UNPKG

botpress

Version:

The world's first CMS for bots. Easily create, manage and extend chatbots.

795 lines (653 loc) 29.3 kB
'use strict'; var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; var _path = require('path'); var _path2 = _interopRequireDefault(_path); var _fs = require('fs'); var _fs2 = _interopRequireDefault(_fs); var _verror = require('verror'); var _lodash = require('lodash'); var _lodash2 = _interopRequireDefault(_lodash); var _bluebird = require('bluebird'); var _bluebird2 = _interopRequireDefault(_bluebird); var _glob = require('glob'); var _glob2 = _interopRequireDefault(_glob); var _mkdirp = require('mkdirp'); var _mkdirp2 = _interopRequireDefault(_mkdirp); var _nanoid = require('nanoid'); var _nanoid2 = _interopRequireDefault(_nanoid); var _json = require('json5'); var _json2 = _interopRequireDefault(_json); var _helpers = require('../database/helpers'); var _helpers2 = _interopRequireDefault(_helpers); var _util = require('../util'); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _asyncToGenerator(fn) { return function () { var gen = fn.apply(this, arguments); return new _bluebird2.default(function (resolve, reject) { function step(key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { return _bluebird2.default.resolve(value).then(function (value) { step("next", value); }, function (err) { step("throw", err); }); } } return step("next"); }); }; } /** * The Content Manager is mainly in charge of storing and retrieving * all the content that is stored and known by the bot. The content includes (but is not limited to) * the messages that the bot sends to users. * @see {@link https://botpress.io/docs/10.0/getting_started/trivia_content/} * @namespace ContentManager * @example * bp.contentManager */ const getNewItemId = category => { const prefix = (category.renderer || category.id).replace(/^#/, ''); return `${prefix}-${(0, _nanoid2.default)(6)}`; }; const prepareDb = (() => { var _ref = _asyncToGenerator(function* () { const knex = (0, _util.getInMemoryDb)(); // NB! This is in-memory temprorary database // It is freshly created so we know there are no tables // We also use camelCased columns for convenience yield knex.schema.createTable('content_items', function (table) { table.string('id').primary(); table.text('data'); table.text('formData'); table.text('metadata'); table.string('categoryId'); table.text('previewText'); table.string('createdBy'); table.timestamp('createdOn'); }); return knex; }); return function prepareDb() { return _ref.apply(this, arguments); }; })(); const defaults = { contentDir: './content', contentDataDir: './content_data' }; module.exports = (() => { var _ref2 = _asyncToGenerator(function* ({ botfile, projectLocation, logger, ghostManager }) { const categories = []; const categoryById = {}; const fileById = {}; const getItemProviders = {}; const contentDir = _path2.default.resolve(projectLocation, botfile.contentDir || defaults.contentDir); const contentDataDir = _path2.default.resolve(projectLocation, botfile.contentDataDir || defaults.contentDataDir); const knex = yield prepareDb(); const transformItemDbToApi = function (item) { if (!item) { return item; } return _extends({}, item, { data: JSON.parse(item.data), formData: JSON.parse(item.formData), metadata: (item.metadata || '').split('|').filter(function (i) { return i.length > 0; }) }); }; const transformItemApiToDb = function (item) { if (!item) { return item; } const result = _extends({}, item); if ('formData' in item) { result.formData = JSON.stringify(item.formData); } if ('data' in item) { result.data = JSON.stringify(item.data); } if ('metadata' in item) { result.metadata = '|' + (item.metadata || []).filter(function (i) { return !!i; }).join('|') + '|'; } return result; }; /** * @typedef {Object} ContentManager~Element * @memberOf ContentManager */ /** * @typedef {Object} ContentManager~CategorySchema * @memberOf ContentManager * @prop {Object} json The JSONSchema * @prop {String} ui The UI JSONSchema * @property {String} description * @property {String} renderer The name of the Content Renderer */ /** * @typedef {Object} ContentManager~Category * @memberOf ContentManager * @prop {String} id * @prop {String} title * @property {String} description * @property {Number} count The number of elements in that category * @property {ContentManager~CategorySchema} schema */ /** * Returns the elements of a given category * @param {String} categoryId The category, for example `text` or `trivia`. * @param {Number} [options.from=0] Pagination parameter (where to start) * @param {Number} [options.count=50] Pagination parameter (how many elements to return) * @param {String} [options.searchTerm=] Only return the elements containing this term * @param {Array.<String>} [options.orderBy=['createdOn']] A list of properties to order the elements by. * @return {ContentManager~Element[]} * @public * @memberOf! ContentManager */ const listCategoryItems = (() => { var _ref3 = _asyncToGenerator(function* (categoryId, { from = 0, count = 50, searchTerm, orderBy = ['createdOn'] } = {}) { let query = knex('content_items'); if (categoryId) { query = query.where({ categoryId }); } if (searchTerm) { query = query.where(function () { this.where('metadata', 'like', `%${searchTerm}%`).orWhere('formData', 'like', `%${searchTerm}%`).orWhere('id', 'like', `%${searchTerm}%`); }); } const items = yield query.orderBy(...orderBy).offset(from).limit(count).then(); return items.map(transformItemDbToApi); }); return function listCategoryItems(_x2) { return _ref3.apply(this, arguments); }; })(); const dumpDataToFile = (() => { var _ref4 = _asyncToGenerator(function* (categoryId) { // TODO Do paging here and dump *everything* const items = (yield listCategoryItems(categoryId, { count: 10000 })).map(function (item) { return _lodash2.default.pick(item, 'id', 'formData', 'createdBy', 'createdOn'); }); yield ghostManager.upsertFile(contentDataDir, fileById[categoryId], JSON.stringify(items, null, 2)); }); return function dumpDataToFile(_x3) { return _ref4.apply(this, arguments); }; })(); const dumpAllDataToFiles = function () { return _bluebird2.default.map(categories, function ({ id }) { return dumpDataToFile(id); }); }; /** * Get the schema for a given category * @param {String} categoryId [description] * @return {ContentManager~CategorySchema} */ const getCategorySchema = function (categoryId) { const category = _lodash2.default.find(categories, { id: categoryId }); if (category == null) { return null; } return { json: category.jsonSchema, ui: category.uiSchema, title: category.title, description: category.description, renderer: category.renderer }; }; /** * Returns all the categories * @public * @return {ContentManager~Category[]} * @memberOf! ContentManager */ const listAvailableCategories = function () { return _bluebird2.default.map(categories, (() => { var _ref5 = _asyncToGenerator(function* (category) { const count = yield knex('content_items').where({ categoryId: category.id }).count('* as count').get(0).then(function (row) { return row && Number(row.count) || 0; }); return { id: category.id, title: category.title, description: category.description, count, schema: getCategorySchema(category.id) }; }); return function (_x4) { return _ref5.apply(this, arguments); }; })()); }; const resolveRefs = (() => { var _ref6 = _asyncToGenerator(function* (data) { if (!data) { return data; } if (Array.isArray(data)) { return _bluebird2.default.map(data, resolveRefs); } if (_lodash2.default.isObject(data)) { return _bluebird2.default.props(_lodash2.default.mapValues(data, resolveRefs)); } if (_lodash2.default.isString(data)) { const m = data.match(/^##ref\((.*)\)$/); if (!m) { return data; } return knex('content_items').select('formData').where('id', m[1]).then(function (result) { if (!result || !result.length) { throw new Error(`Error resolving reference: ID ${m[1]} not found.`); } return JSON.parse(result[0].formData); }).then(resolveRefs); } return data; }); return function resolveRefs(_x5) { return _ref6.apply(this, arguments); }; })(); const computeData = (() => { var _ref7 = _asyncToGenerator(function* (categoryId, formData) { const category = categoryById[categoryId]; if (!category) { throw new Error(`Unknown category ${categoryId}`); } return !category.computeData ? formData : category.computeData(formData, computeData); }); return function computeData(_x6, _x7) { return _ref7.apply(this, arguments); }; })(); const computeMetadata = (() => { var _ref8 = _asyncToGenerator(function* (categoryId, formData) { const category = categoryById[categoryId]; if (!category) { throw new Error(`Unknown category ${categoryId}`); } return !category.computeMetadata ? [] : category.computeMetadata(formData, computeMetadata); }); return function computeMetadata(_x8, _x9) { return _ref8.apply(this, arguments); }; })(); const computePreviewText = (() => { var _ref9 = _asyncToGenerator(function* (categoryId, formData) { const category = categoryById[categoryId]; if (!category) { throw new Error(`Unknown category ${categoryId}`); } return !category.computePreviewText ? 'No preview' : category.computePreviewText(formData, computePreviewText); }); return function computePreviewText(_x10, _x11) { return _ref9.apply(this, arguments); }; })(); const fillComputedProps = (() => { var _ref10 = _asyncToGenerator(function* (category, formData) { if (formData == null) { throw new Error('"formData" must be a valid object'); } const expandedFormData = yield resolveRefs(formData); const data = yield computeData(category.id, expandedFormData); const metadata = yield computeMetadata(category.id, expandedFormData); const previewText = yield computePreviewText(category.id, expandedFormData); if (!_lodash2.default.isArray(metadata)) { throw new Error('computeMetadata must return an array of strings'); } if (!_lodash2.default.isString(previewText)) { throw new Error('computePreviewText must return a string'); } if (data == null) { throw new Error('computeData must return a valid object'); } return { data, metadata, previewText }; }); return function fillComputedProps(_x12, _x13) { return _ref10.apply(this, arguments); }; })(); /** * Creates or updates an [Element]{@link ContentManager~Element} * @param {String} [options.itemId=] The id of the element to add * @param {String} options.categoryId The category of the element * @param {Object} options.formData The content of the element * @async * @public * @memberOf! ContentManager */ const createOrUpdateCategoryItem = (() => { var _ref11 = _asyncToGenerator(function* ({ itemId, categoryId, formData }) { categoryId = categoryId && categoryId.toLowerCase(); const category = _lodash2.default.find(categories, { id: categoryId }); if (category == null) { throw new Error(`Category "${categoryId}" is not a valid registered categoryId`); } const item = _extends({ formData }, (yield fillComputedProps(category, formData))); const body = transformItemApiToDb(item); const isNewItemCreation = !itemId; if (isNewItemCreation) { itemId = getNewItemId(category); } if (!isNewItemCreation) { yield knex('content_items').update(body).where({ id: itemId }).then(); } else { yield knex('content_items').insert(_extends({}, body, { createdBy: 'admin', createdOn: (0, _helpers2.default)(knex).date.now(), id: itemId, categoryId })); } yield dumpDataToFile(categoryId); return itemId; }); return function createOrUpdateCategoryItem(_x14) { return _ref11.apply(this, arguments); }; })(); const categoryItemsCount = function (categoryId) { let q = knex('content_items'); if (categoryId && categoryId !== 'all') { q = q.where({ categoryId }); } return q.count('id as count').then(function ([res]) { return Number(res.count); }); }; const deleteCategoryItems = (() => { var _ref12 = _asyncToGenerator(function* (ids) { if (!_lodash2.default.isArray(ids) || _lodash2.default.some(ids, function (id) { return !_lodash2.default.isString(id); })) { throw new Error('Expected an array of Ids to delete'); } yield knex('content_items').whereIn('id', ids).del().then(); return dumpAllDataToFiles(); }); return function deleteCategoryItems(_x15) { return _ref12.apply(this, arguments); }; })(); const getItemDefault = (() => { var _ref13 = _asyncToGenerator(function* (id) { return knex('content_items').where({ id }).get(0); }); return function getItemDefault(_x16) { return _ref13.apply(this, arguments); }; })(); /** * Retrieves one item * @param {String} query Usually the id of the {@link ContentManager.Element}, * but can also be a call to a {@link ContentManager.ElementProvider}. * @return {ContentManager.ElementProvider} * @memberof! ContentManager * @example * await bp.contentManager.getItem('#!trivia-12345') * await bp.contentManager.getItem('#trivia-random()') */ const getItem = (() => { var _ref14 = _asyncToGenerator(function* (query) { const providerRegex = /-(.+)\((.*)\)$/i; const pMatch = query.match(providerRegex); let item; if (pMatch) { const provider = pMatch[1].toLowerCase(); const args = pMatch[2]; const categoryName = query.substr(0, query.length - pMatch[0].length); const fn = getItemProviders[provider]; if (!fn) { throw new Error(`Invalid content expression "${query}", did you forget to register the "${provider}" provider?`); } item = yield fn(knex, categoryName, args); if (_lodash2.default.isArray(item)) { throw new Error(`Provider "${provider}" returned an array instead of an object`); } } else { item = yield getItemDefault(query); } if (!item) { return null; } const category = _lodash2.default.find(categories, { id: item.categoryId }); return _extends({}, transformItemDbToApi(item), { categoryTitle: category.title, categorySchema: getCategorySchema(item.categoryId) }); }); return function getItem(_x17) { return _ref14.apply(this, arguments); }; })(); /** * Retrieves multiple items * @param {String} query Comma-separted string where each part is * the same as an argument to {@link ContentManager.getItem}. * @return {Array<ContentManager.ElementProvider>} * @memberof! ContentManager * @example * await bp.contentManager.getItems('#!trivia-12345,#!trivia-12346') */ const getItems = function (queries) { return _bluebird2.default.map(queries.split(','), getItem); }; const getItemsByMetadata = (() => { var _ref15 = _asyncToGenerator(function* (metadata) { const items = yield knex('content_items').where('metadata', 'like', '%|' + metadata + '|%').then(); return transformItemDbToApi(items); }); return function getItemsByMetadata(_x18) { return _ref15.apply(this, arguments); }; })(); /** * Refreshes the content categories metadata internally. * This must be called after using `loadCategoryFromSchema` * @memberOf! ContentManager * @public */ const recomputeCategoriesMetadata = (() => { var _ref16 = _asyncToGenerator(function* () { for (const _ref17 of categories) { const { id: categoryId } = _ref17; yield knex('content_items').select('id', 'formData').where('categoryId', categoryId).then().each((() => { var _ref18 = _asyncToGenerator(function* ({ id, formData }) { const computedProps = yield fillComputedProps(categoryById[categoryId], JSON.parse(formData)); return knex('content_items').where('id', id).update(transformItemApiToDb(computedProps)); }); return function (_x19) { return _ref18.apply(this, arguments); }; })()); } }); return function recomputeCategoriesMetadata() { return _ref16.apply(this, arguments); }; })(); const loadData = (() => { var _ref19 = _asyncToGenerator(function* (category) { const fileName = category.id + '.json'; fileById[category.id] = fileName; logger.debug(`Loading data for ${category.id} from ${fileName}`); let data = []; try { data = yield readDataForFile(fileName); } catch (err) { logger.warn(`Error reading data from ${fileName}`, err); } data = yield _bluebird2.default.map(data, (() => { var _ref20 = _asyncToGenerator(function* (item) { return _extends({}, item, { categoryId: category.id, id: item.id || getNewItemId(category) }); }); return function (_x21) { return _ref20.apply(this, arguments); }; })()); // TODO: use transaction return _bluebird2.default.mapSeries(data, function (item) { return knex('content_items').insert(transformItemApiToDb(item)).then(); }); }); return function loadData(_x20) { return _ref19.apply(this, arguments); }; })(); /** * Loads a new category from a raw schema definition. * *Important:* do not forget to call `ContentManager~recomputeCategoriesMetadata` after calling this * @param {ContentManager~Category} schema A schema definition * @memberOf! ContentManager * @return {Object} Returns the loaded category schema * @public */ const loadCategoryFromSchema = (() => { var _ref21 = _asyncToGenerator(function* (schema) { const requiredFields = ['id', 'title', 'jsonSchema']; requiredFields.forEach(function (field) { if (_lodash2.default.isNil(schema[field])) { throw new Error(`"${field}" is required but missing in schema`); } }); schema.id = schema.id.toLowerCase(); if (categoryById[schema.id]) { throw new Error(`There is already a form with id "${schema.id}"`); } if (!schema.jsonSchema.title) { schema.jsonSchema.title = schema.title; } categoryById[schema.id] = schema; categories.push(schema); yield loadData(schema); return schema; }); return function loadCategoryFromSchema(_x22) { return _ref21.apply(this, arguments); }; })(); const loadCategoriesFromPath = (() => { var _ref22 = _asyncToGenerator(function* (file) { const filePath = _path2.default.resolve(contentDir, './' + file); let schemas = null; if (/\.json$/i.test(filePath)) { // Using JSON5 instead of regular JSON schemas = _json2.default.parse(_fs2.default.readFileSync(filePath, 'utf8')); } else { // Allows for registering .form.js files schemas = require(filePath); } try { if (_lodash2.default.isArray(schemas)) { for (const schema of schemas) { yield loadCategoryFromSchema(schema); } } else { yield loadCategoryFromSchema(schemas); } } catch (err) { throw new _verror.VError(err, `Error registering Content Element "${file}"`); } }); return function loadCategoriesFromPath(_x23) { return _ref22.apply(this, arguments); }; })(); const readDataForFile = (() => { var _ref23 = _asyncToGenerator(function* (fileName) { const json = yield ghostManager.readFile(contentDataDir, fileName); if (!json) { return []; } try { const data = JSON.parse(json); if (!Array.isArray(data)) { throw new Error(`${fileName} expected to contain array, contents ignored`); } logger.info(`Read ${data.length} item(s) from ${fileName}`); return data; } catch (err) { logger.warn(`Error reading data from ${fileName}`, err); return []; } }); return function readDataForFile(_x24) { return _ref23.apply(this, arguments); }; })(); const init = (() => { var _ref24 = _asyncToGenerator(function* () { _mkdirp2.default.sync(contentDataDir); yield ghostManager.addRootFolder(contentDataDir, { filesGlob: '**/*.json' }); // DEPRECATED: Files don't have to contain .form anymore const files = yield _bluebird2.default.fromCallback(function (cb) { return (0, _glob2.default)('**/*.@(form.json|form.js|json|js)', { cwd: contentDir }, cb); }); for (const file of files) { try { yield loadCategoriesFromPath(file); } catch (err) { throw new _verror.VError(err, `[Content Manager] Could not register Content Element "${file}"`); } } yield recomputeCategoriesMetadata(); }); return function init() { return _ref24.apply(this, arguments); }; })(); /** * @callback ElementProvider * @memberOf! ContentManager * @param {KnexInstance} knex An instance of Knex * @param {String} category The name of the category * @param {String} args A string with whatever was passed in the parans e.g. "random(25)" * @example const randomProvider = (knex, category, args) => { return knex('content_items') .where({ categoryId: category }) .orderBy(knex.raw('random()')) .limit(1) .get(0) } */ /** * Register a new item provider, which is used when parsing query for {@link ContentManager~getItem} * @param {String} name The name of the provider, e.g. `random` * @param {ContentManager.ElementProvider} fn A content provider function * @memberOf ContentManager * @public * @example // returns a random element from a given category const randomProvider = (knex, category, args) => { return knex('content_items') .where({ categoryId: category }) .orderBy(knex.raw('random()')) .limit(1) .get(0) } bp.contentManager.registerGetItemProvider('random', randomProvider) */ const registerGetItemProvider = function (name, fn) { name = name.toLowerCase(); getItemProviders[name] = fn; }; return { init, listAvailableCategories, getCategorySchema, loadCategoryFromSchema, recomputeCategoriesMetadata, createOrUpdateCategoryItem, listCategoryItems, categoryItemsCount, deleteCategoryItems, getItem, getItems, getItemsByMetadata, registerGetItemProvider }; }); return function (_x) { return _ref2.apply(this, arguments); }; })(); //# sourceMappingURL=service.js.map