src/bot/index.js
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,
};