UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

822 lines (740 loc) 24.8 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"]) if (external_schema.hasOwnProperty("data_variables")) { this.schema["data_variables"] = external_schema["data_variables"] } } 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 }