diff --git a/packages/botkit-plugin-cms/src/cms-local.ts b/packages/botkit-plugin-cms/src/cms-local.ts new file mode 100644 index 000000000..bf55d0677 --- /dev/null +++ b/packages/botkit-plugin-cms/src/cms-local.ts @@ -0,0 +1,163 @@ +/** + * @module botkit-plugin-cms + */ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { Botkit, BotkitConversation, BotkitMessage, BotkitPlugin, BotWorker } from 'botkit'; +import { CmsPluginCore } from './plugin-core'; + +const debug = require('debug')('botkit:cms-local'); + +/** + * A plugin for Botkit that provides access to local scripts in [Botkit CMS](https://github.com/howdyai/botkit-cms) format, + * including the ability to load script content into a DialogSet and bind before, after and onChange handlers to those dynamically imported dialogs by name. + * + * ```javascript + * const cms = require("botkit-cms")(); + * controller.usePlugin(new BotkitCmsLocalPlugin({ + * cms, + * path: `${__dirname}/scripts.json`, + * })); + * + * // use the local cms to test dialog triggers + * controller.on("message", async (bot, message) => { + * const results = await controller.plugins.cms.testTrigger(bot, message); + * return results === false; + * }); + * ``` + */ +export class BotkitCmsLocalPlugin extends CmsPluginCore implements BotkitPlugin { + private _config: LocalCmsOptions; + protected _controller: Botkit; + + public name = 'Botkit CMS local'; + + /** + * Constructor + * @param config + */ + public constructor(config: LocalCmsOptions) { + super(); + + this._config = config; + + if (!this._config.path) { + throw new Error('Scripts paths must be set to use Botkit CMS local plugin.'); + } + if (!this._config.cms) { + throw new Error('CMS must be set to use Botkit CMS local plugin.'); + } + } + + /** + * Botkit plugin init function + * @param controller + */ + public init(controller: Botkit): void { + this._controller = controller; + this._controller.addDep('cms'); + + controller.addPluginExtension('cms', this); + + this._config.cms.loadScriptsFromFile(this._config.path).then((scripts) => { + scripts.forEach((script) => { + // map threads from array to object + const threads = {}; + script.script.forEach((thread) => { + threads[thread.topic] = thread.script.map(this.mapFields); + }); + + const dialog = new BotkitConversation(script.command, this._controller); + dialog.script = threads; + this._controller.addDialog(dialog); + }); + + debug('Dialogs loaded from Botkit CMS local file'); + this._controller.completeDep('cms'); + }).catch((err) => { + console.error('Error loading Botkit CMS local scripts!'); + console.error(`****************************************************************\n${ err }\n****************************************************************`); + }); + } + + /** + * Evaluate if the message's text triggers a dialog from the CMS. Returns a promise + * with the command object if found, or rejects if not found. + * + * @param text + */ + public evaluateTrigger(text: string): Promise { + return this._config.cms.evaluateTriggers(text); + }; + + /** + * Uses the Botkit CMS trigger API to test an incoming message against a list of predefined triggers. + * If a trigger is matched, the appropriate dialog will begin immediately. + * + * @param bot The current bot worker instance + * @param message An incoming message to be interpreted + * @returns Returns false if a dialog is NOT triggered, otherwise returns void. + */ + public testTrigger(bot: BotWorker, message: BotkitMessage): Promise { + debug('Testing Botkit CMS trigger with: ' + message.text); + return this.evaluateTrigger(message.text).then(function(command) { + if (command.command) { + debug('Trigger found, beginning dialog ' + command.command); + return bot.beginDialog(command.command); + } + }).catch(function(error) { + if (typeof (error) === 'undefined') { + return false; + } + throw error; + }); + }; + + /** + * Get all scripts, optionally filtering by a tag + * @param tag + */ + public getScripts(tag?: string): Promise { + return this._config.cms.getScripts(tag); + }; + + /** + * Load script from CMS by id + * @param id + */ + public async getScriptById(id: string): Promise { + try { + return await this._config.cms.getScriptById(id); + } catch (error) { + if (typeof (error) === 'undefined') { + // Script was just not found + return null; + } + throw error; + } + }; + + /** + * Load script from CMS by command + * @param command + */ + public async getScript(command: string): Promise { + try { + return await this._config.cms.getScript(command); + } catch (error) { + if (typeof (error) === 'undefined') { + // Script was just not found + return null; + } + throw error; + } + }; +} + +export interface LocalCmsOptions { + path: string; + cms: any; +} diff --git a/packages/botkit-plugin-cms/src/cms.ts b/packages/botkit-plugin-cms/src/cms.ts index c1e00248e..747353760 100644 --- a/packages/botkit-plugin-cms/src/cms.ts +++ b/packages/botkit-plugin-cms/src/cms.ts @@ -6,10 +6,11 @@ * Licensed under the MIT License. */ -import { Botkit, BotkitDialogWrapper, BotkitMessage, BotWorker, BotkitConversation } from 'botkit'; +import { Botkit, BotkitDialogWrapper, BotkitMessage, BotWorker, BotkitConversation, BotkitPlugin } from 'botkit'; import * as request from 'request'; import * as Debug from 'debug'; import * as url from 'url'; +import { CmsPluginCore } from './plugin-core'; const debug = Debug('botkit:cms'); @@ -29,9 +30,9 @@ const debug = Debug('botkit:cms'); * }); * ``` */ -export class BotkitCMSHelper { +export class BotkitCMSHelper extends CmsPluginCore implements BotkitPlugin { private _config: any; - private _controller: Botkit; + protected _controller: Botkit; /** * Botkit Plugin name @@ -39,6 +40,8 @@ export class BotkitCMSHelper { public name = 'Botkit CMS'; public constructor(config: CMSOptions) { + super(); + this._config = config; if (config.controller) { this._controller = this._config.controller; @@ -138,7 +141,7 @@ export class BotkitCMSHelper { /** * Load all script content from the configured CMS instance into a DialogSet and prepare them to be used. - * @param dialogSet A DialogSet into which the dialogs should be loaded. In most cases, this is `controller.dialogSet`, allowing Botkit to access these dialogs through `bot.beginDialog()`. + * @param botkit The Botkit controller instance */ public async loadAllScripts(botkit: Botkit): Promise { const scripts = await this.getScripts(); @@ -156,86 +159,11 @@ export class BotkitCMSHelper { }); } - /** - * Map some less-than-ideal legacy fields to better places - */ - private mapFields(line): void { - // Create the channelData field where any channel-specific stuff goes - if (!line.channelData) { - line.channelData = {}; - } - - // TODO: Port over all the other mappings - - // move slack attachments - if (line.attachments) { - line.channelData.attachments = line.attachments; - } - - // we might have a facebook attachment in fb_attachments - if (line.fb_attachment) { - const attachment = line.fb_attachment; - if (attachment.template_type) { - if (attachment.template_type === 'button') { - attachment.text = line.text[0]; - } - line.channelData.attachment = { - type: 'template', - payload: attachment - }; - } else if (attachment.type) { - line.channelData.attachment = attachment; - } - - // blank text, not allowed with attachment - line.text = null; - - // remove blank button array if specified - if (line.channelData.attachment.payload.elements) { - for (let e = 0; e < line.channelData.attachment.payload.elements.length; e++) { - if (!line.channelData.attachment.payload.elements[e].buttons || !line.channelData.attachment.payload.elements[e].buttons.length) { - delete (line.channelData.attachment.payload.elements[e].buttons); - } - } - } - - delete (line.fb_attachment); - } - - // Copy quick replies to channelData. - // This gives support for both "native" quick replies AND facebook quick replies - if (line.quick_replies) { - line.channelData.quick_replies = line.quick_replies; - } - - // handle teams attachments - if (line.platforms && line.platforms.teams) { - if (line.platforms.teams.attachments) { - line.attachments = line.platforms.teams.attachments.map((a) => { - a.content = { ...a }; - a.contentType = 'application/vnd.microsoft.card.' + a.type; - return a; - }); - } - delete (line.platforms.teams); - } - - // handle additional custom fields defined in Botkit-CMS - if (line.meta) { - for (let a = 0; a < line.meta.length; a++) { - line.channelData[line.meta[a].key] = line.meta[a].value; - } - delete (line.meta); - } - - return line; - } - /** * Uses the Botkit CMS trigger API to test an incoming message against a list of predefined triggers. * If a trigger is matched, the appropriate dialog will begin immediately. * @param bot The current bot worker instance - * @param message An incoming message to be interpretted + * @param message An incoming message to be interpreted * @returns Returns false if a dialog is NOT triggered, otherwise returns void. */ public async testTrigger(bot: BotWorker, message: Partial): Promise { @@ -245,82 +173,6 @@ export class BotkitCMSHelper { } return false; } - - /** - * Bind a handler function that will fire before a given script and thread begin. - * Provides a way to use BotkitConversation.before() on dialogs loaded dynamically via the CMS api instead of being created in code. - * - * ```javascript - * controller.cms.before('my_script','my_thread', async(convo, bot) => { - * - * // do stuff - * console.log('starting my_thread as part of my_script'); - * // other stuff including convo.setVar convo.gotoThread - * - * }); - * ``` - * - * @param script_name The name of the script to bind to - * @param thread_name The name of a thread within the script to bind to - * @param handler A handler function in the form async(convo, bot) => {} - */ - public before(script_name: string, thread_name: string, handler: (convo: BotkitDialogWrapper, bot: BotWorker) => Promise): void { - const dialog = this._controller.dialogSet.find(script_name) as BotkitConversation; - if (dialog) { - dialog.before(thread_name, handler); - } else { - throw new Error('Could not find dialog: ' + script_name); - } - } - - /** - * Bind a handler function that will fire when a given variable is set within a a given script. - * Provides a way to use BotkitConversation.onChange() on dialogs loaded dynamically via the CMS api instead of being created in code. - * - * ```javascript - * controller.plugins.cms.onChange('my_script','my_variable', async(new_value, convo, bot) => { - * - * console.log('A new value got set for my_variable inside my_script: ', new_value); - * - * }); - * ``` - * - * @param script_name The name of the script to bind to - * @param variable_name The name of a variable within the script to bind to - * @param handler A handler function in the form async(value, convo, bot) => {} - */ - public onChange(script_name: string, variable_name: string, handler: (value: any, convo: BotkitDialogWrapper, bot: BotWorker) => Promise): void { - const dialog = this._controller.dialogSet.find(script_name) as BotkitConversation; - if (dialog) { - dialog.onChange(variable_name, handler); - } else { - throw new Error('Could not find dialog: ' + script_name); - } - } - - /** - * Bind a handler function that will fire after a given dialog ends. - * Provides a way to use BotkitConversation.after() on dialogs loaded dynamically via the CMS api instead of being created in code. - * - * ```javascript - * controller.plugins.cms.after('my_script', async(results, bot) => { - * - * console.log('my_script just ended! here are the results', results); - * - * }); - * ``` - * - * @param script_name The name of the script to bind to - * @param handler A handler function in the form async(results, bot) => {} - */ - public after(script_name: string, handler: (results: any, bot: BotWorker) => Promise): void { - const dialog = this._controller.dialogSet.find(script_name) as BotkitConversation; - if (dialog) { - dialog.after(handler); - } else { - throw new Error('Could not find dialog: ' + script_name); - } - } } export interface CMSOptions { diff --git a/packages/botkit-plugin-cms/src/index.ts b/packages/botkit-plugin-cms/src/index.ts index d8d015553..a6fecf5c4 100644 --- a/packages/botkit-plugin-cms/src/index.ts +++ b/packages/botkit-plugin-cms/src/index.ts @@ -7,3 +7,4 @@ */ export * from './cms'; +export * from './cms-local'; diff --git a/packages/botkit-plugin-cms/src/plugin-core.ts b/packages/botkit-plugin-cms/src/plugin-core.ts new file mode 100644 index 000000000..46ccac8ce --- /dev/null +++ b/packages/botkit-plugin-cms/src/plugin-core.ts @@ -0,0 +1,156 @@ +import { Botkit, BotkitDialogWrapper, BotkitConversation, BotkitMessage, BotkitPlugin, BotWorker } from "botkit"; + +export abstract class CmsPluginCore { + protected _controller; + + /** + * Map some less-than-ideal legacy fields to better places + */ + protected mapFields(line): void { + // Create the channelData field where any channel-specific stuff goes + if (!line.channelData) { + line.channelData = {}; + } + + // TODO: Port over all the other mappings + + // move slack attachments + if (line.attachments) { + line.channelData.attachments = line.attachments; + } + + // we might have a facebook attachment in fb_attachments + if (line.fb_attachment) { + const attachment = line.fb_attachment; + if (attachment.template_type) { + if (attachment.template_type === 'button') { + attachment.text = line.text[0]; + } + line.channelData.attachment = { + type: 'template', + payload: attachment + }; + } else if (attachment.type) { + line.channelData.attachment = attachment; + } + + // blank text, not allowed with attachment + line.text = null; + + // remove blank button array if specified + if (line.channelData.attachment.payload.elements) { + for (let e = 0; e < line.channelData.attachment.payload.elements.length; e++) { + if (!line.channelData.attachment.payload.elements[e].buttons || !line.channelData.attachment.payload.elements[e].buttons.length) { + delete (line.channelData.attachment.payload.elements[e].buttons); + } + } + } + + delete (line.fb_attachment); + } + + // Copy quick replies to channelData. + // This gives support for both "native" quick replies AND facebook quick replies + if (line.quick_replies) { + line.channelData.quick_replies = line.quick_replies; + } + + // handle teams attachments + if (line.platforms && line.platforms.teams) { + if (line.platforms.teams.attachments) { + line.attachments = line.platforms.teams.attachments.map((a) => { + a.content = { ...a }; + a.contentType = 'application/vnd.microsoft.card.' + a.type; + return a; + }); + } + delete (line.platforms.teams); + } + + // handle additional custom fields defined in Botkit-CMS + if (line.meta) { + for (let a = 0; a < line.meta.length; a++) { + line.channelData[line.meta[a].key] = line.meta[a].value; + } + delete (line.meta); + } + + return line; + } + + /** + * Bind a handler function that will fire before a given script and thread begin. + * Provides a way to use BotkitConversation.before() on dialogs loaded dynamically via the CMS api instead of being created in code. + * + * ```javascript + * controller.cms.before('my_script','my_thread', async(convo, bot) => { + * + * // do stuff + * console.log('starting my_thread as part of my_script'); + * // other stuff including convo.setVar convo.gotoThread + * + * }); + * ``` + * + * @param script_name The name of the script to bind to + * @param thread_name The name of a thread within the script to bind to + * @param handler A handler function in the form async(convo, bot) => {} + */ + public before(script_name: string, thread_name: string, handler: (convo: BotkitDialogWrapper, bot: BotWorker) => Promise): void { + const dialog = this._controller.dialogSet.find(script_name) as BotkitConversation; + if (dialog) { + dialog.before(thread_name, handler); + } else { + throw new Error('Could not find dialog: ' + script_name); + } + } + + /** + * Bind a handler function that will fire when a given variable is set within a a given script. + * Provides a way to use BotkitConversation.onChange() on dialogs loaded dynamically via the CMS api instead of being created in code. + * + * ```javascript + * controller.plugins.cms.onChange('my_script','my_variable', async(new_value, convo, bot) => { + * + * console.log('A new value got set for my_variable inside my_script: ', new_value); + * + * }); + * ``` + * + * @param script_name The name of the script to bind to + * @param variable_name The name of a variable within the script to bind to + * @param handler A handler function in the form async(value, convo, bot) => {} + */ + public onChange(script_name: string, variable_name: string, handler: (value: any, convo: BotkitDialogWrapper, bot: BotWorker) => Promise): void { + const dialog = this._controller.dialogSet.find(script_name) as BotkitConversation; + if (dialog) { + dialog.onChange(variable_name, handler); + } else { + throw new Error('Could not find dialog: ' + script_name); + } + } + + /** + * Bind a handler function that will fire after a given dialog ends. + * Provides a way to use BotkitConversation.after() on dialogs loaded dynamically via the CMS api instead of being created in code. + * + * ```javascript + * controller.plugins.cms.after('my_script', async(results, bot) => { + * + * console.log('my_script just ended! here are the results', results); + * + * }); + * ``` + * + * @param script_name The name of the script to bind to + * @param handler A handler function in the form async(results, bot) => {} + */ + public after(script_name: string, handler: (results: any, bot: BotWorker) => Promise): void { + const dialog = this._controller.dialogSet.find(script_name) as BotkitConversation; + if (dialog) { + dialog.after(handler); + } else { + throw new Error('Could not find dialog: ' + script_name); + } + } +}