adaptorex
Version:
Connect all your live interactive storytelling devices and software
903 lines (815 loc) • 27 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("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