adaptorex
Version:
Connect all your live interactive storytelling devices and software
789 lines (701 loc) • 29.6 kB
JavaScript
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.info("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 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 {Object} 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) {
switch (input[0]) {
case "list":
for (let item in this.items) {
log.info(this.name, this.items[item].name, item)
}
break
default:
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 if (Object.keys(this.items).length == 1) {
for (let item in this.items) {
if (typeof this.items[item].command === "function") {
this.items[item].command(input)
} else {
log.warn(this.name, item + " has no command function")
}
}
} else {
log.info(this.name, "No such item or command '" + input[0] + "'")
}
break
}
}
}
/**
* @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