UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

1,281 lines (1,142 loc) 44.9 kB
const { Topic, Issue } = require("../topic") const express = require("express") const { PluginItemsTopic, PluginItem } = require('./plugin_items') class PluginTopic extends Topic { /** * * @param {import('./game').Game} game * @param {*} openapi */ constructor(game, client, items_client, openapi) { super({ name: "plugin", collection: game.db.plugins, schema: { query: {}, path: openapi.components.parameters.plugin.schema, body: openapi.components.schemas.plugin }, main_properties: ["name", "settings", "core", "connected"], methods: ["POST", "GET", "EDIT", "DELETE"], client: client }, game) /** dictionary of enabled plugins * @type {Object<string, Plugin>} */ this.plugins = {} this.items_client = items_client this.router.put('/:entity/settings', (req, res) => { if (!this.valid(req.params.entity, this.schema.path, res, 400)) return if (!this.valid(req.body, this.schema.body["properties"]["settings"], res, 400)) return this.changeSettings(req.params.entity, req.body, req) .then(result => { if (result) { res.send(result) } else { res.status(404).send({name:"NotFoundError", message:`${this.name} ${req.params.entity} was not found`}) } }) .catch(err => { this.errorHandling(err, res) }) }) this.addCustomOperator('load', async (id, data) => { return this.load(id) }) this.addCustomOperator('connect', async (id, data) => { if (this.plugins[id] && typeof this.plugins[id].connect === 'function') { var return_val = await this.plugins[id].connect() } else { throw new adaptor.NotFoundError(`could not find plugin connect function for ${id}`) } return return_val || {} }) this.addCustomOperator('disconnect', async (id, data) => { if (this.plugins[id] && typeof this.plugins[id].disconnect === 'function') { var return_val = await this.plugins[id].disconnect() } else { throw new adaptor.NotFoundError(`could not find plugin disconnect function for ${id}`) } return return_val || {} }) } /** * Add all plugins that can be found in the plugins collection. * * Expected Errors are returned as issues. * Issues in plugin items are added as well except if the plugin itself throws an error/issue. * * @returns {Promise<{issues:array<Issue>}} */ async reload() { let plugins = await this.collection.find({}) let result = {} let load_plugins = plugins.map((plugin) => { return this.add(plugin, result).catch(err => { this.appendIssue(err, result) }) }) await Promise.all(load_plugins) return result } async create(data, origin) { let response = await super.create(data, origin) data._id = response.created_id try { await this.add(data, response) } catch (error) { if(!error instanceof adaptor.AdaptorError) { await this.delete(response.created_id) } this.appendIssue(error, response) } response.created_id = data.name return response } /** * add a plugin to the pallette of this game * get it from index.js with getPlugin function * * @param {string} data.name - name of plugin * * @returns {Promise<{issues: array}>} returns once plugin was added */ async add(data, result) { let plugin = data.name if (this.plugins.hasOwnProperty(plugin)) { throw new adaptor.DuplicateError("Plugin is already active") } let new_plugin try { new_plugin = adaptor.getPlugin(plugin) } catch (error) { if (error instanceof adaptor.NotFoundError) { log.error(this.name, "Failed to load " + plugin + " plugin.") this.collection.delete({ name: plugin }) } throw error } this.plugins[plugin] = new_plugin let plugin_app = express.Router() this.game.app.use(`/${plugin}`, plugin_app) /** @type {PluginConfig} */ let config = { log: log.getContextLog(`${this.game.name} ${plugin}`), app: plugin_app, items_client: this.items_client, settings: {} } Object.assign(config, data) if (typeof this.plugins[plugin].setup !== "function") { throw new Error(plugin + " is missing a setup function!") } let schema = await this.plugins[plugin].setup(config, this.game) if(this.plugins[plugin]._setup_issues && this.plugins[plugin]._setup_issues.length) { if(!result.issues) { result.issues = [] } result.issues = result.issues.concat(this.plugins[plugin]._setup_issues) } if (!schema) { throw new Error(plugin + " did not return a schema on setup") } if (!schema.hasOwnProperty('settings')) { schema['settings'] = {} } this.plugins[plugin].schema = schema await this.collection.set({ name: plugin }, { schema: schema, core: this.plugins[plugin].core }) log.info(this.name, "added plugin " + plugin) if (typeof this.plugins[plugin].connect === "function" && this.plugins[plugin].autoconnect) { try { let connect_result = await this.plugins[plugin].connect(config) if(typeof connect_result === "object") { Object.assign(result, connect_result) } } catch (error) { this.appendIssue(error, result) } } this.plugins[plugin].ready = true return result } async get(id) { let plugins = await this.collection.find({ name: id }) let plugin_setup = await this.getSetups(plugins) if (!plugin_setup || !plugin_setup.length) { return undefined } return plugin_setup[0] } async getMany(query, options) { var plugins = await this.collection.find(query, options) let plugin_setups = await this.getSetups(plugins) if (!plugin_setups) { return undefined } return plugin_setups } /** * Remove document from plugins collection * Remove plugin item collections and remove and quit all items * * @param {string} plugin_name - name of plugin * @returns {Promise<boolean>} - returns true if delete was successful */ async delete(plugin_name) { if (!this.plugins.hasOwnProperty(plugin_name)) { throw new adaptor.NotFoundError("Could not remove. " + plugin_name + " is not an active plugin") } let plugin = this.plugins[plugin_name] let response = await this.collection.delete({ name: plugin_name }) if (response == 0) { throw new adaptor.NotFoundError("Could not delete " + plugin_name + ". Document was not found in database") } if (plugin.hasOwnProperty('schema')) { if (plugin.schema.hasOwnProperty('collections')) { for (let collection in plugin.schema.collections) { await this.plugins[plugin_name][collection].deleteAll() this.plugins[plugin_name][collection].close() await this.game.db.dropCollection(plugin_name + "_" + collection) log.info(this.name, plugin_name + "_" + collection + " collection removed") } } } else { log.warn(this.name, "problem while removing " + plugin_name + ". Plugin has has no schema") } delete this.plugins[plugin.name] await this.publish() log.info(this.name, `Plugin removed: ${plugin_name}`) return true } async changeSettings(plugin, settings, origin) { let changes = await this.edit(plugin, "set", {settings: settings}, origin) if(!changes) { throw new adaptor.NotFoundError(`Could not change settings. Plugin document was not found for '${plugin}'`) } if(changes.changed) { if (typeof this.plugins[plugin].update === "function") { log.debug(plugin, "Plugin settings update") log.debug(plugin, settings) try { await this.plugins[plugin].update(settings) } catch (error) { this.appendIssue(error, changes) } } else { log.warn(this.name, plugin + " settings have changed but plugin does not own an update function to apply them.") } } changes.plugin = await this.load(plugin) return changes } /** * load all plugin setups and emit them via socket */ async emitUpdate() { if(!this.game.ready()) {return} var plugins = await this.collection.find({}) let plugin_setups = await this.getSetups(plugins, true) this.client.emit("plugin", plugin_setups) } /** * * @param {string} plugin - the plugins name * @returns {Object} - Full Plugin setup data */ async load(plugin) { var plugins = await this.collection.find({ name: plugin }) if (!plugins.length) { return undefined } if (typeof this.plugins[plugin].load === 'function') { var return_val = await this.plugins[plugin].load(plugins[0]) if (return_val) { plugins[0] = return_val } } let plugin_setups = await this.getSetups(plugins, true) if (!plugin_setups) { return undefined } return plugin_setups[0] } /** * walk through local plugin object and look for matching key * * @param {string} key - key to look up * @param value - value in key to match * * @returns {Plugin} first plugin Object that has key:value. undefined if no plugin was found. */ find(key, value) { for (let plugin in this.plugins) { if (this.plugins[plugin].hasOwnProperty(key)) { if (this.plugins[plugin][key] == value) { return this.plugins[plugin] } else if (key == "_id") { if (this.collection.compareIDs(this.plugins[plugin]._id, value)) { return this.plugins[plugin] } } } } log.warn(this.name, "no plugin found with " + key + ": '" + value + "'") return undefined } /** * get a list of active plugins names */ getNames() { return this.plugins.map(plugin => {return plugin}) } /** * get list of plugins that are not enabled for this game */ getInactivePlugins() { let available_plugins = adaptor.getAvailablePlugins().map(plug => { return plug.name }) let i_plugins = available_plugins.filter(a_t => !this.plugins.hasOwnProperty(a_t)) return i_plugins } /** * log a pretty list that shows available and unavailable plugins */ showStatus() { for (let plugin in this.plugins) { log.info(0, "[x] " + plugin) } for (let plugin of this.getInactivePlugins()) { log.info(0, "[-] " + plugin) } } /** * Get all names of collections of current active plugins * * @returns Array<object> - list of all plugin collection names */ listCollections() { let plugin_collections_list = [] for (let plugin in this.plugins) { if (!this.plugins[plugin].schema) { log.error(this.name, "Failed to get collections from plugin " + plugin) continue } if (this.plugins[plugin].schema.hasOwnProperty('collections')) { for (let collection in this.plugins[plugin].schema.collections) { plugin_collections_list.push({ plugin: plugin, name: collection, collection: plugin + "_" + collection }) } } } return plugin_collections_list } /** * Get plugins and their item collections * * includes: * - plugin settings * - schema for settings, events, collections (Includes global and plugin schema definitions. Excludes actions.) * Note: definitions are not dereferenced, like they are for action schemas * - collections with items and their settings * * @param {Object} plugins - set of plugins from database * @param {boolean} [load_items=false] - wether or not to call load function on plugin items * * @returns {Promise<array>} list of plugin setups */ async getSetups(plugins, load_items) { let global_schema_definitions = await this.getGlobalSchemaDefinitions() for (let plugin of plugins) { if (!plugin.schema) { log.error(this.name, "Failed to get plugin setup for " + plugin.name) continue } let schema_definitions = Object.assign(global_schema_definitions, plugin.schema.definitions || {}) let plugin_settings_schema = Object.assign({ definitions: schema_definitions }, plugin.schema.settings || {}) plugin['settings'] = { data: plugin.settings, schema: plugin_settings_schema } if (plugin.schema.hasOwnProperty('collections')) { plugin['items'] = {} for (let collection in plugin.schema.collections) { let items = await this.game.topics[plugin.name + "_" + collection].getMany({}, {}, load_items) plugin.items[collection] = { schema: Object.assign({ definitions: global_schema_definitions }, plugin.schema.collections[collection]), items: items || [] } } } delete plugin.schema plugin.package = this.plugins[plugin.name].package if (typeof plugin.connected === 'undefined') { plugin.connected = this.plugins[plugin.name].connected } if (typeof this.plugins[plugin.name].connect === 'function') { plugin.connectible = true } else { plugin.connectible = false } } return plugins } /** * Get a list of all actions that are part of the game. * * Provides basic information about the action and include its extensive schema * * Schema definition references will be resolved. Set keep_references true to prevent that. * * @param {string} query.plugin - return actions of specified plugin only * @param {string} query.action - return specified action only * @param {boolean} keep_references - set true if you don't want to resolve references in action payload schemas * @returns {Promise} array of actions */ async getActions(query, keep_references) { let actions = [] let plugins = await this.collection.find({}) let plugins_deref = { definitions: await this.getGlobalSchemaDefinitions(plugins), plugins: plugins } if (!keep_references) { for (let plugin of plugins) { Object.assign(plugins_deref.definitions, plugin.schema.definitions) } plugins_deref = await adaptor.dereference(plugins_deref) } for (let plugin of plugins_deref.plugins) { if (!plugin.schema) { log.error(this.name, "Failed to get actions from plugin " + plugin.name) continue } for (let action in plugin.schema.actions) { if ((!query.action && !query.plugin) || (!query.plugin && query.action == action) || (query.action == action && query.plugin == plugin.name) || (!query.action && query.plugin == plugin.name)) { actions.push({ action: action, title: plugin.schema.actions[action]["title"] || action, mode: plugin.schema.actions[action]["mode"] || "run", plugin: plugin.name, icon: plugin.schema.actions[action]["icon"] || "", tooltip: plugin.schema.actions[action]["description"] || "", documentation: plugin.schema.actions[action]["documentation"] || "", template: plugin.schema.actions[action]["template"] || `{return {}}`, schema: { type: "object", properties: { name: { type: "string" }, action: { type: "string", readOnly: true, const: action }, mode: { type: "string", readOnly: true, const: plugin.schema.actions[action]["mode"] || "run" }, plugin: { type: "string", readOnly: true, const: plugin.name }, payload: plugin.schema.actions[action] } } }) } } } if (!actions.length) { throw new adaptor.NotFoundError('no action found: ' + JSON.stringify(query)) } return actions } /** * get a minimized version of actions. Schema is omitted. * * @param {string} query.plugin - return actions of specified plugin only * @param {string} query.action - return specified action only * @returns {Promise} array of action previews (without payload property) */ async getActionsPreview(query) { let actions = await this.getActions(query, true) for (let action of actions) { delete action.schema } return actions } /** * Search for action variables in all plugins get collect them in one object * * @returns {Promise<object>} merge of all plugins action variables */ async getActionVariables() { let plugins = await this.collection.find({ 'schema.action_variables': { $exists: true } }) let action_variables = {} for (let plugin of plugins) { action_variables[plugin.name] = plugin.schema.action_variables } return action_variables } /** * @typedef {Object} GlobalDefinitions * @param {Object} webhooks - A dictionary of urls available to access adaptor:ex * @param {Object} event_source - Defines schema for an event source item, session or game selectable or custom * @param {Object} event_name - Defines schema for an event selectable or custom * @param {Object} keep_listening - Schema for listeners keep listening and queue option */ /** * Collect schema definitions that are accessible and may be useful for all plugin and action schemas * @param {array<object>} plugins * @returns {GlobalDefinitions} - global schema definitions */ async getGlobalSchemaDefinitions(plugins) { let definitions = {} if(plugins) { definitions = await this.getEventDefinitions(plugins) } definitions.keep_listening = { "title":"keep listening and queue", "type":"object", "description":"Keep on listening for events, even if moving on to next state. Incoming events are queued and re triggered whenever this state is called again. Max queue length indicates the maximum queue size for incoming messages.", "additionalProperties":false, "format":"grid-strict", "default":{"enabled":true,"max_queue_length":100}, "required":["enabled"], "properties":{ "enabled":{ "type":"boolean", "format":"checkbox" }, "max_queue_length":{ "format":"number", "title":"max queue length", "minimum":1, "type":"integer" } } } definitions.webhooks = { "type": "object", "readonly": true, "additionalProperties": false, "options":{ "disable_edit_json":true, "disable_properties":true }, "properties": {} } for (let webhook of adaptor.webhooks) { definitions.webhooks.properties[webhook.name] = { "type": "string", "title": webhook.title, "readOnly": true } } return definitions } /** * walk through all plugin schemas and collect events to merge them in * event enum. * * @param {array<object>} plugins - all plugins as loaded from database * * @returns {{event_source: Object, event_name: Object}} - schema for event sources and event names */ async getEventDefinitions(plugins) { let sources = ["session","game"] let source_titles = ["session","game"] let names = ["quit","error"] let name_titles = ["quit","error"] for(let plugin of plugins) { if(plugin.hasOwnProperty("schema")) { if(plugin.schema.hasOwnProperty("collections")) { for(let collection in plugin.schema.collections) { if(plugin.schema.collections[collection].hasOwnProperty("events")) { let items = await this.game.db[plugin.name + "_" + collection].find({}) for(let item of items) { let item_path = `${plugin.name}.${collection}.${item.name}` if(!sources.includes(item_path)) { sources.push(item_path) source_titles.push(item_path) } } for(let event in plugin.schema.collections[collection].events) { if(!names.includes(event)) { names.push(event) if(plugin.schema.collections[collection].events[event].title) { name_titles.push(plugin.schema.collections[collection].events[event].title) } else { name_titles.push(event) } } } } } } } } let event_source = { "anyOf":[ { "title":"Select Source", "type":"string", "enum":sources, "options":{ "enum_titles":source_titles } }, { "title":"Custom Source", "type":"string" } ] } let event_name = { "anyOf":[ { "title":"Select Event", "type":"string", "enum":names, "options":{ "enum_titles":name_titles } }, { "title":"Custom Event", "type":"string" } ] } return {event_source: event_source, event_name: event_name} } close() { super.close() let promises = [] for (let p in this.plugins) { if (typeof this.plugins[p].quit === "function") { promises.push(this.plugins[p].quit(this.name)) } } return Promise.all(promises) } } /** * @callback PluginSetup * * @param {PluginConfig} config - Information and interfaces from and about game. * @param {Game} game - access to game variables and functions that required this module * * @returns {Promise<Object>} the plugin schema in it's current state. */ /** * @typedef {Object} PluginConfig * @property {string} name - this plugins name * @property {string} _id - this plugins database document id * @property {Object} settings - settings that where stored previously using the editor * @property {Object} schema - plugin setup schema * @property {Object} log - log functions set in the plugins context * @property {express.Router} app - Express router addressing this plugin * @property {Object} items_client - socket connector for updates on plugin items */ /** * provides basic functions for adaptor plugins and helps to integrate plugins into adaptor:ex. * * Redefine abstract functions to extend your plugin. * * @param {Object} schema - js object notation schema. Defines the plugins necessities for * settings, actions and, if plugin uses collections, creating new items */ class Plugin { constructor(schema) { /** indicates if plugin is active and can be used. */ this.connected = undefined /** * indicates if plugin connect function should be called when plugin is created or reloaded * @type {boolean} * @default */ this.autoconnect = false /** Indicates if plugin is done loading * @type boolean */ this.ready = false if (schema) { this.schema = adaptor.deepClone(schema) } else { this.schema = { settings: {}, actions: {} } } } /** * * Is called once after the plugin is inserted or restored by the game. * * If the schema defines collections, setup creates object representations of the collection and * calls setup and loadItems successively. * * @type {PluginSetup} * @param {Object<string, PluginItem>} item_constructors - Class definitions for collection items: `{"collection":ItemClass}` */ async setup(config, game, item_constructors = {}) { this.game = game /** * log function for plugin context */ this.log = log.getContextLog(`${game.name} ${config.name}`) //Object.assign(this, config) for (let c in config) { if (c != "schema") { this[c] = config[c] } } /** * @type {string} * the property in data items that identifies the item for this plugin (e.g. 'socketio.id') */ this.id_key = this.name + ".id" if (this.schema.hasOwnProperty("collections")) { let collections_created = [] let collections_collected = [] this.setCommonItemDefinition() for (let collection in this.schema.collections) { this.schema.collections[collection]["additionalProperties"] = false this.schema.collections[collection].properties.settings.additionalProperties = false Object.assign(this.schema.collections[collection]["properties"], { "_id": { "type": "string", "readOnly": true, "options": { "hidden": true } }, "created_at": { "type": "string", "readOnly": true }, "created_by": { "type": "string", "readOnly": true }, "created_with": { "type": "string", "readOnly": true }, "modified_at": { "type": "string", "readOnly": true }, "modified_by": { "type": "string", "readOnly": true }, "modified_with": { "type": "string", "readOnly": true }, "connected": { "type": "boolean", "readOnly": true, "options": { "hidden": true } }, "connectible": { "type": "boolean", "readOnly": true, "options": { "hidden": true } }, "sessions": { "type": "array", "propertyOrder": 3000, "readOnly": true, "options": { "hidden": true, "collapsed": true, "disable_array_add": true, "disable_array_delete": true, "disable_array_delete_all_rows": true, "disable_array_delete_last_row": true, "disable_array_reorder": true }, "items": { "type": "object", "options": { "disable_edit_json": true, "disable_properties": true }, "title": "session", "properties": { "_id": { "type": "string", "readOnly": true }, "reference": { "type": "string", "readOnly": true } } } } }) this[collection] = new PluginItemsTopic({ name: collection, schema: this.schema.collections[collection], client: config.items_client, item_constructor: item_constructors[collection] || PluginItem }, { name: config.name, settings: this.settings, id_key: this.id_key, schema: this.schema, schemaUpdate: this.schemaUpdate.bind(this) }, this.game) collections_created.push(this[collection].setup()) this.game.addTopic(this[collection], `${this.name}_${collection}`, `/plugin/${this.name}/${collection}`) } await Promise.all(collections_created) for(let collection in this.schema.collections) { if (item_constructors[collection]) { collections_collected.push(this[collection].loadItems()) } } let result = await Promise.all(collections_collected) result.forEach(res => { if(res.issues && res.issues.length) { if(!this._setup_issues) { this._setup_issues = [] } this._setup_issues = this._setup_issues.concat(res.issues) } }) } } /** * Create a new express http route for incoming http requests to the game url. * * The route URL will be the adaptor base URL, game name, plugin name and optional route param: * * `http://localhost:8080/MyGame/myplugin/optional_route` * * All known URLs to this webhook will be returned as a "urls" array * * The **express.Router** object will be returned as "router" * * See https://expressjs.com/en/guide/routing.html **express.Router** * * @param {string} [route_name] - name of route that is appended to plugin route url * * @returns {Object} - router: express router app with base route on game route app (baseurl/{game}/{plugin}), urls: all known access points to the newly created route */ getRouter(route_name) { let router = express.Router({ mergeParams: true }) let name_convert_spaces = this.name.replace(/ /g, '%20') let route = "/" + name_convert_spaces if (route_name) { route += route_name } this.game.app.use(route, router) return { router: router, urls: this.game.getWebhookURLs(route) } } /** * Remove all routes for the router object. Requests to the routers endpoints will return common 404 error response. * * @param {Object} router - express router app * @returns */ removeRoutes(router) { if (!router) { return } let i = router.stack.length while(i--) { let routing = router.stack.splice(i, 1) if(routing[0].route) { this.log.trace(`Express route removed: ${Object.keys(routing[0].route.methods)[0]} ${routing[0].route.path}`) } } } /** * Called if a state was triggered that contains the named action. * * The function must have the same name as the action as it was defined in actions * * If a cancel function is returned, the session will store all action data. * * If the adaptor server restarts for any reason, the function is retriggered with all active listeners. * * @callback * * @param {Object} data - data from the action inside the state that was triggered * @param {Session} session - Information and interfaces of and about the session the cue originates from. * * @returns {Promise|undefined|string} For child class: Return cancel function if a listener was installed. Return a state name to trigger next state. Else return undefined */ action(data, session) { } /** * Create a common definition for items of all collections so they can be selected from a dropdown. * * Enum is filled by each collection separately * * The schema will be accessible via schema definitions with the name "{plugin}_items" e.g. "telegram_items": * * Use it like this inside the schema: `"$ref":"#/definitions/{plugin}_items"` */ setCommonItemDefinition() { if (!this.schema.definitions) { this.schema.definitions = {} } this.schema.definitions[this.name + '_items'] = { "propertyOrder": 1, "isAdaptorContent": false, "title": this.name + " item", "anyOf": [ { "title": "select", "type": "string", "enum": [], "options": { "enum_titles": [] } }, { "type": "string", "title": "custom" } ] } } /** * Replace the title for the common item definition (setCommonItemDefinition) * * @param {string} title - title that will be shown in action editor. */ setCommonItemDefinitionTitle(title) { this.schema.definitions[this.name + '_items'].title = title } /** * This method is called in the plugin setup if one wants to add a template to a schema of an action. * Templates are functions that allow the frontend to dynamically display information about the content of an action. * * A Template function is called with the Actions Payload and the Action object (to access e.g. name property). * The function should return an object in the following format: * {title:"", subtitle:"", body:[{text:"", next:""},{...}]} * * @todo write proper documentation * * @abstract * * @param {string} action - name/key of the Action the template should be added to * @param {(string|function)} template - template function to be stringified * @param {Object<string, string>} variables - Variables that will be replaced in the template function string */ addTemplate(action, template, variables) { if (typeof template === "function") { template = template.toString() if(variables) { for(const variable in variables) { let r = new RegExp(variable,'g') template = template.replace(r,variables[variable]) } } this.schema.actions[action].template = template.substring(template.indexOf("{"), template.lastIndexOf("}") + 1) } else { this.schema.actions[action].template = template } } addConditionTemplate(name, {value_name, subtitle_insertion} = {value_name: undefined, subtitle_insertion: "undefined"}) { if(value_name) { value_name = '"' + value_name + '"' } this.addTemplate(name, (payload, action) => { const title = `${action.plugin} ${action.name}` const subtitle = subtitle_insertion const body = [] // prettier-ignore const types = ["equals", "contains", "lessThan", "greaterThan", "regex", "javascript", "find"] const hasProp = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key) if (hasProp(payload, "if")) { for (let i = 0; i < payload.if.length; i++) { const condition = payload.if[i] let type = types.find((t) => hasProp(condition, t)); if (!type) break; // ignore unknown types let text = `If ${condition.value || condition.field || value_name || ""} ${type} ` if (Array.isArray(condition[type])) text += condition[type].join(", "); else if (typeof condition[type] === "string") text += condition[type]; else if (typeof condition[type] === "object") text += JSON.stringify(condition[type]) if(condition.respond) text += ` reply "${condition.respond}"` let next; if (hasProp(condition, "next")) next = condition.next body.push({ text, next }) } } if (hasProp(payload, "else")) { if (hasProp(payload.else, "next")) body.push({ text: `Else: ${payload.else.next}`, next: payload.else.next }); } if (hasProp(payload, "keep_listening")) { if (payload.keep_listening.enabled) body.push({ text: `Keep listening and queue ✔️`}); } return { title, subtitle, body } }, {value_name: value_name, subtitle_insertion: subtitle_insertion}) } /** * Create a template for an Incoming Message Dialog action * * @param {string} name - action name */ addConversationTemplate(name, {value_name, subtitle_insertion} = {value_name: undefined, subtitle_insertion: "undefined"}) { if(value_name) { value_name = '"' + value_name + '"' } this.addTemplate(name, (payload, action) => { const title = `${action.plugin} ${action.name}`; const regex = new RegExp(`${action.plugin}\.[a-zA-Z]*\.`); let subtitle = subtitle_insertion const body = []; if (payload.from && payload.chat) { subtitle += ` in ${payload.chat}` } // prettier-ignore const types = ["equals", "contains", "lessThan", "greaterThan", "regex", "javascript", "media", "find", "query"]; const hasProp = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key); const respondStringBuilder = (arr) => { if (arr && arr.length) { return ` (${arr.length} ${arr.length == 1 ? "reply" : "replies"})`; } else { return "" } } if (hasProp(payload, "if")) { for (let i = 0; i < payload.if.length; i++) { const condition = payload.if[i]; let type = types.find((t) => hasProp(condition, t)); if (!type) break; // ignore unknown types let text = `${type[0].toUpperCase() + type.substring(1)}: `; if (Array.isArray(condition[type])) text += condition[type].join(", "); else if (typeof condition[type] === "string") text += condition[type]; else if (typeof condition[type] === "object") text += JSON.stringify(condition[type]); if (hasProp(condition, "respond")) text += respondStringBuilder(condition.respond); let next; if (hasProp(condition, "next")) next = condition.next; body.push({ text, next }); } } if (hasProp(payload, "else")) { const respond = payload.else.respond; const text = `Else: ${respondStringBuilder(respond) || payload.else.next}`; let next; if (hasProp(payload.else, "next")) next = payload.else.next; body.push({ text, next }); } if (hasProp(payload, "while_idle")) { const delays = payload.while_idle.map(msg => msg.delay) let text = `While idle send messages after:\r${delays.join(", ")} sec.`; body.push({ text }); } if (hasProp(payload, "finally")) { let text = `Finally`; if (payload.finally.delay) { text += ` after ${payload.finally.delay} sec.` } let next = payload.finally.next body.push({ text, next }); } if (hasProp(payload, "keep_listening")) { if (payload.keep_listening.enabled) body.push({ text: `Keep listening and queue ✔️`}); } return { title, subtitle, body } }, {value_name: value_name, subtitle_insertion: subtitle_insertion}) } /** * called when a session wants to cancel a listener that didn't return a cancel function on installation. * * @todo by the date, this function is never called, since listener are forced to return their own function. * should this be changed? * * @abstract * @deprecated * * @param {Object} listener - all data that was stored about the listener */ cancel(listener) { log.warn(this.name, "received cancel command but cancel function not specified:") log.warn(this.name, listener) } /** * is called if settings, based on the schema the module provides, were changed * * @abstract * * @param {Object} new_settings - changed settings based on settings schema of this plugin */ update(new_settings) { } /** * is called the moment the plugin was disabled in a game * * @abstract * * @param {string} config.game - Name of game that required this module */ removed(config) { } /** * is called before node process exits. * Can for example be used to stop external processes * * @abstract * * @returns {Promise} - return once everything is set and done to exit */ quit() { return new Promise((resolve, reject) => { resolve() }) } /** * handle commands forwarded by game * * Commands are handed to plugins if they are addressed by name. * adaptor:ex will call the `command()` function with whatever is typed after the plugin name. * * Commands that match any collection are forwarded to the named collection command function. */ command(input) { switch (input[0]) { case "collections": if (this.schema.hasOwnProperty("collections")) { for (let key in this.schema.collections) { log.info(this.name, key + " - " + this.schema.collections[key].title + ": " + this.schema.collections[key].description) } } break default: if (this.hasOwnProperty(input[0]) && this[input[0]]) { if (typeof this[input[0]].command === "function") { let coll = input.shift() this[coll].command(input) } else { this.log.info(this[input[0]]) } } else if (this.schema.hasOwnProperty("collections")) { if (Object.keys(this.schema.collections).length == 1) { for (let col in this.schema.collections) { this[col].command(input) } } else { log.info(this.name, "No such collection or command '" + input[0] + "'") } } else { log.info(this.name, "No such collection or command '" + input[0] + "'") } break } } /** * write modified schema to plugin document in plugins database * * @param {Object} schema - plugin json schema object */ async schemaUpdate(schema) { this.schema = schema for(let collection in this.schema.collections) { this[collection].setSchema(this.schema.collections[collection]) } await this.game.db.plugins.set({ name: this.name }, { schema: schema }) await this.emitUpdate() } async emitUpdate() { if(!this.ready) {return} await this.game.topics.plugin.emitUpdate() } /** * Change connection status and report status via socket * * @param {boolean} connected - true if connected, false otherwise */ setConnected(connected) { this.connected = connected this.log.trace("Connected " + connected) this.emitUpdate() } } module.exports.PluginTopic = PluginTopic module.exports.Plugin = Plugin