botpress
Version:
The world's first CMS for bots. Easily create, manage and extend chatbots.
795 lines (653 loc) • 29.3 kB
JavaScript
'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