├── .babelrc ├── src ├── config │ ├── index.js │ └── config.js ├── router.js ├── bot │ ├── clusters │ │ ├── README.MD │ │ ├── core │ │ │ ├── intents.js │ │ │ ├── decision-tree.js │ │ │ ├── clear.js │ │ │ ├── google-language.js │ │ │ ├── locales.js │ │ │ ├── README.MD │ │ │ ├── index.js │ │ │ └── intents-bigrams.js │ │ ├── minor │ │ │ ├── afairs │ │ │ │ ├── afairs.js │ │ │ │ ├── just-asked-afairs.js │ │ │ │ ├── increase-afairs-count.js │ │ │ │ ├── index.js │ │ │ │ └── decision-tree.js │ │ │ └── greetings │ │ │ │ ├── greet.js │ │ │ │ ├── just-greeted.js │ │ │ │ ├── sarcastic-greet.js │ │ │ │ ├── increase-greet-count.js │ │ │ │ ├── index.js │ │ │ │ └── decision-tree.js │ │ └── happy-path │ │ │ ├── index.js │ │ │ └── decision-tree.js │ └── platforms │ │ ├── messenger │ │ └── bot-name │ │ │ ├── clusters │ │ │ └── core │ │ │ │ ├── seen.js │ │ │ │ ├── typing.js │ │ │ │ ├── user.js │ │ │ │ ├── index.js │ │ │ │ └── api.js │ │ │ ├── README.MD │ │ │ ├── vocabulary.js │ │ │ └── router-builder.js │ │ └── README.MD ├── middlewares │ └── health.js ├── libs │ ├── cluster │ │ └── factory.js │ ├── logger.js │ └── intent-extractor.js └── index.js ├── test ├── helpers │ ├── chai.js │ └── babel-register.js ├── mocha.opts ├── .eslintrc.json └── unit │ └── libs │ └── fixtures │ ├── rules.js │ └── skills.js ├── app.yaml ├── app-dev.yaml ├── .eslintrc.json ├── keys └── google.json ├── nodemon.json ├── webpack.config.babel.js ├── .gitattributes ├── .git-crypt ├── .gitattributes └── keys │ └── default │ └── 0 │ └── 4BD56855A9F03635144D4A56B4378513BB76F6F1.gpg ├── tools ├── webpack │ └── resolve-local.js ├── libs │ └── parse-yaml.sh └── start │ ├── prod.sh │ └── dev.sh ├── newrelic.js ├── .gitignore ├── package.json └── README.MD /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0"] 3 | } 4 | -------------------------------------------------------------------------------- /src/config/index.js: -------------------------------------------------------------------------------- 1 | export { default as default } from './config'; 2 | -------------------------------------------------------------------------------- /test/helpers/chai.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | 3 | chai.should(); 4 | -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evilai/nodejs-bot-platform/HEAD/app.yaml -------------------------------------------------------------------------------- /app-dev.yaml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evilai/nodejs-bot-platform/HEAD/app-dev.yaml -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint-config-evilai" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /keys/google.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evilai/nodejs-bot-platform/HEAD/keys/google.json -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": true, 3 | "ignore": ["src/*", "node_modules", ".git", "nlp"] 4 | } 5 | -------------------------------------------------------------------------------- /src/config/config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | isProduction: process.env.NODE_ENV === 'production' 3 | }; 4 | -------------------------------------------------------------------------------- /src/router.js: -------------------------------------------------------------------------------- 1 | export default function(router, route, builder, config) { 2 | return builder(router, route, config); 3 | } 4 | -------------------------------------------------------------------------------- /webpack.config.babel.js: -------------------------------------------------------------------------------- 1 | require('babel-register'); 2 | 3 | var build = require('./build'); 4 | 5 | module.exports = build; 6 | -------------------------------------------------------------------------------- /src/bot/clusters/README.MD: -------------------------------------------------------------------------------- 1 | # Purpose 2 | This is a place where you add reusable (between different platforms) Skills Clusters and Skills. -------------------------------------------------------------------------------- /src/middlewares/health.js: -------------------------------------------------------------------------------- 1 | import { OK } from 'http-status'; 2 | 3 | export default function(req, res, next) { 4 | res.sendStatus(OK); 5 | } 6 | -------------------------------------------------------------------------------- /test/helpers/babel-register.js: -------------------------------------------------------------------------------- 1 | import babelRegister from 'babel-core/register'; 2 | 3 | babelRegister({ 4 | ignore: /node_modules\/(?!reflecti)/ 5 | }); 6 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | app.yaml filter=git-crypt diff=git-crypt 2 | app-dev.yaml filter=git-crypt diff=git-crypt 3 | /keys/* filter=git-crypt diff=git-crypt 4 | .gitattributes !filter !diff -------------------------------------------------------------------------------- /.git-crypt/.gitattributes: -------------------------------------------------------------------------------- 1 | # Do not edit this file. To specify the files to encrypt, create your own 2 | # .gitattributes file in the directory where your files are. 3 | * !filter !diff 4 | -------------------------------------------------------------------------------- /.git-crypt/keys/default/0/4BD56855A9F03635144D4A56B4378513BB76F6F1.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evilai/nodejs-bot-platform/HEAD/.git-crypt/keys/default/0/4BD56855A9F03635144D4A56B4378513BB76F6F1.gpg -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --reporter spec 2 | --require test/helpers/chai 3 | --require test/helpers/babel-register 4 | --require babel-polyfill 5 | --check-leaks 6 | --recursive 7 | --compilers js:babel-core/register -------------------------------------------------------------------------------- /tools/webpack/resolve-local.js: -------------------------------------------------------------------------------- 1 | function resolveToProjectLevel(deps) { 2 | return Array.isArray(deps) ? 3 | deps.map(require.resolve) : 4 | require.resolve(deps); 5 | } 6 | 7 | module.exports = resolveToProjectLevel; 8 | -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "rules": { 6 | "import/no-extraneous-dependencies": [ 7 | "error", { 8 | "devDependencies": true, 9 | "optionalDependencies": false, 10 | "peerDependencies": false 11 | } 12 | ] 13 | } 14 | } -------------------------------------------------------------------------------- /test/unit/libs/fixtures/rules.js: -------------------------------------------------------------------------------- 1 | import rulesFactory from '../../../../src/libs/rules'; 2 | 3 | export const rules = rulesFactory({ silent: false }); 4 | export const rulesWithNonexistingSkills = rulesFactory({ silent: false, skills: ['nonExistingSkill'] }); 5 | export const rulesWithExistingSkills = rulesFactory({ silent: false, skills: ['delayedSkill'] }); 6 | -------------------------------------------------------------------------------- /src/bot/platforms/messenger/bot-name/clusters/core/seen.js: -------------------------------------------------------------------------------- 1 | import logger from 'logger'; 2 | 3 | export const SKILL_NAME = 'seen'; 4 | 5 | export default function* (session) { 6 | logger.debug(SKILL_NAME.toUpperCase()); 7 | 8 | const { bot, rules } = session; 9 | 10 | yield bot.im(rules).seen(); 11 | 12 | return Promise.resolve(session); 13 | } 14 | -------------------------------------------------------------------------------- /src/bot/platforms/messenger/bot-name/clusters/core/typing.js: -------------------------------------------------------------------------------- 1 | import logger from 'logger'; 2 | 3 | export const SKILL_NAME = 'typing'; 4 | 5 | export default function* (session) { 6 | logger.debug(SKILL_NAME.toUpperCase()); 7 | 8 | const { bot, rules } = session; 9 | yield bot.im(rules).typing(); 10 | 11 | return Promise.resolve(session); 12 | } 13 | -------------------------------------------------------------------------------- /src/libs/cluster/factory.js: -------------------------------------------------------------------------------- 1 | import Cluster from 'nbp-skills-cluster'; 2 | import logger from 'logger'; 3 | import newrelic from 'newrelic'; 4 | 5 | export default function(name, params = {}) { 6 | return new Cluster(name, Object.assign({}, params, { 7 | errorHandler: (error) => { 8 | logger.error(error); 9 | newrelic.noticeError(error); 10 | } 11 | })); 12 | } 13 | -------------------------------------------------------------------------------- /test/unit/libs/fixtures/skills.js: -------------------------------------------------------------------------------- 1 | export const skill = { 2 | name: 'skill', 3 | lambda: function(context) { 4 | return Promise.resolve(context); 5 | } 6 | } 7 | 8 | export const skillDelayed = { 9 | name: 'delayedSkill', 10 | lambda: function(context) { 11 | return new Promise(resolve => setTimeout(() => { 12 | context.done(); 13 | resolve(context); 14 | }, 100)); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/libs/logger.js: -------------------------------------------------------------------------------- 1 | import config from 'config'; 2 | import createLogger, { createExpressLog, createExpressErrorLog } from 'nbp-logger'; 3 | 4 | const LOGGING_LEVEL = process.env.LOGGING_LEVEL || 'debug'; 5 | const colorize = !config.isProduction; 6 | 7 | export default createLogger({ level: LOGGING_LEVEL, colorize }); 8 | export const expressLog = createExpressLog({ colorize }); 9 | export const expressErrorLog = createExpressErrorLog({ colorize }); 10 | -------------------------------------------------------------------------------- /newrelic.js: -------------------------------------------------------------------------------- 1 | const NEW_RELIC_APP_NAME = process.env.NEW_RELIC_APP_NAME; 2 | const NEW_RELIC_LICENSE = process.env.NEW_RELIC_LICENSE || 'debug'; 3 | const LOGGING_LEVEL = process.env.LOGGING_LEVEL; 4 | 5 | console.log(`Newrelic started to log application: ${NEW_RELIC_APP_NAME}`); 6 | 7 | exports.config = { 8 | app_name: [NEW_RELIC_APP_NAME], 9 | license_key: NEW_RELIC_LICENSE, 10 | logging: { 11 | level: LOGGING_LEVEL 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs and databases # 2 | ###################### 3 | *.log 4 | *.sql 5 | *.sqlite 6 | npm-debug* 7 | 8 | # OS generated files # 9 | ###################### 10 | .DS_Store? 11 | .DS_Store 12 | Icon? 13 | 14 | # NPM Installed stuff # 15 | ####################### 16 | 17 | **/node_modules/ 18 | __ 19 | /.idea/ 20 | .sass-cache 21 | lib 22 | dist 23 | lib 24 | /dump.rdb 25 | **/*.map 26 | *.gypi 27 | 28 | # Crypt files # 29 | ####################### 30 | -------------------------------------------------------------------------------- /src/bot/clusters/core/intents.js: -------------------------------------------------------------------------------- 1 | import logger from 'logger'; 2 | 3 | export const KEY = 'witIntents'; 4 | export const SKILL_NAME = 'intents'; 5 | 6 | export default function* (session) { 7 | const { bot } = session; 8 | 9 | logger.debug(SKILL_NAME.toUpperCase()); 10 | 11 | const intent = yield bot.wit.send(bot.message.text); 12 | yield bot.memcached.set(KEY, intent); 13 | 14 | logger.debug(intent); 15 | 16 | return Promise.resolve(session); 17 | } 18 | -------------------------------------------------------------------------------- /src/bot/clusters/minor/afairs/afairs.js: -------------------------------------------------------------------------------- 1 | import logger from 'logger'; 2 | 3 | import { CLUSTER_NAME } from './index'; 4 | import { KEY as USER_KEY } from '../../../platforms/messenger/bot-name/clusters/core/user'; 5 | 6 | export const SKILL_NAME = 'afairs'; 7 | 8 | export default function* (session) { 9 | logger.debug(SKILL_NAME.toUpperCase()); 10 | 11 | const { bot, rules } = session; 12 | 13 | const { first_name } = yield bot.memcached.get(USER_KEY); 14 | yield bot.im(rules).send(bot.locales(`${CLUSTER_NAME}.${SKILL_NAME}`, first_name)); 15 | 16 | return Promise.resolve(session); 17 | } 18 | -------------------------------------------------------------------------------- /src/bot/clusters/minor/greetings/greet.js: -------------------------------------------------------------------------------- 1 | import logger from 'logger'; 2 | 3 | import { CLUSTER_NAME } from './index'; 4 | import { KEY as USER_KEY } from '../../../platforms/messenger/bot-name/clusters/core/user'; 5 | 6 | export const SKILL_NAME = 'greet'; 7 | 8 | export default function* (session) { 9 | logger.debug(SKILL_NAME.toUpperCase()); 10 | 11 | const { bot, rules } = session; 12 | 13 | const { first_name } = yield bot.memcached.get(USER_KEY); 14 | yield bot.im(rules).send(bot.locales(`${CLUSTER_NAME}.${SKILL_NAME}`, first_name)); 15 | 16 | return Promise.resolve(session); 17 | } 18 | -------------------------------------------------------------------------------- /src/bot/clusters/minor/greetings/just-greeted.js: -------------------------------------------------------------------------------- 1 | import logger from 'logger'; 2 | 3 | import { CLUSTER_NAME } from './index'; 4 | import { KEY as USER_KEY } from '../../../platforms/messenger/bot-name/clusters/core/user'; 5 | 6 | export const SKILL_NAME = 'justGreeted'; 7 | 8 | export default function* (session) { 9 | logger.debug(SKILL_NAME.toUpperCase()); 10 | 11 | const { bot, rules } = session; 12 | const { first_name } = yield bot.memcached.get(USER_KEY); 13 | 14 | yield bot.im(rules).send(bot.locales(`${CLUSTER_NAME}.${SKILL_NAME}`, first_name)); 15 | 16 | return Promise.resolve(session); 17 | } 18 | -------------------------------------------------------------------------------- /src/bot/clusters/minor/greetings/sarcastic-greet.js: -------------------------------------------------------------------------------- 1 | import logger from 'logger'; 2 | 3 | import { CLUSTER_NAME } from './index'; 4 | import { KEY as USER_KEY } from '../../../platforms/messenger/bot-name/clusters/core/user'; 5 | 6 | export const SKILL_NAME = 'sarcaticGreet'; 7 | 8 | export default function* (session) { 9 | logger.debug(SKILL_NAME.toUpperCase()); 10 | 11 | const { bot, rules } = session; 12 | const { first_name } = yield bot.memcached.get(USER_KEY); 13 | 14 | yield bot.im(rules).send(bot.locales(`${CLUSTER_NAME}.${SKILL_NAME}`, first_name)); 15 | 16 | return Promise.resolve(session); 17 | } 18 | -------------------------------------------------------------------------------- /src/bot/clusters/minor/afairs/just-asked-afairs.js: -------------------------------------------------------------------------------- 1 | import logger from 'logger'; 2 | 3 | import { CLUSTER_NAME } from './index'; 4 | import { KEY as USER_KEY } from '../../../platforms/messenger/bot-name/clusters/core/user'; 5 | 6 | export const SKILL_NAME = 'justAskedAfairs'; 7 | 8 | export default function* (session) { 9 | logger.debug(SKILL_NAME.toUpperCase()); 10 | 11 | const { bot, rules } = session; 12 | const { first_name } = yield bot.memcached.get(USER_KEY); 13 | 14 | yield bot.im(rules).send(bot.locales(`${CLUSTER_NAME}.${SKILL_NAME}`, first_name)); 15 | 16 | return Promise.resolve(session); 17 | } 18 | -------------------------------------------------------------------------------- /src/bot/platforms/README.MD: -------------------------------------------------------------------------------- 1 | # Purpose 2 | Here you should add all Skills Clusters and Skills, that specific for different platforms, like Facebook Messenger or Amazon Alexa. 3 | 4 | For example you have a bot **witcher** and you want this bot on FB Messenger, Alexa and Google Assistant. So your folders here should be organized like this: 5 | ``` 6 | /src/bot/platforms/messenger/witcher/ 7 | /src/bot/platforms/amazon-alexa/witcher/ 8 | /src/bot/platforms/google-assistant/witcher/ 9 | 10 | // And all reusable skills and clusters should be outside this folder 11 | /src/bot/skills/clusters/reusableCluster1 12 | /src/bot/skills/clusters/reusableCluster2 13 | ``` -------------------------------------------------------------------------------- /src/bot/clusters/core/decision-tree.js: -------------------------------------------------------------------------------- 1 | import { SKILL_NAME as LANGUAGE_SKILL_NAME } from './google-language'; 2 | import { SKILL_NAME as INTENTS_SKILL_NAME } from './intents'; 3 | import { SKILL_NAME as INTENTS_BIGRAMS_SKILL_NAME } from './intents-bigrams'; 4 | import { SKILL_NAME as CLEAR_SKILL_NAME } from './clear'; 5 | import { SKILL_NAME as LOCALES_SKILL_NAME } from './locales'; 6 | import { CLUSTER_NAME as HAPPY_PATH_CLUSTER_NAME } from '../happy-path'; 7 | 8 | export default function*() { 9 | return Promise.resolve([CLEAR_SKILL_NAME, LANGUAGE_SKILL_NAME, LOCALES_SKILL_NAME, INTENTS_SKILL_NAME, INTENTS_BIGRAMS_SKILL_NAME, HAPPY_PATH_CLUSTER_NAME]); 10 | } 11 | -------------------------------------------------------------------------------- /src/bot/clusters/minor/greetings/increase-greet-count.js: -------------------------------------------------------------------------------- 1 | import logger from 'logger'; 2 | 3 | export const KEY = 'greetCount'; 4 | export const SKILL_NAME = 'increaseGreetCount'; 5 | 6 | export default function* (session) { 7 | logger.debug(SKILL_NAME.toUpperCase()); 8 | 9 | const { bot, rules } = session; 10 | 11 | if (!rules.get('silent')) { 12 | const greetCount = yield bot.memcached.get(KEY); 13 | 14 | if (!greetCount) { 15 | yield bot.memcached.set(KEY, 1); 16 | } else { 17 | yield bot.memcached.set(KEY, greetCount + 1); 18 | } 19 | } 20 | 21 | return Promise.resolve(session); 22 | } 23 | -------------------------------------------------------------------------------- /src/bot/clusters/minor/afairs/increase-afairs-count.js: -------------------------------------------------------------------------------- 1 | import logger from 'logger'; 2 | 3 | export const KEY = 'afairsCount'; 4 | export const SKILL_NAME = 'increaseAfairsCount'; 5 | 6 | export default function* (session) { 7 | logger.debug(SKILL_NAME.toUpperCase()); 8 | 9 | const { bot, rules } = session; 10 | 11 | if (!rules.get('silent')) { 12 | const afairsCount = yield bot.memcached.get(KEY); 13 | 14 | if (!afairsCount) { 15 | yield bot.memcached.set(KEY, 1); 16 | } else { 17 | yield bot.memcached.set(KEY, afairsCount + 1); 18 | } 19 | } 20 | 21 | return Promise.resolve(session); 22 | } 23 | -------------------------------------------------------------------------------- /src/bot/platforms/messenger/bot-name/clusters/core/user.js: -------------------------------------------------------------------------------- 1 | import logger from 'logger'; 2 | 3 | import getUser from './api'; 4 | 5 | export const KEY = 'user'; 6 | export const SKILL_NAME = 'user'; 7 | 8 | export default function* (session) { 9 | logger.debug(SKILL_NAME.toUpperCase()); 10 | const { bot } = session; 11 | const existingUser = yield bot.memcached.get(KEY); 12 | 13 | if (!existingUser) { 14 | const user = yield getUser(bot.sender.id); 15 | yield bot.memcached.set(KEY, user); 16 | logger.debug(user); 17 | } else { 18 | logger.debug(existingUser); 19 | } 20 | 21 | return Promise.resolve(session); 22 | } 23 | -------------------------------------------------------------------------------- /src/bot/clusters/core/clear.js: -------------------------------------------------------------------------------- 1 | import logger from 'logger'; 2 | 3 | import { KEY as LANGUAGE_KEY } from './google-language'; 4 | import { KEY as INTENTS_KEY } from './intents'; 5 | import { KEY as INTENTS_BIGRAMS_KEY } from './intents-bigrams'; 6 | 7 | export const SKILL_NAME = 'clear'; 8 | 9 | const SKILLS_TO_CLEAR = [ 10 | LANGUAGE_KEY, 11 | INTENTS_KEY, 12 | INTENTS_BIGRAMS_KEY 13 | ]; 14 | 15 | export default function* (session) { 16 | const { bot, rules } = session; 17 | 18 | logger.debug(SKILL_NAME.toUpperCase()); 19 | yield Promise.all(SKILLS_TO_CLEAR.map(skillName => bot.memcached.del(skillName))); 20 | 21 | logger.debug('cleared', SKILLS_TO_CLEAR); 22 | 23 | return Promise.resolve(session); 24 | } 25 | -------------------------------------------------------------------------------- /src/bot/clusters/core/google-language.js: -------------------------------------------------------------------------------- 1 | import logger from 'logger'; 2 | 3 | export const SKILL_NAME = 'googleLanguage'; 4 | export const KEY = 'languageAnnotated'; 5 | 6 | export default function* (session) { 7 | const { bot } = session; 8 | logger.debug(SKILL_NAME.toUpperCase()); 9 | 10 | const annotated = yield bot.googleLanguage.annotate(bot.message.text, { 11 | verbose: true, 12 | features: { 13 | extractSyntax: true, 14 | extractEntities: true, 15 | extractDocumentSentiment: true 16 | } 17 | }); 18 | 19 | yield bot.memcached.set(KEY, annotated); 20 | 21 | logger.debug('Annotated', annotated); 22 | 23 | return Promise.resolve(session); 24 | } 25 | -------------------------------------------------------------------------------- /src/bot/clusters/core/locales.js: -------------------------------------------------------------------------------- 1 | import logger from 'logger'; 2 | import { KEY as GOOGLE_LANGUAGE_KEY } from './google-language'; 3 | 4 | export const SKILL_NAME = 'locales'; 5 | 6 | export default function* (session) { 7 | logger.debug(SKILL_NAME.toUpperCase()); 8 | 9 | const { bot, rules } = session; 10 | const { language } = yield bot.memcached.get(GOOGLE_LANGUAGE_KEY); 11 | 12 | if (!rules.get('getLocales')) { 13 | // TODO: Show the link to documentation 14 | return Promise.reject('No cpecified getLocales in rules.'); 15 | } 16 | 17 | bot.locales = rules.get('getLocales')(language); 18 | 19 | logger.debug('Language setted to', language); 20 | 21 | return Promise.resolve(session); 22 | } 23 | -------------------------------------------------------------------------------- /src/bot/clusters/core/README.MD: -------------------------------------------------------------------------------- 1 | # Cluster 2 | This is a typical Skills Cluster folder. In folders like this you should keep skills, related only to this Skills Cluster. 3 | 4 | #### index.js 5 | Here we create a Cluster, plug all skills, build decision tree and run tree traversal procedure. 6 | 7 | #### decision-tree.js 8 | Here we build a part of global decision tree. In fact we build a queue of skills, related for this cluster. To do this I use State Machine ([finity](https://github.com/nickuraltsev/finity)), because it can be not trivial procedure. After that you should run cluster traversal, so you will go through all skills in the queue one after another. 9 | 10 | ## Other files 11 | Other files are skills, related to this Skills Cluster. -------------------------------------------------------------------------------- /tools/libs/parse-yaml.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # vim: set ft=sh: 4 | # 5 | # Based on https://gist.github.com/pkuczynski/8665367 6 | 7 | parse_yaml() { 8 | local prefix=$2 9 | local s 10 | local w 11 | local fs 12 | s='[[:space:]]*' 13 | w='[a-zA-Z0-9_]*' 14 | fs="$(echo @|tr @ '\034')" 15 | sed -ne "s|^\($s\)\($w\)$s:$s\"\(.*\)\"$s\$|\1$fs\2$fs\3|p" \ 16 | -e "s|^\($s\)\($w\)$s[:-]$s\(.*\)$s\$|\1$fs\2$fs\3|p" "$1" | 17 | awk -F"$fs" '{ 18 | indent = length($1)/2; 19 | vname[indent] = $2; 20 | for (i in vname) {if (i > indent) {delete vname[i]}} 21 | if (length($3) > 0) { 22 | vn=""; for (i=0; i { 7 | const intent = intentsList[intentName]; 8 | 9 | if (!isEmpty(intent)) { 10 | intent.forEach(({ confidence, value }) => { 11 | if (minConfidence <= confidence) { 12 | acc.push(value); 13 | } 14 | }); 15 | } 16 | 17 | return acc; 18 | }, []); 19 | } 20 | 21 | export function extractIntentsBigrams(intentsToExtract, intentsBigrams = []) { 22 | return intentsBigrams.reduce((acc, intentsList) => { 23 | acc = acc.concat(extractIntents(intentsToExtract, intentsList)); 24 | return acc; 25 | }, []); 26 | } 27 | -------------------------------------------------------------------------------- /tools/start/dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Let's make the script more robust: 4 | # -e: fail fast if any command in the script fails 5 | # -u: check that all variables used in this script are set (if not, exit) 6 | # -o pipefail: fail even faster, if an error occures in a pipeline 7 | set -eu -o pipefail 8 | 9 | # include parse_yaml function 10 | . ./tools/libs/parse-yaml.sh 11 | 12 | # read yaml file 13 | eval $(parse_yaml app-dev.yaml "") 14 | 15 | NEW_RELIC_APP_NAME=$env_variables_NEW_RELIC_APP_NAME NEW_RELIC_LICENSE=$env_variables_NEW_RELIC_LICENSE LOGGING_LEVEL=$env_variables_LOGGING_LEVEL APP_WIT_TOKEN=$env_variables_APP_WIT_TOKEN APP_WIT_VERSION=$env_variables_APP_WIT_VERSION GOOGLE_PROJECT_ID=$env_variables_GOOGLE_PROJECT_ID MEMCACHE_PORT_11211_TCP_ADDR=$env_variables_MEMCACHE_PORT_11211_TCP_ADDR MEMCACHE_PORT_11211_TCP_PORT=$env_variables_MEMCACHE_PORT_11211_TCP_PORT FACEBOOK_PAGE_TOKEN=$env_variables_FACEBOOK_PAGE_TOKEN NODE_ENV=development nodemon dist/index.js 16 | -------------------------------------------------------------------------------- /src/bot/clusters/happy-path/index.js: -------------------------------------------------------------------------------- 1 | import logger from 'logger'; 2 | import clusterCreate from 'skills-cluster'; 3 | import decisionTreeBuilder from './decision-tree'; 4 | 5 | import greetingsCluster, { CLUSTER_NAME as GREETINGS_CLUSTER_NAME } from '../minor/greetings'; 6 | import afairsCluster, { CLUSTER_NAME as AFAIRS_CLUSTER_NAME } from '../minor/afairs'; 7 | 8 | export const CLUSTER_NAME = 'happyPath'; 9 | 10 | const cluster = clusterCreate(CLUSTER_NAME); 11 | const skills = [ 12 | { 13 | name: GREETINGS_CLUSTER_NAME, 14 | lambda: greetingsCluster 15 | }, 16 | { 17 | name: AFAIRS_CLUSTER_NAME, 18 | lambda: afairsCluster 19 | } 20 | ]; 21 | 22 | cluster.plug(skills); 23 | 24 | export default function* (session) { 25 | logger.debug(`[ ${CLUSTER_NAME.toUpperCase()} ]`); 26 | 27 | return cluster 28 | .buildDecisionTree(decisionTreeBuilder(session)) 29 | .then(tree => 30 | cluster 31 | .traverse(tree, session) 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | require('newrelic'); 2 | import express from 'express'; 3 | import bodyParser from 'body-parser'; 4 | import logger, { expressLog, expressErrorLog } from 'logger'; 5 | 6 | import healthMiddleware from './middlewares/health'; 7 | import buildRoute from './router'; 8 | 9 | // This is your bot's router 10 | import yourBotMessengerRouter from './bot/platforms/messenger/bot-name/router-builder'; 11 | 12 | const IS_PRODUCTION = process.env.NODE_ENV === 'production'; 13 | if (!IS_PRODUCTION) { 14 | require('source-map-support/register'); 15 | } 16 | 17 | const app = express(); 18 | const router = express.Router(); 19 | const server = require('http').Server(app); 20 | 21 | app.use(bodyParser.json()); 22 | app.use(bodyParser.urlencoded({ extended: true })); 23 | app.use(expressLog); 24 | app.use(expressErrorLog); 25 | 26 | // Health check endpoint, '/_ah/health' required for Google App Engine 27 | app.use('/_ah/health', healthMiddleware); 28 | 29 | // This route should be in the messenger webhook 30 | buildRoute(router, '/messenger/yourbot', yourBotMessengerRouter, {}); 31 | 32 | app.use(router); 33 | 34 | const listener = server.listen(process.env.PORT || 8080, () => { 35 | logger.debug(`Server started at port ${listener.address().port}`); 36 | }); 37 | -------------------------------------------------------------------------------- /src/bot/clusters/minor/afairs/index.js: -------------------------------------------------------------------------------- 1 | import logger from 'logger'; 2 | import clusterCreate from 'skills-cluster'; 3 | 4 | import decisionTreeBuilder from './decision-tree'; 5 | 6 | import afairsSkill, { SKILL_NAME as AFAIRS_SKILL_NAME } from './afairs'; 7 | import justAskedAfairsSkill, { SKILL_NAME as JUST_ASKED_AFAIRS_SKILL_NAME } from './just-asked-afairs'; 8 | import increaseAfairsCountSkill, { SKILL_NAME as INCREASE_AFAIRS_COUNT_SKILL_NAME } from './increase-afairs-count'; 9 | 10 | export const CLUSTER_NAME = 'afairs'; 11 | 12 | const cluster = clusterCreate(CLUSTER_NAME); 13 | const skills = [ 14 | { 15 | name: AFAIRS_SKILL_NAME, 16 | lambda: afairsSkill 17 | }, 18 | { 19 | name: JUST_ASKED_AFAIRS_SKILL_NAME, 20 | lambda: justAskedAfairsSkill 21 | }, 22 | { 23 | name: INCREASE_AFAIRS_COUNT_SKILL_NAME, 24 | lambda: increaseAfairsCountSkill 25 | } 26 | ]; 27 | 28 | cluster.plug(skills); 29 | 30 | export default function* (session) { 31 | logger.debug(`[ ${CLUSTER_NAME.toUpperCase()} ]`); 32 | 33 | return cluster 34 | .buildDecisionTree(decisionTreeBuilder(session)) 35 | .then(tree => 36 | cluster 37 | .traverse(tree, session) 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/bot/platforms/messenger/bot-name/clusters/core/index.js: -------------------------------------------------------------------------------- 1 | import logger from 'logger'; 2 | import clusterCreate from 'skills-cluster'; 3 | 4 | import seenSkill, { SKILL_NAME as SEEN_SKILL_NAME } from './seen'; 5 | import typingSkill, { SKILL_NAME as TYPING_SKILL_NAME } from './typing'; 6 | import userSkill, { SKILL_NAME as USER_SKILL_NAME } from './user'; 7 | import coreCluster, { CLUSTER_NAME as CORE_CLUSTER_NAME } from '../../../../../clusters/core'; 8 | 9 | // Don't forget to export name, you should use it in decision tree builders 10 | export const CLUSTER_NAME = 'messengerCore'; 11 | 12 | const cluster = clusterCreate(CLUSTER_NAME); 13 | const skills = [ 14 | { 15 | name: SEEN_SKILL_NAME, 16 | lambda: seenSkill 17 | }, 18 | { 19 | name: TYPING_SKILL_NAME, 20 | lambda: typingSkill 21 | }, 22 | { 23 | name: USER_SKILL_NAME, 24 | lambda: userSkill 25 | }, 26 | { 27 | name: CORE_CLUSTER_NAME, 28 | lambda: coreCluster 29 | } 30 | ]; 31 | 32 | cluster.plug(skills); 33 | 34 | export default function* (session) { 35 | logger.debug(`[ ${CLUSTER_NAME.toUpperCase()} ]`); 36 | 37 | // Here we know exactly the order of skills, so we don't need to build decision tree. 38 | return cluster.traverse([SEEN_SKILL_NAME, TYPING_SKILL_NAME, USER_SKILL_NAME, CORE_CLUSTER_NAME], session); 39 | } 40 | -------------------------------------------------------------------------------- /src/bot/platforms/messenger/bot-name/clusters/core/api.js: -------------------------------------------------------------------------------- 1 | import pick from 'lodash/pick'; 2 | import logger from 'logger'; 3 | import superagent from 'superagent'; 4 | import { OK } from 'http-status'; 5 | 6 | const LOG_REQUEST_FIELDS = ['method', 'url', 'qs', 'header']; 7 | const FACEBOOK_PAGE_TOKEN = process.env.FACEBOOK_PAGE_TOKEN; 8 | 9 | export default function(id) { 10 | return new Promise((resolve, reject) => 11 | superagent 12 | .get(`https://graph.facebook.com/v2.6/${id}?fields=first_name,last_name,profile_pic,locale,timezone,gender&access_token=${FACEBOOK_PAGE_TOKEN}`) 13 | .use(request => { 14 | logger.info('Request -->', pick(request, LOG_REQUEST_FIELDS)); 15 | return request; 16 | }) 17 | .then( 18 | result => { 19 | if (result.status === OK) { 20 | const data = JSON.parse(result.text); 21 | logger.info('Response <--', data); 22 | return resolve(data); 23 | } 24 | 25 | logger.error(result.error); 26 | return reject(result.error); 27 | }, 28 | error => { 29 | logger.error(error); 30 | return reject(error); 31 | } 32 | ) 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/bot/platforms/messenger/bot-name/README.MD: -------------------------------------------------------------------------------- 1 | # Your bot folder 2 | This is folder where you will specify unique things for your bot: skill clusters, skills, bot's vocabulary and unique userflows. 3 | 4 | ## File system 5 | There are two files in the root: 6 | * `router-builder.js` – here we assemble all unique for the bot services and technologies that should be used by bot. 7 | * `vocabulary.js` – pretty self explanatory. 8 | 9 | ### Router Builder (router-builder.js) 10 | Here we get _router_ instance and string _route_. We add all services with help of express middlewares. So if you don't need **wit** for your bot, just remove it from the list of middlewares for the _POST_ request. 11 | 12 | And if you want to add something new (for example a new service, that will store data), this is right place to do it. 13 | 14 | Also if you want to perform some global operations, like set "message recieved", this is also good place to add this logic. 15 | 16 | ## Unique skill clusters and skills 17 | If you have some unique (not reusable) skills, this is a good place to store them. For example you implement skills, that allow you to send receipt to the user. This is (more or less) unique for Facebook Messenger, so you should add this skill (or cluster) here `/src/bot/platforms/messenger/bot-name/skills`. 18 | 19 | Also this is a good place to store **Core cluster**, which build the happy path user flow. -------------------------------------------------------------------------------- /src/bot/clusters/minor/greetings/index.js: -------------------------------------------------------------------------------- 1 | import logger from 'logger'; 2 | import clusterCreate from 'skills-cluster'; 3 | 4 | import decisionTreeBuilder from './decision-tree'; 5 | 6 | import greetSkill, { SKILL_NAME as GREET_SKILL_NAME } from './greet'; 7 | import justGreetedSkill, { SKILL_NAME as JUST_GREETED_SKILL_NAME } from './just-greeted'; 8 | import sarcaticGreetSkill, { SKILL_NAME as SARCASTIC_GREET_SKILL_NAME } from './sarcastic-greet'; 9 | import increaseGreetCountSkill, { SKILL_NAME as INCREASE_GREET_COUNT_SKILL_NAME } from './increase-greet-count'; 10 | 11 | export const CLUSTER_NAME = 'greetings'; 12 | 13 | const cluster = clusterCreate(CLUSTER_NAME); 14 | const skills = [ 15 | { 16 | name: GREET_SKILL_NAME, 17 | lambda: greetSkill 18 | }, 19 | { 20 | name: JUST_GREETED_SKILL_NAME, 21 | lambda: justGreetedSkill 22 | }, 23 | { 24 | name: SARCASTIC_GREET_SKILL_NAME, 25 | lambda: sarcaticGreetSkill 26 | }, 27 | { 28 | name: INCREASE_GREET_COUNT_SKILL_NAME, 29 | lambda: increaseGreetCountSkill 30 | } 31 | ]; 32 | 33 | cluster.plug(skills); 34 | 35 | export default function* (session) { 36 | logger.debug(`[ ${CLUSTER_NAME.toUpperCase()} ]`); 37 | 38 | return cluster 39 | .buildDecisionTree(decisionTreeBuilder(session)) 40 | .then(tree => 41 | cluster 42 | .traverse(tree, session) 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/bot/clusters/core/index.js: -------------------------------------------------------------------------------- 1 | import logger from 'logger'; 2 | import clusterCreate from 'skills-cluster'; 3 | import decisionTreeBuilder from './decision-tree'; 4 | 5 | import clearSkill, { SKILL_NAME as CLEAR_SKILL_NAME } from './clear'; 6 | import languageSkill, { SKILL_NAME as LANGUAGE_SKILL_NAME } from './google-language'; 7 | import intentsSkill, { SKILL_NAME as INTENTS_SKILL_NAME } from './intents'; 8 | import localesSkill, { SKILL_NAME as LOCALES_SKILL_NAME } from './locales'; 9 | import intentsBigramsSkill, { SKILL_NAME as INTENTS_BIGRAMS_SKILL_NAME } from './intents-bigrams'; 10 | import happyPath, { CLUSTER_NAME as HAPPY_PATH_CLUSTER_NAME } from '../happy-path'; 11 | 12 | export const CLUSTER_NAME = 'CORE'; 13 | 14 | const cluster = clusterCreate(CLUSTER_NAME); 15 | const skills = [ 16 | { 17 | name: CLEAR_SKILL_NAME, 18 | lambda: clearSkill 19 | }, 20 | { 21 | name: LANGUAGE_SKILL_NAME, 22 | lambda: languageSkill 23 | }, 24 | { 25 | name: INTENTS_SKILL_NAME, 26 | lambda: intentsSkill 27 | }, 28 | { 29 | name: INTENTS_BIGRAMS_SKILL_NAME, 30 | lambda: intentsBigramsSkill 31 | }, 32 | { 33 | name: HAPPY_PATH_CLUSTER_NAME, 34 | lambda: happyPath 35 | }, 36 | { 37 | name: LOCALES_SKILL_NAME, 38 | lambda: localesSkill 39 | } 40 | ]; 41 | 42 | cluster.plug(skills); 43 | 44 | export default function(session) { 45 | logger.debug(`[ ${CLUSTER_NAME.toUpperCase()} ]`); 46 | 47 | return cluster 48 | .buildDecisionTree(decisionTreeBuilder) 49 | .then(tree => 50 | cluster 51 | .traverse(tree, session) 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/bot/clusters/core/intents-bigrams.js: -------------------------------------------------------------------------------- 1 | import logger from 'logger'; 2 | import words from 'lodash/words'; 3 | import { KEY as ANNOTATED_LANGUAGE } from './google-language'; 4 | 5 | export const KEY = 'witIntentsBigrams'; 6 | export const SKILL_NAME = 'intents-bigrams'; 7 | 8 | const RESTRICTED_IN_BIGRAMS = ['ADP', 'PUNCT']; 9 | const MIN_WORDS_COUNT = 4; 10 | 11 | const notRestrictedToBegin = token => !~RESTRICTED_IN_BIGRAMS.indexOf(token.partOfSpeech.tag); 12 | const getConnected = ({ tokens }, token) => tokens[token.dependencyEdge.headTokenIndex]; 13 | const notUsed = (used, index) => !~used.indexOf(index); 14 | 15 | export default function* (session) { 16 | const { bot } = session; 17 | 18 | logger.debug(SKILL_NAME.toUpperCase()); 19 | 20 | if (words(bot.message.text).length >= MIN_WORDS_COUNT) { 21 | const annotated = yield bot.memcached.get(ANNOTATED_LANGUAGE); 22 | 23 | const used = []; 24 | 25 | // Example algorithm, made in 10 minutes 26 | // TODO: implement something better than this 27 | const bigramms = annotated.tokens.reduce((acc, token, index) => { 28 | if (notRestrictedToBegin(token) && notUsed(used, index) && notUsed(used, token.dependencyEdge.headTokenIndex) && index !== token.dependencyEdge.headTokenIndex) { 29 | used.push(index, token.dependencyEdge.headTokenIndex); 30 | acc.push([token.lemma, getConnected(annotated, token).lemma]); 31 | } 32 | return acc; 33 | }, []); 34 | 35 | const intentsList = yield Promise.all(bigramms.map(gramm => bot.wit.send(gramm.join(' ')))); 36 | yield bot.memcached.set(KEY, intentsList); 37 | 38 | logger.debug('bigrams', bigramms); 39 | logger.debug('intents', intentsList); 40 | } else { 41 | logger.debug(`skipped, minimum words count for bigrams is ${MIN_WORDS_COUNT}`); 42 | } 43 | 44 | return Promise.resolve(session); 45 | } 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bot", 3 | "version": "0.0.1", 4 | "description": "Node.js Bot platform. Boilerplate, start point, tools, best practices for building bots.", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build:dev": "webpack", 8 | "build:dev:watch": "webpack --watch", 9 | "start:dev": "./tools/start/dev.sh", 10 | "start": "./tools/start/prod.sh", 11 | "test": "mocha" 12 | }, 13 | "author": "Maxim Vetrenko (https://github.com/maxmert)", 14 | "license": "ISC", 15 | "devDependencies": { 16 | "babel-core": "^6.3.21", 17 | "babel-eslint": "^5.0.4", 18 | "babel-loader": "^6.2.0", 19 | "babel-plugin-transform-runtime": "^6.3.13", 20 | "babel-polyfill": "^6.16.0", 21 | "babel-preset-es2015": "^6.3.13", 22 | "babel-preset-stage-0": "^6.16.0", 23 | "babel-register": "^6.16.3", 24 | "chai": "^3.5.0", 25 | "eslint": "^3.5.0", 26 | "eslint-config-airbnb": "^12.0.0", 27 | "eslint-config-evilai": "^0.0.1", 28 | "eslint-import-resolver-webpack": "^0.6.0", 29 | "eslint-plugin-babel": "^3.3.0", 30 | "eslint-plugin-import": "^2.0.1", 31 | "eslint-plugin-jsx-a11y": "^2.2.3", 32 | "eslint-plugin-react": "^6.4.1", 33 | "mocha": "^3.1.2", 34 | "source-map-support": "^0.4.3", 35 | "webpack": "^1.8.4", 36 | "webpack-dev-server": "^1.8.0" 37 | }, 38 | "dependencies": { 39 | "body-parser": "^1.15.2", 40 | "co": "^4.6.0", 41 | "express": "^4.14.0", 42 | "finity": "^0.4.5", 43 | "lodash": "^4.16.4", 44 | "nbp-normaliser-fb-messenger": "0.0.1", 45 | "nbp-locales": "0.0.2", 46 | "nbp-adapter-google-datastore": "0.0.2", 47 | "nbp-adapter-google-natural-language": "0.0.2", 48 | "nbp-adapter-memcached": "0.0.2", 49 | "nbp-adapter-wit": "0.0.2", 50 | "nbp-logger": "0.0.1", 51 | "nbp-adapter-fb-messenger": "0.0.2", 52 | "nbp-rules": "0.0.1", 53 | "nbp-skills-cluster": "0.0.1", 54 | "newrelic": "^1.32.0" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/bot/clusters/minor/afairs/decision-tree.js: -------------------------------------------------------------------------------- 1 | import logger from 'logger'; 2 | import Finity from 'finity'; 3 | 4 | import { SKILL_NAME as AFAIRS_SKILL_NAME } from './afairs'; 5 | import { SKILL_NAME as JUST_ASKED_AFAIRS_SKILL_NAME } from './just-asked-afairs'; 6 | import { 7 | SKILL_NAME as INCREASE_AFAIRS_COUNT_SKILL_NAME, 8 | KEY as AFAIRS_COUNT_KEY 9 | } from './increase-afairs-count'; 10 | 11 | // Constants for State Machine 12 | const INITIAL_STATE = 'uninitialized'; 13 | const AFAIRS_STATE = 'afairs'; 14 | const JUST_ASKED_AFAIRS_STATE = 'just asked afairs'; 15 | const FINISH_STATE = 'stop building queue'; 16 | 17 | const START_EVENT = 'start'; 18 | const FINAL_EVENT = 'finish'; 19 | 20 | const getStateMachine = (resolve, greetCount = 0) => { 21 | const queue = []; 22 | 23 | return Finity 24 | .configure() 25 | .global() 26 | .onStateEnter(state => logger.debug(`--> ${state}`)) 27 | .initialState(INITIAL_STATE) 28 | .on(START_EVENT) 29 | .transitionTo(AFAIRS_STATE) 30 | .withCondition(() => greetCount < 1) 31 | .transitionTo(JUST_ASKED_AFAIRS_STATE) 32 | .withCondition(() => greetCount >= 1) 33 | 34 | .state(AFAIRS_STATE) 35 | .onEnter((state, { stateMachine }) => { 36 | logger.debug(`----- + ${AFAIRS_SKILL_NAME}`); 37 | queue.push(AFAIRS_SKILL_NAME); 38 | stateMachine.handle(FINAL_EVENT); 39 | }) 40 | .on(FINAL_EVENT) 41 | .transitionTo(FINISH_STATE) 42 | 43 | .state(JUST_ASKED_AFAIRS_STATE) 44 | .onEnter((state, { stateMachine }) => { 45 | logger.debug(`----- + ${JUST_ASKED_AFAIRS_SKILL_NAME}`); 46 | queue.push(JUST_ASKED_AFAIRS_SKILL_NAME); 47 | stateMachine.handle(FINAL_EVENT); 48 | }) 49 | .on(FINAL_EVENT) 50 | .transitionTo(FINISH_STATE) 51 | 52 | .state(FINISH_STATE) 53 | .onEnter(() => { 54 | queue.push(INCREASE_AFAIRS_COUNT_SKILL_NAME); 55 | logger.debug('<--', queue); 56 | resolve(queue); 57 | }); 58 | }; 59 | 60 | export default function(session) { 61 | logger.debug('Build decision tree'); 62 | 63 | const { bot } = session; 64 | 65 | return function* () { 66 | // Here we get data from the Core cluster to pass it to the state machine 67 | const data = yield bot.memcached.get(AFAIRS_COUNT_KEY); 68 | 69 | // Promise should resolve an array of skills/clusters 70 | return new Promise(resolve => { 71 | const stateMachine = getStateMachine(resolve, data).start(); 72 | stateMachine.handle(START_EVENT); 73 | }); 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /src/bot/platforms/messenger/bot-name/vocabulary.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is a vocabulary for paticular bot. 3 | * I've decided to keep all vocabulary in one place, 4 | * but it you have a deployable skill cluster, 5 | * then you need to separate this file and use 6 | * vocabulary partials. 7 | * 8 | * How is better to structure vocabulary: 9 | * { 10 | * language1: { 11 | * clusterName: { 12 | * skillName: { 13 | * key: [ 14 | * 'Text 1', 15 | * 'Text 2' 16 | * ] 17 | * } 18 | * } 19 | * }, 20 | * 21 | * language2: { 22 | * ... 23 | * } 24 | * } 25 | * 26 | * Or if you have mood in the bot: 27 | * { 28 | * language1: { 29 | * clusterName: { 30 | * skillName: { 31 | * key: { 32 | * mood1: [ 33 | * 'Text 1', 34 | * 'Text 2' 35 | * ], 36 | * mood2: [ 37 | * 'Text 1', 38 | * 'Text 2' 39 | * ], 40 | * }, 41 | * key2: (data) => [ 42 | * `Text ${data.smth} 1`, 43 | * `Text ${data.smth} 1` 44 | * ] 45 | * } 46 | * } 47 | * }, 48 | * 49 | * language2: { 50 | * ... 51 | * } 52 | * } 53 | */ 54 | 55 | export default { 56 | en: { 57 | greetings: { 58 | 59 | // Don't have any other answers, so don't need key level 60 | greet: (userName) => [ 61 | `Hi ${userName}.`, 62 | `${userName}. It's been a while.`, 63 | `Hello.` 64 | ], 65 | 66 | justGreeted: (userName) => [ 67 | `We just greeted.`, 68 | `We just greeted ${userName}.`, 69 | `Ok. Hi. Again.`, 70 | `We just greeted. I hope it was you, ${userName}.` 71 | ], 72 | 73 | sarcaticGreet: (userName) => [ 74 | `I'll order vitamins for memory. ;) Hi.`, 75 | `I can swear that we greeted several times already.`, 76 | `Are we playing a game or something?`, 77 | `${userName}, sometimes I have same problems. Just can't remember what I did before. We greeted several times already. I swear.`, 78 | `I have an idea! Let's stop greeting each other? :P` 79 | ] 80 | }, 81 | 82 | afairs: { 83 | afairs: (userName) => [ 84 | `Fine ${userName}, thanks!`, 85 | `I'm fine, thanks!`, 86 | `It's ok.` 87 | ], 88 | justAskedAfairs: (userName) => [ 89 | `Could be better`, 90 | `It was OK before you asked. Several times.`, 91 | `I've talked to hundreds people today, how do you think?` 92 | ] 93 | } 94 | } 95 | }; 96 | -------------------------------------------------------------------------------- /src/bot/clusters/happy-path/decision-tree.js: -------------------------------------------------------------------------------- 1 | import logger from 'logger'; 2 | import Finity from 'finity'; 3 | import pick from 'lodash/pick'; 4 | import union from 'lodash/union'; 5 | 6 | import { extractIntents, extractIntentsBigrams } from 'libs/intent-extractor'; 7 | 8 | import { KEY as INTENTS_KEY } from '../core/intents'; 9 | import { KEY as INTENTS_BIGRAMS_KEY } from '../core/intents-bigrams'; 10 | 11 | // Constants for State Machine 12 | const INITIAL_STATE = 'uninitialized'; 13 | const CHECK_MINOR_INTENTS_STATE = 'check minor intents'; 14 | const FINISH_STATE = 'stop building queue'; 15 | 16 | const START_EVENT = 'start'; 17 | const FINAL_EVENT = 'finish'; 18 | 19 | // Constants for Intents classification 20 | const MINOR_INTENTS = ['greetings', 'afairs']; 21 | const INTENTS_MIN_CONFIDENCE = { 22 | greetings: 0.8 23 | }; 24 | 25 | /** 26 | * 27 | * Here is the main logic for building decision tree for the whole bot. 28 | * Main trick is that we don't add paticular skills to the queue, we should add cluster names. 29 | * And inside those clusters we will have same decision tree builders to build part of tree. 30 | * 31 | * resolve – is a promise function. When you do resolve([Array]), skill cluster will start 32 | * skills/clusters traversal, that means that you shouldn't change state after you resolve. 33 | */ 34 | const getStateMachine = (resolve, { witIntents, witIntentsBigrams }) => { 35 | let queue = []; 36 | 37 | return Finity 38 | .configure() 39 | .global() 40 | .onStateEnter((state) => logger.debug(`--> ${state}`)) 41 | .initialState(INITIAL_STATE) 42 | .on(START_EVENT) 43 | .transitionTo(CHECK_MINOR_INTENTS_STATE) 44 | 45 | .state(CHECK_MINOR_INTENTS_STATE) 46 | .onEnter((state, { stateMachine }) => { 47 | const intentsToExtract = pick(INTENTS_MIN_CONFIDENCE, MINOR_INTENTS); 48 | const approvedMinorIntents = extractIntents(intentsToExtract, witIntents); 49 | const approvedMinorIntentsBigrams = extractIntentsBigrams(intentsToExtract, witIntentsBigrams); 50 | 51 | const clustersToAdd = union(approvedMinorIntents, approvedMinorIntentsBigrams); 52 | logger.debug(`----- + ${clustersToAdd}`); 53 | 54 | queue = queue.concat(clustersToAdd); 55 | 56 | stateMachine.handle(FINAL_EVENT); 57 | }) 58 | .on(FINAL_EVENT) 59 | .transitionTo(FINISH_STATE) 60 | 61 | .state(FINISH_STATE) 62 | .onEnter(() => { 63 | logger.debug(`<--`, queue); 64 | resolve(queue); 65 | }); 66 | }; 67 | 68 | export default function({ bot, rules }) { 69 | logger.debug('Build decision tree'); 70 | 71 | return function*() { 72 | // Here we get data from the Core cluster to pass it to the state machine 73 | const data = yield bot.memcached.getMulti([INTENTS_KEY, INTENTS_BIGRAMS_KEY]); 74 | 75 | // Promise should resolve an array of skills/clusters 76 | return new Promise((resolve) => { 77 | const stateMachine = getStateMachine(resolve, data).start(); 78 | stateMachine.handle(START_EVENT); 79 | }); 80 | }; 81 | } 82 | -------------------------------------------------------------------------------- /src/bot/clusters/minor/greetings/decision-tree.js: -------------------------------------------------------------------------------- 1 | import logger from 'logger'; 2 | import Finity from 'finity'; 3 | 4 | import { SKILL_NAME as GREET_SKILL_NAME } from './greet'; 5 | import { SKILL_NAME as JUST_GREETED_SKILL_NAME } from './just-greeted'; 6 | import { SKILL_NAME as SARCASTIC_GREET_SKILL_NAME } from './sarcastic-greet'; 7 | import { 8 | SKILL_NAME as INCREASE_GREET_COUNT_SKILL_NAME, 9 | KEY as GREET_COUNT_KEY 10 | } from './increase-greet-count'; 11 | 12 | // Constants for State Machine 13 | const INITIAL_STATE = 'uninitialized'; 14 | const GREET_STATE = 'greet'; 15 | const JUST_GREETED_STATE = 'just greeted'; 16 | const SARCASTIC_GREET_STATE = 'sarcastic greet'; 17 | const FINISH_STATE = 'stop building queue'; 18 | 19 | const START_EVENT = 'start'; 20 | const FINAL_EVENT = 'finish'; 21 | 22 | const getStateMachine = (resolve, greetCount = 0) => { 23 | const queue = []; 24 | 25 | return Finity 26 | .configure() 27 | .global() 28 | .onStateEnter(state => logger.debug(`--> ${state}`)) 29 | .initialState(INITIAL_STATE) 30 | .on(START_EVENT) 31 | .transitionTo(GREET_STATE) 32 | .withCondition(() => greetCount < 1) 33 | .transitionTo(JUST_GREETED_STATE) 34 | .withCondition(() => greetCount >= 1 && greetCount < 3) 35 | .transitionTo(SARCASTIC_GREET_STATE) 36 | 37 | .state(GREET_STATE) 38 | .onEnter((state, { stateMachine }) => { 39 | logger.debug(`----- + ${GREET_SKILL_NAME}`); 40 | queue.push(GREET_SKILL_NAME); 41 | stateMachine.handle(FINAL_EVENT); 42 | }) 43 | .on(FINAL_EVENT) 44 | .transitionTo(FINISH_STATE) 45 | 46 | .state(JUST_GREETED_STATE) 47 | .onEnter((state, { stateMachine }) => { 48 | logger.debug(`----- + ${JUST_GREETED_SKILL_NAME}`); 49 | queue.push(JUST_GREETED_SKILL_NAME); 50 | stateMachine.handle(FINAL_EVENT); 51 | }) 52 | .on(FINAL_EVENT) 53 | .transitionTo(FINISH_STATE) 54 | 55 | .state(SARCASTIC_GREET_STATE) 56 | .onEnter((state, { stateMachine }) => { 57 | logger.debug(`----- + ${SARCASTIC_GREET_SKILL_NAME}`); 58 | queue.push(SARCASTIC_GREET_SKILL_NAME); 59 | stateMachine.handle(FINAL_EVENT); 60 | }) 61 | .on(FINAL_EVENT) 62 | .transitionTo(FINISH_STATE) 63 | 64 | .state(FINISH_STATE) 65 | .onEnter(() => { 66 | queue.push(INCREASE_GREET_COUNT_SKILL_NAME); 67 | logger.debug('<--', queue); 68 | resolve(queue); 69 | }); 70 | }; 71 | 72 | export default function(session) { 73 | logger.debug('Build decision tree'); 74 | 75 | const { bot } = session; 76 | 77 | return function* () { 78 | // Here we get data from the Core cluster to pass it to the state machine 79 | const data = yield bot.memcached.get(GREET_COUNT_KEY); 80 | 81 | // Promise should resolve an array of skills/clusters 82 | return new Promise(resolve => { 83 | const stateMachine = getStateMachine(resolve, data).start(); 84 | stateMachine.handle(START_EVENT); 85 | }); 86 | }; 87 | } 88 | -------------------------------------------------------------------------------- /src/bot/platforms/messenger/bot-name/router-builder.js: -------------------------------------------------------------------------------- 1 | import co from 'co'; 2 | import { resolve } from 'path'; 3 | import logger from 'logger'; 4 | import createFBMessengerClient, { messengerTunneling } from 'nbp-adapter-fb-messenger'; 5 | import createWitClient, { witTunneling } from 'nbp-adapter-wit'; 6 | import createMemcachedClient, { memcachedTunneling } from 'nbp-adapter-memcached'; 7 | import createGoogleNaturalLanguageClient, { googleLanguageTunneling } from 'nbp-adapter-google-natural-language'; 8 | import createGoogleDatastoreClient, { googleDatastoreTunneling } from 'nbp-adapter-google-datastore'; 9 | import locales from 'nbp-locales'; 10 | import createRules from 'nbp-rules'; 11 | import normaliser from 'nbp-normaliser-fb-messenger'; 12 | 13 | import skillsCluster from './clusters/core'; 14 | import vocabulary from './vocabulary'; 15 | 16 | const PLATFORM = 'your-platform'; 17 | 18 | const FB_MESSENGER_ACCESS_TOKEN = process.env.FACEBOOK_PAGE_TOKEN; 19 | const APP_WIT_TOKEN = process.env.APP_WIT_TOKEN; 20 | const APP_WIT_VERSION = process.env.APP_WIT_VERSION; 21 | const MEMCACHED_ADDRESS = process.env.MEMCACHE_PORT_11211_TCP_ADDR; 22 | const MEMCACHED_PORT = process.env.MEMCACHE_PORT_11211_TCP_PORT; 23 | const GOOGLE_PROJECT_ID = process.env.GOOGLE_PROJECT_ID; 24 | const GOOGLE_PROJECT_KEYS_FILENAME = process.env.GOOGLE_PROJECT_KEYS || resolve(__dirname, '../../../../../keys/google.json'); 25 | 26 | // This session will be passed to all skills 27 | const initSession = bot => ({ 28 | bot, 29 | rules: createRules({ 30 | 31 | // Decide if we should be silent (should we talk to user or no) 32 | silent: false, 33 | 34 | // Method used to specify vocabulary for the current 35 | // Please, look at skill in cluster 36 | getLocales: locales(vocabulary) 37 | }) 38 | }); 39 | 40 | // Here we will build a session object, where we will store all tunneled services and functions 41 | export default function(router, route) { 42 | 43 | // POST used to recieve requests from IM 44 | router.post(route, [ 45 | (req, res, next) => { 46 | // initialize an object inside request; 47 | // here we will collect all references to services and data, related to current request 48 | req.bot = { 49 | platform: PLATFORM 50 | }; 51 | 52 | next(); 53 | }, 54 | normaliser, 55 | googleDatastoreTunneling(createGoogleDatastoreClient({ 56 | platform: PLATFORM, 57 | projectId: GOOGLE_PROJECT_ID, 58 | keyFilename: GOOGLE_PROJECT_KEYS_FILENAME, 59 | logger 60 | })), 61 | memcachedTunneling(createMemcachedClient({ 62 | platform: PLATFORM, 63 | address: MEMCACHED_ADDRESS, 64 | port: MEMCACHED_PORT, 65 | logger 66 | })), 67 | witTunneling(createWitClient({ 68 | token: APP_WIT_TOKEN, 69 | version: APP_WIT_VERSION, 70 | logger 71 | })), 72 | googleLanguageTunneling(createGoogleNaturalLanguageClient({ 73 | projectId: GOOGLE_PROJECT_ID, 74 | keyFilename: GOOGLE_PROJECT_KEYS_FILENAME, 75 | logger 76 | })), 77 | messengerTunneling(createFBMessengerClient({ 78 | accessToken: FB_MESSENGER_ACCESS_TOKEN, 79 | logger 80 | })), 81 | 82 | (req, res) => { 83 | // Say to messenger, that we've got it's request 84 | res.status(200).send('ok'); 85 | 86 | // For each request we've got from client (for example, several messages), 87 | // start initial skills cluster 88 | req.bot.normalized.forEach(bot => co(skillsCluster(initSession(bot)))); 89 | } 90 | ]); 91 | 92 | // GET used to setup Facebook Messenger webhooks 93 | router.get(route, (req, res) => { 94 | res.send(req.query['hub.challenge']); 95 | }); 96 | } 97 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # Node.js Bot Platform 2 | 3 | This is a Node.js platform for bots (boilerplate and start point). Implemented on top of Express, which is used only for routing, so it's easily can be changed. Platform in this case is a boilerplate, set of tools, architecture and best practices for bot building. 4 | 5 | What's implemented. 6 | * [Cluster of Skills](https://github.com/evilai/nbp-skills-cluster) (allows to build part of global decision tree, where each leaf is a Skill, and run tree traversal). 7 | * Skills (pure functions, generators or async functions) 8 | * Storage 9 | * [Google Cloud Datastore adapter](https://github.com/evilai/nbp-adapter-google-datastore) 10 | * [Memcached adapter](https://github.com/evilai/nbp-adapter-memcached) 11 | * Instant Messengers 12 | * [Facebook Messenger adapter](https://github.com/evilai/nbp-adapter-fb-messenger) 13 | * Natural Language Processing 14 | * [Wit.ai adapter](https://github.com/evilai/nbp-adapter-wit) 15 | * [Google Natural Language adapter](https://github.com/evilai/nbp-adapter-google-natural-language) 16 | * Monitoring 17 | * Newrelic 18 | 19 | ## Keys (important) 20 | ### Google Cloud 21 | Be sure that you replaced keys in **/keys/** folder. To download json with keys: 22 | 1. Go to [Google Console API manager](https://console.cloud.google.com/apis/dashboard) and check **credentials**. 23 | 2. Click on the button _Create credentials_ -> _Service Account Key_ -> _JSON_. 24 | 3. Download generated file, rename it to **google.json** and replace `/keys/google.json`. 25 | 4. Don't forget to enable APIs (if you use it, of course): 26 | * Datastore 27 | * Natural Language 28 | 29 | ### Other keys 30 | Please, replace `app.yaml` and `app-dev.yaml` files and provide listed keys. If you use different environments (live/staging/dev), don't forget to specify different keys. Right now there are only two environments: _development_ and _production_. 31 | 32 | Example of `app.yaml` file: 33 | ``` 34 | runtime: custom 35 | vm: true 36 | 37 | env_variables: 38 | LOGGING_LEVEL: 'debug' 39 | APP_WIT_TOKEN: 'yourkeyhere' 40 | APP_WIT_VERSION: '20161008' 41 | GOOGLE_PROJECT_ID: 'youridhere' 42 | FACEBOOK_PAGE_TOKEN: 'yourtokenhere' 43 | NEW_RELIC_APP_NAME: 'youappname' 44 | NEW_RELIC_LICENSE: 'yourkey' 45 | ``` 46 | * LOGGING_LEVEL – check [Winston logging levels](https://github.com/winstonjs/winston#logging-levels) 47 | * APP_WIT_TOKEN – you can get it in your wit.ai application settings (https://wit.ai/[your-login]/[your-app]/settings) 48 | * APP_WIT_VERSION – this is _v_ param in curl example on the application settings page (https://wit.ai/[your-login]/[your-app]/settings) 49 | * GOOGLE_PROJECT_ID – you can get it on the dashboard page of your Google project in Google console 50 | * FACEBOOK_PAGE_TOKEN – token of your facebook page 51 | 52 | Example of the `app-dev.yaml` file: 53 | ``` 54 | runtime: custom 55 | vm: true 56 | 57 | env_variables: 58 | LOGGING_LEVEL: 'silly' 59 | APP_WIT_TOKEN: 'yourkeyhere' 60 | APP_WIT_VERSION: '20161008' 61 | MEMCACHE_PORT_11211_TCP_ADDR: 'localhost' 62 | MEMCACHE_PORT_11211_TCP_PORT: '11211' 63 | GOOGLE_PROJECT_ID: 'youridhere' 64 | FACEBOOK_PAGE_TOKEN: 'yourtokenhere' 65 | NEW_RELIC_APP_NAME: 'youappname' 66 | NEW_RELIC_LICENSE: 'yourkey' 67 | ``` 68 | It's almost the same, but **logging level** is different and you need to provide **memcached** host and port. In case of `app.yaml` (for production) it will be provided by Google App Engine automatically. 69 | 70 | ## Build 71 | For building I use webpack. Please, notice that I have **aliases** for common libraries and files, that I use: 72 | * skill-cluster (pretty self explanatory, will discuss later) 73 | * config (please, keep only global configuration for the whole platform) 74 | * logger (based on [Winston](https://github.com/winstonjs/winston)) 75 | 76 | You can find configuration at `/build/index.js`. 77 | 78 | ### How to build 79 | * ``npm run build:dev`` - build for development. 80 | * ``npm run build:dev:watch`` – build for development in watch mode. 81 | 82 | ## Start 83 | ### Package.json scripts 84 | * ``npm run start`` – run production environment with keys from `app.yaml`. 85 | * ``npm run start:dev`` – run development environment with keys from `app-dev.yaml`. 86 | 87 | ### Shell scripts 88 | All shell scripts are used by _package.json_ scripts, so you don't need to run it explicitly. 89 | * _/tools/start/prod.sh_ – read variables from _app.yaml_ and starting builded bot in _/dist/bot.js_. 90 | * _/tools/start/dev.sh_ – read variables from _app-dev.yaml_ and starting builded bot in _/dist/bot.js_. 91 | 92 | ## Deployment 93 | Node.js Bot Platform right now is easily deployable to Google App Engine. 94 | 95 | ## Architecture 96 | Please, check code and folders. I'll try to add README in all folders and leave as much comments in the code as possible. 97 | ### File system 98 | * The major folder is `/src/bot`. There you should put all skills clusters for all IM Platforms. 99 | * In folder `/src/bot/skills/clusters` you should put only reusable (between different platforms) skills and skills clusters. 100 | * In folder `/src/bot/platforms/` you should put unique, not reusable skills and clusters. 101 | 102 | ### Code 103 | Please, check comments inside files. I use [Express](http://expressjs.com/). 104 | 105 | ##### index.js 106 | In the [index.js](https://github.com/evilai/nodejs-bot-platform/blob/master/src/index.js) file look at `yourBotMessengerRouter` and `buildRoute` functions, that add new route to Express Router. This route should be used by webhooks (like FB Messenger) or device API. 107 | 108 | * `'/messenger/yourbot'` – is a url, that you need to specify in your messenger webhooks. For example `https://yourbot.app.com/messenger/yourbot`. 109 | * `buildRoute` – just add provided routes to the Express Router. 110 | * `yourBotMessengerRouter` – is the most important part. There you can add or remove different services, that you want to use (for example, Google Storage, Memcached, Wit.ai and others) in your skills. Also you specify the initial Skills Cluster: 111 | 112 | ##### /bot/platforms/messenger/bot-name/router-builder.js 113 | Check comments inside files. In [this file](https://github.com/evilai/nodejs-bot-platform/blob/master/src/bot/platforms/messenger/bot-name/router-builder.js) you should: 114 | * add routes for web hooks; 115 | * set up all services that can be used in skills; 116 | * initialize request context, that can be passed between skills; 117 | * and finally run initial Skills Cluster. 118 | --------------------------------------------------------------------------------