UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

745 lines (659 loc) 27.7 kB
const { nanoid, customAlphabet } = require('nanoid') const { Plugin, PluginSetup } = require('../plugin.js') const { Messenger, Agent } = require("../messenger.js") const { Condition } = require('../logic/logic.js') const { Action } = require('../action.js') const schema = require('./schema.json') /** @typedef {import('socket.io').Namespace} SocketNamespace */ /** @typedef {import('../../types').Game} Game */ /** @typedef {import('../../types').SessionInterface} Session */ /** @typedef {import('../../types').AdaptorAction} Action */ /** @typedef {import('../plugin_items').PluginItemsTopic} PluginItemsTopic */ class SocketioServer extends Messenger { /** @type {PluginItemsTopic} */ namespaces constructor(external_schema) { if (external_schema) { super(schema) Object.assign(this.schema["actions"], external_schema["actions"]) Object.assign(this.schema["action_variables"], external_schema["action_variables"]) Object.assign(this.schema["definitions"], external_schema["definitions"]) } else { super(schema) } this.core = false this.autoconnect = true } /** @type {PluginSetup} */ async setup(config, game, item_constructors = {}) { for(const action in this.schema.actions) { if(this.schema.actions[action].properties.namespace) { this.schema.actions[action].properties.namespace = { "$ref": `#/definitions/${config.name}_namespaces` } } } item_constructors = Object.assign({ namespaces: Namespace }, item_constructors) await super.setup(config, game, item_constructors) this.addConditionTemplate("onSocketioMessage", { value_name: "event", subtitle_insertion:"`Wait for '${payload.topic}' event from ${payload.namespace} in ${payload.room}`" }) return (this.schema) } /** * Send Message on given topic with socketio namespace in given room. broadcast if no room is given * @type {Action} */ async sendSocketioMessage(data, session) { /** @type {Namespace} */ data.namespace = this.namespaces.getItem(data.namespace) let action = new SocketioAction(data, session, this.game) await action.setup() action.sendMessage(data.topic, data.message, { add_metadata: data.add_metadata }) } /** * Wait for incoming events from socket * @type {Action} */ async onSocketioMessage(data, session) { /** @type {Namespace} */ data.namespace = this.namespaces.getItem(data.namespace) let listener = new SocketioAction(data, session, this.game) await listener.setup() await listener.listen() return { cancel: listener.cancel.bind(listener) } } } /** * Base class for SocketIO Actions * * @property {Namespace} namespace */ class SocketioAction extends Action { /** * @type {Object} * Original variable reference to the item that stores room id and namespace status information * Maybe a Data Item or the Namespace item itself. */ room_item_reference /** * * @param {Object<string,*>} data - action data * @param {Namespace} data.namespace - The namespace to communicate with during the operation * @param {string} [data.room] - Room identifier the operation will communicate in. * @param {Session} session * @param {Game} game */ constructor(data, session, game) { super(data, session, game) /** @type {Namespace} */ this.namespace } /** * @todo Preserve or get reference to room item if any. Otherwise set reference to namespace room property */ async setup() { if (this.room) { this.room = await this.session.variables.review(this.room) if (typeof this.room === "object" && !Array.isArray(this.room)) { let room_id = adaptor.getPath(this.room, this.namespace.plugin.id_key) if (!room_id) { throw new Error(`Could not listen for events from ${this.room.name || this.room._id}. ${this.namespace.plugin.id_key} property missing to identify room.`) } this.room = room_id } } } /** * Sends a message to the specified topic within the current namespace and room. * * @param {string} topic - The topic to send the message on. * @param {any} data - The data to be sent with the message. * @param {Object} options - Options for sending the message. * @param {boolean} [options.add_metadata=false] - If true, additional metadata will be added to the message. * * The message will include metadata such as the current timestamp, action details (id, name, action type, plugin), * state information (id, name, path), and session details (_id, name, level) if `add_metadata` is enabled. */ sendMessage(topic, data, options) { if(!options) { options = {add_metadata: false} } data = this.session.variables.parseJSON(data) this.namespace.emitMessage(this.room, topic, data, {add_metadata: options.add_metadata, metadata: { timestamp: Date.now(), action: { id: this.session.action.id, name: this.session.action.name, action: this.session.action.action, plugin: this.session.action.plugin }, state: { id: this.session.state.id, name: this.session.state.name, path: this.session.state.path }, session: { _id: this.session._id, name: this.session.name, level: this.session.level } }}) } /** * Make namespace start listening for events on the given topic in the given room. * * @param {string} [topic] - The topic to listen on. If not provided, the topic given in the action data is used. */ async listen(topic) { if (!topic) { topic = this.topic } topic = await this.session.variables.review(topic) this.listenerCallback = this.session.getCallback(this.onEvent.bind(this)) this.route_id = this.namespace.on("event", { topic: topic, room: this.room, priority: this.priority }, this.listenerCallback) this.session.log.info(`Listening on event from ${this.namespace.name} in room '${this.room}' on topic '${topic}'`) } /** * Called when an event is received from the namespace. * * If the action has an 'if' property, it is evaluated against the incoming message. * If the condition is true, the action's 'next' property is resolved and or a response for the client is returned. * * If the action has an 'else' property, it is executed if none of the 'if' conditions match. * * @param {any} payload - The incoming message. * * @returns {Promise<any>} - The response to send back to the client, if any. */ async onEvent(payload) { await this.session.variables.store({ room: this.room, topic: this.topic, message: payload }) if (this.hasOwnProperty("if") && Array.isArray(this['if'])) { for (let if_condition of this["if"]) { if (if_condition["field"]) { if (typeof payload !== 'object') { this.session.log(`incoming message is not of type object`) continue } if_condition['value'] = adaptor.getPath(payload, if_condition.field) if (typeof if_condition.value === 'undefined') { this.session.log(`incoming message has no field ${if_condition.field}`) continue } } else { if_condition['value'] = payload } let condition = new Condition(if_condition, this.session, this.game) let match = await condition.match() if (match) { if(match.next) { this.session.next(match.next) } if (match.respond) { match.respond = await this.session.variables.review(match.respond) return this.session.variables.parseJSON(match.respond) } return } } } if (this.hasOwnProperty("else")) { if (this.else.next) { this.session.next(this.else.next) if (this.else.respond) { this.else.respond = await this.session.variables.review(this.else.respond) return this.else.respond } return true } if (this.else.respond) { this.else.respond = await this.session.variables.review(this.else.respond) return this.else.respond } this.session.log.warn("Can not handle incoming message. 'next' or 'respond' missing in else.") } this.session.log(`no match on incoming message and no else was defined`) } cancel() { this.namespace.removeRouting(this.route_id) } } class Namespace extends Agent { /** @type {SocketNamespace} */ namespace /** * Initializes a new instance of the Namespace class. * * @param {Object} data - Configuration data for the namespace. * @param {PluginCollectionInterface} collection - Database collection instance for accessing namespace data. * @param {Plugin} plugin - Plugin instance associated with the namespace. * @param {Game} game - Game instance associated with the namespace. */ constructor(data, collection, plugin, game) { super([], data, collection, plugin, game) if (!this.settings.namespace) { this.settings.namespace = this.name } /** * identification for route entries. Up count for each new route. Allows for canceling the right entry. * Referred to as `route_id` when assigned to entry. * @type {number} * */ this.routing_id_count = 0 } /** * Establishes a connection to the socket.IO namespace. * * Listens for incoming messages and handles them according to the topic. Followng topics are natively supported: * * * **create** - create a new room * * **join** - join an existing room * * **leave** - leave a room. Use message payload 'all' or `undefined` to leave all rooms * * **rooms** - list all rooms the socket has joined * * Except for a successful **create**, all messages are forwarded to {@link Namespace#onSocketEvent} * * @listens Namespace#onConnection */ async connect() { await this.connectNamespace() this.namespace.on("connection", async socket => { this.log(`New ${this.namespace.name} socket connection`) socket.onAny(async (topic, data, callback) => { try { this.log(`Incoming message from socket ${socket.id} on topic ${topic}`) this.log(data) // this.log(this.getRooms(socket)) let response = {} switch(topic) { case "create": let id = await this.onCreate(socket, data) if (!callback) { this.log.warn(`Could not respond to 'create' event because no callback has been provided.`) } response.status = 201 response.data = {id: id} break case "rooms": response.status = 200 if(data == "all") { response.data = this.getRooms() } else { response.data = this.getRooms(socket) } break case "join": let join_response = await this.onJoin(socket, data) response.status = 200, response.data = join_response break case "leave": let leave_response = [] if (data == "all" || !data) { leave_response = this.leaveAllRooms(socket) } else { if(!socket.rooms.has(data)) { throw new adaptor.NotFoundError(`Can not leave room ${data}. Socket is not in room.`) } socket.leave(data) leave_response = [data] } if (callback) { response.status = 201 response.data = leave_response } break } let event_responses = await this.onSocketEvent(socket, topic, data) if(event_responses.length) { response.data = event_responses[0] response.status = 200 } if (callback) { response.data = adaptor.parseJSON(response.data) if(this.settings.add_metadata) { response.timestamp = Date.now() response.message_id = adaptor.createId() if(typeof response.data === "undefined") { response.status = 204 } callback(response) } else if(response.data) { callback(response.data) } } else { this.log.info(`Could not return response to client. No callback provided with socket event on topic ${topic}.`) } } catch (error) { this.errorCallback(error, callback) } }) }) } async onJoin(socket, room) { if (typeof room !== "string") { throw new adaptor.InvalidError(`Can not 'join'. Message payload has to be of type string.`) } let query = { $or: [{}, {}] } query["$or"][0][this.plugin.id_key] = room query["$or"][1]["rooms.id"] = room let items = await this.game.topics.items.getMany(query, {}) if (!items.length) { throw new adaptor.NotFoundError(`Can not join room ${room}. Room does not exist`) } socket.join(room) this.log.info(`${socket.id} joins room ${room} associated with ${items[0].name || items[0]._id}`) let item_data = {} if (items[0].type == "data") { item_data = items[0][this.plugin.name] } else { item_data = items[0].rooms.find(elem => elem.id == room) } return item_data } async onCreate(socket, data) { if (!data) { data = {} } if (typeof data === "string") { data = { id: data } } let argument = { name: "room", type: "object" } if (this.settings.argument) { if (this.settings.argument.name) { argument.name = this.settings.argument.name } if (this.settings.argument.type) { argument.type = this.settings.argument.type } } if (argument.type == "number" || argument.type == "array") { throw new adaptor.InvalidError(`Could not create room. Namespace settings can not be of type ${argument.type}`) } let collection = "" let room_key = "" if (argument.type == "string" || argument.type == "object") { collection = this.plugin.name + "_namespaces" room_key = "rooms.id" } else { collection = argument.type room_key = this.plugin.id_key } let query = {} let items = [] if (!data.id) { // Create auto generated id based on namespace id pattern let duplicate = true while (duplicate) { data.id = this.createId() query[room_key] = data.id items = await this.game.topics[collection].getMany(query) if (!items.length) { duplicate = false } else { this.log.info(`Duplicate item id ${data.id}. Retry.`) } } } else { query[room_key] = data.id items = await this.game.topics[collection].getMany(query) if (items.length) { throw new adaptor.DuplicateError(`Another room '${data.id}' associated with ${items[0].name || items[0]._id} already exists`) } } if (argument.type == "string" || argument.type == "object") { await this.game.topics[collection].editOne(this.name, "add", { rooms: data }, { user: { login: this.name } }) if (argument.type == "string") { argument.value = data.id } else { argument.value = data } } else { let item_data = {} if (data.name) { item_data.name = data.name } item_data[this.plugin.name] = data let result = await this.game.topics[collection].create(item_data, { user: { login: this.name } }) argument.value = { _id: result.created_id } } if (this.settings.level) { let level_arguments = {} level_arguments[argument.name] = { type: argument.type, value: argument.value } await this.game.createSession({ level: this.settings.level, "content": "de", "arguments": level_arguments }, { login: this.name }) this.log(`Created ${argument.type} ${data.id} and launched session of level ${this.settings.level}`) } socket.join(data.id) return data.id } /** * Processes incoming socket events and calls registered event callbacks if topic and room match * * @param {Socket} socket - the socket that emitted the event * @param {string} topic - the topic of the event * @param {object} data - the data of the event * @returns {Promise.<Array>} An array of results of the callbacks */ async onSocketEvent(socket, topic, data) { if (!this.routing.event) { return } let responses = [] for (let route of this.routing.event) { if (route.topic && topic != route.topic) { continue } if (route.room) { for (let room of socket.rooms) { if (room == route.room) { responses.push(route.callback(data)) } } } else { responses.push(route.callback(data)) } } let results = await Promise.all(responses) return results.filter(r => r) } /** * Processes an error and sends it back to the client if a callback is provided. * If the error is of type NotFoundError it will be sent with a 404 status, * if it is of type AdaptorError it will be sent with a 400 status, * otherwise it will be sent with a 500 status. * @param {Error} err - The error to process * @param {Function} callback - The callback to send the response to */ errorCallback(err, callback) { this.log.error(err.message) if (err.cause) { err.message = err.message + '\n' + err.cause.message this.log.error(err.cause.message) } if (!callback) { this.log.error(`Above Error will not be send to socket. No callback provided.`) return } if (err instanceof adaptor.NotFoundError) { return callback({ status: 404, name: err.name, message: err.message, timestamp: Date.now(), id: adaptor.createId() }) } else if (err instanceof adaptor.AdaptorError) { return callback({ status: 400, name: err.name, message: err.message, timestamp: Date.now(), id: adaptor.createId() }) } this.log.error(err.stack) return callback({ status: 500, name: err.name, message: err.message, stack: err.stack, timestamp: Date.now(), id: adaptor.createId() }) } /** * Creates a unique identifier. If settings.id_pattern is set, it will be used to generate the id. * If settings.id_pattern.characters is set, it will be used as the character set for the id. * If settings.id_pattern.length is set, it will be used as the length of the id. * Otherwise, it falls back to using the standard nanoid function. * @returns {string} a unique identifier */ createId() { if (this.settings.id_pattern) { if (this.settings.id_pattern.characters) { let custom_nanoid = customAlphabet(this.settings.id_pattern.characters) return custom_nanoid(this.settings.id_pattern.length) } return nanoid(this.settings.id_pattern.length) } return nanoid() } /** * Sets the socket namespace of the plugin to the one specified in the plugin settings. * The namespace is created using the game name, plugin name and namespace name. * After setting the namespace, the plugin is marked as connected. */ async connectNamespace() { this.disconnect() this.namespace = adaptor.io.of(`/${this.game.name}/${this.plugin.name}/${this.settings.namespace}`) let urls = this.game.getWebhookURLs(`/${this.plugin.name}/${this.settings.namespace}`) this.log(`Installed namespace connection for ${this.name}. Connect via one of the following URLs:`) if(!this.settings.url) { this.settings.url = {} } for(let url of urls) { this.settings.url[url.name] = url.url this.log(`${url.title}: ${url.url}`) } await this.storeSettings() this.setConnected(true) } /** * Returns a list of room names the given socket has joined. * Does not include the "private id" room the socket is always part of. * * @param {Socket} socket - the socket to get the rooms for * @returns {array<string>} - list of room names */ getRooms(socket) { if(!socket) { return this.rooms } let rooms = [] for (let room of socket.rooms) { if (room !== socket.id) { rooms.push(room) } } return rooms } /** * Leave all rooms the socket has joined before except for the "private id" room * @param {*} socket * @returns {array<string>} - list of rooms the socket has left */ leaveAllRooms(socket) { let rooms = [] for (let room of socket.rooms) { if (room !== sock.id) { socket.leave(room) rooms.push(room) } } return rooms } disconnectSockets(room) { this.event.emit("disconnect", room) this.namespace.in(room).disconnectSockets() } async update(data) { if (!data.settings.namespace) { data.settings.namespace = this.name } if (this.settings.namespace != data.settings.namespace) { this.settings = data.settings await this.connectNamespace() } else { this.settings = data.settings } } /** * Send a message on the given topic to the given room. If the room is not specified, the message is broadcasted to all connected sockets. * * @param {string} room - the room to send the message to. If not set, the message is broadcasted to all connected sockets. * @param {string} topic - the topic to send the message on * @param {object} message - the message to send * @param {object} [options] - options for the message * @param {boolean} [options.add_metadata=false] - if set to true, the message will be wrapped in a metadata object with the following properties: * - timestamp: the time and date the message was sent * - message_id: a unique id for the message * - room: the room the message was sent to * Message payload will be in the `data` property. A given options.metadata object properties will be appended to above properties. */ emitMessage(room, topic, message, options) { if(!options) { options = {} } if (this.namespace) { if(this.settings.add_metadata || options.add_metadata) { message = { data: message, timestamp: Date.now(), message_id: adaptor.createId(), room: room } if(options.metadata) { message = { ...message, ...options.metadata } } } if (!room) { this.log(`broadcast message on topic ${topic}`) this.log(message.data || message) this.namespace.emit(topic, message) } else { this.log(`send message on topic ${topic} to room ${room}`) this.log(message.data || message) this.namespace.to(room).emit(topic, message) } } else { this.log.warn(`Can not send message on ${topic} to ${room}\nNamespace is not (yet) created`) } } /** * Disconnect all sockets in the namespace and emit disconnect event */ disconnect() { if(!this.namespace) { this.setConnected(false) return } this.event.emit("disconnect") this.namespace.disconnectSockets() this.setConnected(false) this.namespace.removeAllListeners("connection") this.log.info("Disconnected") return {"connected":false} } /** * Disconnect telegram api for this agent */ close() { this.disconnect() } } module.exports = { Plugin: SocketioServer, Action: SocketioAction, Namespace: Namespace }