silentrob/superscript

View on GitHub
src/bot/index.js

Summary

Maintainability
A
2 hrs
Test Coverage
import _ from 'lodash';
import requireDir from 'require-dir';
import debuglog from 'debug-levels';
import Message from 'ss-message';

import processHelpers from './reply/common';
import connect from './db/connect';
import factSystem from './factSystem';
import chatSystem from './chatSystem';
import getReply from './getReply';
import Importer from './db/import';
import Logger from './logger';

const debug = debuglog('SS:SuperScript');

class SuperScript {
  constructor(coreChatSystem, coreFactSystem, plugins, scope, editMode, conversationTimeout, historyCheckpoints, tenantId = 'master') {
    this.chatSystem = coreChatSystem.getChatSystem(tenantId);
    this.factSystem = coreFactSystem.getFactSystem(tenantId);

    // We want a place to store bot related data
    this.memory = this.factSystem.createUserDB('botfacts');

    this.scope = scope;
    this.scope.bot = this;
    this.scope.facts = this.factSystem;
    this.scope.chatSystem = this.chatSystem;
    this.scope.botfacts = this.memory;

    this.plugins = plugins;
    this.editMode = editMode;
    this.conversationTimeout = conversationTimeout;
    this.historyCheckpoints = historyCheckpoints;
  }

  importFile(filePath, callback) {
    Importer.importFile(this.chatSystem, filePath, (err) => {
      console.log('Bot is ready for input!');
      debug.verbose('System loaded, waiting for replies');
      callback(err);
    });
  }

  importJSON(obj, callback) {
    if (typeof obj === 'string') {
      try {
        obj = JSON.parse(obj);
      } catch (error) {
        callback(err); 
      }
    }
    Importer.importData(this.chatSystem, obj, callback);
  }

  getUsers(callback) {
    this.chatSystem.User.find({}, 'id', callback);
  }

  getUser(userId, callback) {
    this.chatSystem.User.findOne({ id: userId })
      .slice('history', this.historyCheckpoints)
      .exec(callback);
  }

  findOrCreateUser(userId, callback) {
    this.chatSystem.User.findOneAndUpdate({ id: userId }, {}, {
      upsert: true,
      setDefaultsOnInsert: true,
      new: true,
    }).slice('history', this.historyCheckpoints)
      .exec(callback);
  }

  // Converts msg into a message object, then checks for a match
  reply(userId, messageString, callback, extraScope) {
    // TODO: Check if random assignment of existing user ID causes problems
    if (arguments.length === 2 && typeof messageString === 'function') {
      callback = messageString;
      messageString = userId;
      userId = Math.random().toString(36).substr(2, 5);
      extraScope = {};
    }

    debug.log("[ New Message - '%s']- %s", userId, messageString);
    const options = {
      userId,
      extraScope,
    };

    this._reply(messageString, options, callback);
  }

  // This is like doing a topicRedirect
  directReply(userId, topicName, messageString, callback) {
    debug.log("[ New DirectReply - '%s']- %s", userId, messageString);
    const options = {
      userId,
      topicName,
      extraScope: {},
    };

    this._reply(messageString, options, callback);
  }

  message(messageString, callback) {
    const options = {
      factSystem: this.factSystem,
    };

    Message.createMessage(messageString, options, (err, msgObj) => {
      callback(null, msgObj);
    });
  }

  _reply(messageString, options, callback) {
    const system = {
      // Pass in the topic if it has been set
      topicName: options.topicName || null,
      plugins: this.plugins,
      scope: this.scope,
      extraScope: options.extraScope,
      chatSystem: this.chatSystem,
      factSystem: this.factSystem,
      editMode: this.editMode,
      conversationTimeout: this.conversationTimeout,
      defaultKeepScheme: 'exhaust',
      defaultOrderScheme: 'random',
    };

    this.findOrCreateUser(options.userId, (err, user) => {
      if (err) {
        debug.error(err);
      }

      const messageOptions = {
        factSystem: this.factSystem,
      };

      Message.createMessage(messageString, messageOptions, (err, messageObject) => {
        processHelpers.getTopic(system.chatSystem, system.topicName).then((topicData) => {
          const options = {
            user,
            system,
            depth: 0,
          };

          if (topicData) {
            options.pendingTopics = [topicData];
          }

          getReply(messageObject, options, (err, replyObj) => {
            if (!replyObj) {
              replyObj = {};
              console.log('There was no response matched.');
            }

            user.updateHistory(messageObject, replyObj, (err, log) => {
              // We send back a smaller message object to the clients.
              const clientObject = {
                replyId: replyObj.replyId,
                createdAt: Date.now(),
                string: replyObj.string || '',
                topicName: replyObj.topicName,
                subReplies: replyObj.subReplies,
                debug: log,
              };

              const newClientObject = _.merge(clientObject, replyObj.props || {});

              debug.verbose("Update and Reply to user '%s'", user.id, replyObj.string);
              debug.info("[ Final Reply - '%s']- '%s'", user.id, replyObj.string);

              return callback(err, newClientObject);
            });
          });
        });
      });
    });
  }
}

/**
 *  This a class which has global settings for all bots on a certain database server,
 *  so we can reuse parts of the chat and fact systems and share plugins, whilst still
 *  being able to have multiple bots on different databases per server.
 */
class SuperScriptInstance {
  constructor(coreChatSystem, coreFactSystem, options) {
    this.coreChatSystem = coreChatSystem;
    this.coreFactSystem = coreFactSystem;
    this.plugins = [];

    // This is a kill switch for filterBySeen which is useless in the editor.
    this.editMode = options.editMode || false;
    this.conversationTimeout = options.conversationTimeout;
    this.historyCheckpoints = options.historyCheckpoints;
    this.scope = options.scope || {};

    // Built-in plugins
    this.loadPlugins(`${__dirname}/../plugins`);

    // For user plugins
    if (options.pluginsPath) {
      this.loadPlugins(options.pluginsPath);
    }

    if (options.messagePluginsPath) {
      Message.loadPlugins(options.messagePluginsPath);
    }
  }

  loadPlugins(path) {
    try {
      const pluginFiles = requireDir(path);

      Object.keys(pluginFiles).forEach((file) => {
        // For transpiled ES6 plugins with default export
        if (pluginFiles[file].default) {
          pluginFiles[file] = pluginFiles[file].default;
        }

        Object.keys(pluginFiles[file]).forEach((func) => {
          debug.verbose('Loading plugin: ', path, func);
          this.plugins[func] = pluginFiles[file][func];
        });
      });
    } catch (e) {
      console.error(`Could not load plugins from ${path}: ${e}`);
    }
  }

  getBot(tenantId) {
    return new SuperScript(this.coreChatSystem,
      this.coreFactSystem,
      this.plugins,
      this.scope,
      this.editMode,
      this.conversationTimeout,
      this.historyCheckpoints,
      tenantId,
    );
  }
}

const defaultOptions = {
  mongoURI: 'mongodb://localhost/superscriptDB',
  importJSON: null,
  importFile: null,
  factSystem: {
    clean: false,
    importFiles: null,
  },
  scope: {},
  editMode: false,
  pluginsPath: `${process.cwd()}/plugins`,
  messagePluginsPath: null,
  logPath: `${process.cwd()}/logs`,
  useMultitenancy: false,
  conversationTimeout: 1000 * 300,
  historyCheckpoints: 10
};

/**
 * Setup SuperScript. You may only run this a single time since it writes to global state.
 * @param {Object} options - Any configuration settings you want to use.
 * @param {String} options.mongoURI - The database URL you want to connect to.
 *                 This will be used for both the chat and fact system.
 * @param {Object} options.importJSON - Use this if you want to re-import from JSON Object. 
 *                 Otherwise SuperScript will use whatever it currently finds in the database.
 * @param {String} options.importFile - Use this if you want to re-import your parsed
 *                 '*.json' file. Otherwise SuperScript will use whatever it currently
 *                 finds in the database.
 * @param {Object} options.factSystem - Settings to use for the fact system.
 * @param {Boolean} options.factSystem.clean - If you want to remove everything in the
 *                  fact system upon launch. Otherwise SuperScript will keep facts from
 *                  the last time it was run.
 * @param {Array} options.factSystem.importFiles - Any additional data you want to
 *                import into the fact system.
 * @param {Object} options.scope - Any extra scope you want to pass into your plugins.
 * @param {Boolean} options.editMode - Used in the editor.
 * @param {String} options.pluginsPath - A path to the plugins written by you. This loads
 *                 the entire directory recursively.
 * @param {String} options.logPath - If null, logging will be off. Otherwise writes
 *                 conversation transcripts to the path.
 * @param {Boolean} options.useMultitenancy - If true, will return a bot instance instead
 *                  of a bot, so you can get different tenancies of a single server. Otherwise,
 *                  returns a default bot in the 'master' tenancy.
 * @param {Number} options.conversationTimeout - The time to wait before a conversation expires,
 *                 so you start matching from the top-level triggers.
 */
const setup = function setup(options = {}, callback) {
  options = _.merge(defaultOptions, options);

  // Uses schemas to create models for the db connection to use
  factSystem.setupFactSystem(options.mongoURI, options.factSystem, (err, coreFactSystem) => {
    if (err) {
      return callback(err);
    }

    const db = connect(options.mongoURI);
    const logger = new Logger(options.logPath);
    const coreChatSystem = chatSystem.setupChatSystem(db, coreFactSystem, logger);

    const instance = new SuperScriptInstance(coreChatSystem, coreFactSystem, options);

    /**
     *  When you want to use multitenancy, don't return a bot, but instead an instance that can
     *  get bots in different tenancies. Then you can just do:
     *
     *  instance.getBot('myBot');
     */
    if (options.useMultitenancy) {
      return callback(null, instance);
    }

    const bot = instance.getBot('master');
    if (options.importFile) {
      return bot.importFile(options.importFile, err => callback(err, bot));
    }
    if (options.importJSON) {
      return bot.importJSON(options.importJSON, err => callback(err, bot));
    }
    return callback(null, bot);
  });
};

export default {
  setup,
};