UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

903 lines (815 loc) 27 kB
const { Topic } = require("../topic") const events = require("events") const express = require("express") /** @typedef {import('../game.js').Game} Game */ /** @typedef {import('../session.js').Session} Session */ /** @typedef {import('./plugin.js').Plugin} Plugin */ /** @typedef {import('../mongo.js').Collection} DBCollection */ /** * @typedef {Object} PluginCollectionInterface * @property {string} name - collection name (differs from db collection name!) * @property {DBCollection} db_coll - DB Collection connector * @property {function} emitUpdate - Emit Update Callback */ /** * Adds new plugin collection to database and add new topic to enable remote access to the collection * * Enhance getMany API function to return connect properties and load the item if requested * * Adds _load, _connect, _disconnect endpoint to allow calling respective item function */ class ItemsTopic extends Topic { /** * @param {Object} config - collection configuration data * @param {Object} config.name - collection name * @param {Object} config.schema - containing schemes for collection items * @param {DBCollection} config.db_coll - mongodb access to collection in game database * @param {Item} [config.item_constructor] - collection item class. If undefined collection will use base Item class so the add function should be redefined * @param {Plugin} plugin - plugin config data * @param {Game} game - game interface */ constructor({ name, schema, client, item_constructor }, plugin, game) { super( { name: name, context: game.name, coll_name: plugin.name + "_" + name, data_type: "plugin", plugin: plugin.name, client: client, schema: { query: {}, path: { type: "string" }, body: {} }, main_properties: [], cast_query_types_default: true, methods: ["POST", "GET", "EDIT", "DELETE"] }, game ) this.item_schema = schema /** @type {Plugin} */ this.plugin = plugin /** Dictionary containing all plugin items. {"item_id":Item} * @type {Object<string, Item>} */ this.items = {} /** name of schema definition that collects all items in a string enum * @type {string | undefined} */ this.item_definition this.setItemDefinition() if (item_constructor) { this.Item = item_constructor } else { this.Item = Item } this.log = log.getContextLog( `${this.game.name} ${this.plugin.name} ${this.name}` ) this.addCustomOperator("load", async (id, data) => { return this.game.topics.plugin.load(this.plugin.name) }) this.addCustomOperator("connect", async (id, data, origin) => { let item = this.getItem(id) if (item) { log.info( this.name, `Connect ${this.plugin.name} ${this.name} item ${id} via API` ) if (data && !adaptor.isEmpty(data)) { var result = await item.connect(data, origin) } else { var result = await item.connect(item.settings, origin) } this.appendWebhook(result, id) return result || {} } else { throw new adaptor.NotFoundError( `Could not find ${this.plugin.name} ${this.name} ${id}` ) } }) this.addCustomOperator("disconnect", async (id) => { let item = this.getItem(id) if (item && typeof item.disconnect === "function") { var return_val = await item.disconnect() } else { throw new adaptor.NotFoundError( `could not find plugin disconnect function for ${id}` ) } return return_val || {} }) } async setup() { await super.setup() this.log.debug("plugin items collection added " + this.collection.name) this.game.status.emit("collection_added", { name: this.name, collection: this.coll_name, db_collection_exists: this.db_coll_created, type: "plugin", plugin: this.plugin.name }) return { created_id: this.coll_name, db_collection_created: this.db_coll_created } } /** * check db collection for item list and call add function for each record. */ async loadItems() { let items = await this.collection.find({}) let result = {} let collect_items = items.map((item) => { return this.add(item).catch((err) => { this.appendIssue(err, result) }) }) await Promise.all(collect_items) return result } async create(data, origin) { let response = await super.create(data, origin) data._id = response.created_id try { Object.assign(response, await this.add(data, origin)) } catch (error) { this.appendIssue(error, response) } response.schema = this.item_schema response.item = await this.assignItemProperties(data) return response } /** * add an item of class Item to collection or if a custom class was provided, of that custom class. * * Call Item setup function if it exists. * * Call Item connect function if item instances `autoconnect` property is `true` * * @param {Object} data - initial item data * * @returns {Promise} - If Item.setup() is called, return value is setup return value. If Item.connect() is called , return value is connect return value */ async add(data, origin) { let return_value this.items[data._id] = new this.Item( data, { name: this.name, db_coll: this.collection, emitUpdate: this.emitItemUpdate.bind(this) }, this.plugin, this.game, origin ) if (data.name) { this.addItemToDefinition(this.item_definition, data.name) this.addItemToDefinition( this.plugin.name + "_items", this.getItemPath(data.name), data.name + " (" + this.item_schema.title + ")" ) await this.plugin.schemaUpdate(this.plugin.schema) this.log("Add item '" + data.name + "'") } if (typeof this.items[data._id].setup === "function") { return_value = await this.items[data._id].setup( data, { name: this.name, db_coll: this.collection }, this.plugin, this.game, origin ) } if ( typeof this.items[data._id].connect === "function" && this.items[data._id].autoconnect ) { return_value = await this.items[data._id].connect(data, origin) } this.appendWebhook(return_value, data._id) this.items[data._id].ready = true return return_value } async edit(doc, operator, data, origin) { let changes = await super.edit(doc, operator, data, origin) if (!changes) { throw new adaptor.NotFoundError( `Could not change settings. Item was not found with '${doc}'` ) } if (changes.changed) { let item_doc = await this.get(changes.query) let item = this.getItem(item_doc._id) try { this.update(item, item_doc, origin) if (typeof item.update === "function") { Object.assign(changes, await item.update(item_doc, origin)) this.appendWebhook(changes, item_doc._id) } else { this.log( item.name + " data changed but Item does not own an update function to apply them." ) } } catch (error) { this.appendIssue(error, changes) } await this.emitItemUpdate([item_doc]) Object.assign(changes, item_doc) } return changes } /** * Add webhook to prompt property if it exists and contains a callback function. * * The callback can then be accessed via the returned webhook * * @param {Object} response - The response object that shall be returned with the request * @param {string} id - The respective items id */ appendWebhook(response, id) { if (response && response.prompt && response.prompt.callback) { let callback_name = response.prompt.callback.name.replace("bound ", "") this.addCustomOperator(callback_name, response.prompt.callback) response.prompt["webhook"] = `/game/${this.game.name}/plugin/${this.plugin.name}/${this.name}/${id}/_${callback_name}` delete response.prompt.callback } } async get(id) { let docs = await this.collection.find(this.getIndexQuery(id)) if (!docs.length) { throw new adaptor.NotFoundError(`${this.name} ${id} was not found`) } return (await this.assignItemProperties(docs[0], true)) || docs[0] } async getMany(query, options, load_items) { var items = await this.collection.find(query, options) for (let item of items) { item = (await this.assignItemProperties(item, load_items)) || item } return items } /** * Add Item runtime values to item document. * * @param {Object} item_doc - The items document data * @param {*} load_item - Wether to call items load function and assign return value * @returns {Promise<Item>} Extended item document with connected and connectible property */ async assignItemProperties(item_doc, load_item) { let item_instance = this.getItem(item_doc._id) if (!item_instance) { this.log.warn( `Could not find ${this.plugin.name} ${this.name} item with id ${item_doc._id}. Creating the item might still be in progress.` ) return } if (load_item && typeof item_instance.load === "function") { var return_val = await item_instance.load(item_doc) if (return_val) { item_doc = return_val } } item_doc.connected = item_instance.connected if (typeof item_instance.connect === "function") { item_doc.connectible = true } else { item_doc.connectible = false } return item_doc } setSchema(schema) { this.item_schema = schema } /** * Get the item based on the item documents id or name. * * @throws {adaptor.NotFoundError} if item could not be found in collection * @param {string|object} value - unique identifier or name of requested item. Maybe nested in `_id` property * @returns {Item} - The requested item */ getItem(value) { let item if (this.collection.validId(value) && this.findItem("_id", value)) { item = this.findItem("_id", value) } else if (typeof value === "string") { if (this.items.hasOwnProperty(value)) { return this.items[value] } else { value = value.replace(this.plugin.name + "." + this.name + ".") item = this.findItem("name", value) } } else if ( typeof value === "object" && value.hasOwnProperty("_id") && this.items.hasOwnProperty(value._id) ) { return this.items[value._id] } if (item) { return item } throw new adaptor.NotFoundError( `Could not get ${this.plugin.name} ${this.name} item with ${value}` ) } /** * Get item based on a key - value combination. * * @param {string} key - key to look up * @param value - value in key to match * * @returns {Item | undefined} first item Object that has key:value. undefined if no item was found. */ findItem(key, value) { for (let item in this.items) { if (this.items[item].hasOwnProperty(key)) { if (this.items[item][key] == value) { return this.items[item] } else if (key == "_id") { if (this.collection.compareIDs(this.items[item]._id, value)) { return this.items[item] } } } } return undefined } /** * Find an item from in this Plugin Item collection based on a mongo find query. * * @param {Object} query - mongodb find query * @returns {Promise<Item>} - The requested item */ async queryItem(query) { let documents = await this.collection.find(query) if (!documents.length) { throw new adaptor.NotFoundError( `Could not find item. No document with ${JSON.stringify(query)} in ${this.name}` ) } return this.getItem(documents[0]._id) } /** * Get dot noted path to identify item as variable using its name property * @param {string} name - Item name * @returns {string} - path to item */ getItemPath(name) { return `${this.plugin.name}.${this.name}.${name}` } /** * Collect all items that are created in a schema string enum so they can be selected from a dropdown. * * The schema will be accessible via schema definitions with the name "{plugin}_{collectionName}" e.g. "telegram_accounts": * * Use it like this inside the schema: `"$ref":"#/definitions/{plugin}_{collection}"` */ setItemDefinition() { this.item_definition = this.plugin.name + "_" + this.name if (!this.plugin.schema.definitions) { this.plugin.schema.definitions = {} } this.plugin.schema.definitions[this.item_definition] = { title: this.item_schema.title, propertyOrder: 1, anyOf: [ { title: "select", type: "string", enum: [], options: { enum_titles: [] } }, { type: "string", title: "custom" } ] } } /** * Replace the title for the collections item definition (setItemDefinition) * * @param {string} title - title that will be shown in action editor. */ setItemDefinitionTitle(title) { this.plugin.schema.definitions[this.item_definition].title = title } addItemToDefinition(definition, item_name, title) { if (this.plugin.schema.definitions[definition]) { this.plugin.schema.definitions[definition].anyOf[0].enum.push(item_name) if ( title && this.plugin.schema.definitions[definition].anyOf[0].hasOwnProperty( "options" ) ) { this.plugin.schema.definitions[ definition ].anyOf[0].options.enum_titles.push(title) } } } removeItemFromDefinition(definition, item_name) { if (this.plugin.schema.definitions[definition]) { let index = this.plugin.schema.definitions[definition].anyOf[0].enum.indexOf( item_name ) this.plugin.schema.definitions[definition].anyOf[0].enum.splice(index, 1) if ( this.plugin.schema.definitions[definition].anyOf[0].hasOwnProperty( "options" ) ) { this.plugin.schema.definitions[ definition ].anyOf[0].options.enum_titles.splice(index, 1) } } } replaceItemInDefinition(definition, old_name, new_name, new_title) { if (this.plugin.schema.definitions[definition]) { let index = this.plugin.schema.definitions[definition].anyOf[0].enum.indexOf( old_name ) this.plugin.schema.definitions[definition].anyOf[0].enum.splice( index, 1, new_name ) if ( this.plugin.schema.definitions[definition].anyOf[0].hasOwnProperty( "options" ) ) { this.plugin.schema.definitions[ definition ].anyOf[0].options.enum_titles.splice(index, 1, new_title) } } } async delete(doc) { let item = this.getItem(doc) item.ready = false let result = await super.delete(doc) await this.remove(item) return result } /** * call item close function and delete item from collection Object */ async remove(item) { if (typeof item["close"] === "function") { try { await item.close() } catch (error) { log.error(this.name, error) } } else { log.warn(this.name, item.name + " has no close function.") } if (item.name) { this.removeItemFromDefinition(this.item_definition, item.name) this.removeItemFromDefinition( this.plugin.name + "_items", this.getItemPath(item.name) ) await this.plugin.schemaUpdate(this.plugin.schema) } delete this.items[item._id] this.log.info("Item removed: " + item.name) } async deleteAll() { for (let item in this.items) { await this.remove(this.items[item]) } } /** * is called if changes happened for one of the items. * if item name has changed, item definition will be adapted * * @param {Item} item - the item instances to the changed item document * @param {Object} item_doc - the altered document */ async update(item, item_doc) { if (item.name != item_doc.name) { this.replaceItemInDefinition( this.item_definition, item.name, item_doc.name ) this.replaceItemInDefinition( this.plugin.name + "_items", this.getItemPath(item.name), this.getItemPath(item_doc.name), item_doc.name + " (" + this.item_schema.title + ")" ) await this.plugin.schemaUpdate(this.plugin.schema) } } /** * load item and emit current data via socket * @param {string|array} item - the items _id property or the item document objects themselves */ async emitItemUpdate(item) { if (!this.game.ready()) { return } let item_docs = [] if (Array.isArray(item)) { item_docs = item } else { item_docs.push(await this.get(item)) } item_docs = item_docs.map((doc) => { doc.type = "plugin" doc.collection = this.name doc.plugin = this.plugin.name return doc }) this.client.emit("changed", item_docs) } close() { super.close() this.game.status.emit("collection_removed", this.coll_name) } /** * Get command forwarded from plugin. * * If input matches the name property of an item in the collection, the command is forwarded to the item command function. * * If you defined a custom command function you should still call this to forward commands to collection items. Use the super keyword if you extend the Collection class: * * `super.command(input)` * */ command(input) { if (this.items.hasOwnProperty(input[0])) { let item = input.shift() if (typeof this.items[item].command === "function") { this.items[item].command(input) } else { log.warn(this.name, item + " has no command function") } } else if (this.findItem("name", input[0])) { let item = this.findItem("name", input.shift()) if (typeof item.command === "function") { item.command(input) } else { log.warn(this.name, item + " has no command function") } } else { super.command(input) } } } /** * @typedef {function} ItemSetup * @param {Object} data - Item data as provided by user interface form * @param {PluginCollectionInterface} coll - collection interface * @param {Plugin} plugin - PLugin interface the item and item collection is part of * @param {Game} game - game context object the item is part of * @param {Object} [origin] - information about the originating create or update request. undefined if function call did not follow a request */ /** * provides basic functions for items adaptor plugins might inherit * */ class Item { /** * @param {Object} data - initial set of item data when item is added to collection * @param {PluginCollectionInterface} coll - collection interface * @param {Plugin} plugin - The plugin instance the item is part of * @param {Game} game - game context object the item is part of */ constructor(data, coll, plugin, game) { Object.assign(this, data) /** Indicates if plugin is done loading * @type boolean */ this.ready = false /** @type {PluginCollectionInterface} */ this.collection = coll this.plugin = plugin /** @type {Game} */ this.game = game this.log = log.getContextLog( `${this.game.name} ${this.plugin.name} ${this.collection.name} ${this.name}` ) /** * eventemitter instance to dispatch plugin item events * @type {events.EventEmitter} */ this.event = new events.EventEmitter() /** indicates if item is active and can be used. */ this.connected = undefined /** indicates if connect function should be called when item is created or recreated */ this.autoconnect = true /** access to item element in database */ this.document = { set: (update) => this.game.topics[this.collection.db_coll.name].edit( { _id: data._id }, "set", update, { user: { login: this.collection.name + " " + this.name } } ) } } /** * is called once the Item was removed from collection */ close() {} /** * Change item connection status * * @todo report status via socket * * @param {boolean} connected - true if connected, false otherwise */ setConnected(connected) { if (this.connected != connected) { this.log.trace("Connected " + connected) this.connected = connected this.emitUpdate() } } /** * store current item settings to document in database collection */ async storeSettings() { await this.document.set({ settings: this.settings }) } /** * is called on user click connect button */ connect() { throw new adaptor.NotFoundError( `${this.name} did not define a connect function` ) } /** * is called on user click disconnect button */ disconnect() { throw new adaptor.NotFoundError( `${this.name} did not define a disconnect function` ) } /** * is called once any property for this item has changed * * @param {Object} data - the data representation of the item including name and settings. */ update(data) { this.log("Item was updated") this.log(data) Object.assign(this, data) } emitUpdate() { if (!this.ready) { return } this.collection.emitUpdate(this._id) } /** * callback executed by plugin item if there is a contact by a peer that has no session with the plugin item. * adds player id_key property to player collection if it does not exist. * * If player with given id does not exist create a new player with id_key property. * * If player exists and is already part of a session based on the same level, ignore contact `{ignore:true}` * * Otherwise start default level with existing or new player. * * If player language is available in default levels, use the language specific version of the level. * * @param {string} id_key - The name of the property in the player document, that contains the plugin item specific id (e.g. `telegram.id`) * @param {any} id - The id value of the foreign contact * @param {string} level - Name of the level that should be started if there is not already an active session * @param {string} argument_name - Name of the level argument that will reference the player document * * @return {Promise<object>} - Object with `level` name and `player` document if a default level was started and `{ignore:false}` or `{ignore:true}` if the default level was not started * */ async initialContact(id_key, id, level, argument_name) { let player = {} let query = {} query[id_key] = id let player_list = await this.game.db.players.find(query) if (player_list.length) { player = player_list[0] if (player.hasOwnProperty("sessions")) { let player_sessions = player.sessions.map((session) => { return { _id: session._id } }) if (player_sessions.length) { let sessions = await this.game.db.sessions.find({ $or: player_sessions, level_name: level }) if (sessions.length) { return { level: level, player: player, ignore: true } } } } } else { let player_doc = {} adaptor.assignPath(player_doc, id_key, id) player_doc[this.plugin.name + "_contacts"] = [] let result = await this.game.topics.players.create(player_doc, { user: { login: this.name + " initial contact" } }) player._id = result.created_id } let level_arguments = {} level_arguments[argument_name] = { type: "players", value: { _id: player._id } } await this.game.createSession( { level: level, content: player.language, arguments: level_arguments }, { login: this.name + " initial contact" } ) return { level: level, player: player, ignore: false } } /** * 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, collection name, item name and optional route param: * * `http://localhost:8080/mygame/myplugin/myplugincollection/myitem/optional_route` * * All known URLs to this webhook will be returned as a "urls" array * * The **express.Router** object will be returned as "router" * * More information about express routing see https://expressjs.com/en/guide/routing.html **express.Router** * * @param {string} [route_name] - name of route that is appended to plugin item route url * * @returns {{router:express.Router,urls:array<{name:string,url:string}>}} - router: express router app with base route on game route app (baseurl/{game}/{plugin}/{collection}/{item}), 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 = `/${this.plugin.name}/${this.collection.name}/${name_convert_spaces}` if (route_name) { route += route_name } this.game.app.use(route, router) let urls = this.game.getWebhookURLs(route) return { router: router, urls: urls } } /** * 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 with input that is addressing the item. Collection has to forward input to specified item. * * @abstract * @param {array} input command line input forwarded from collection command function */ command(input) { switch (input[0]) { default: if (input[0]) { let prop = adaptor.getPath(this, input[0]) if (prop) { this.log.info(prop) } else { this.log.warn("unknown command or property: " + input[0]) } } else { this.log.warn("empty command") } } } } module.exports.PluginItemsTopic = ItemsTopic module.exports.PluginItem = Item