adaptorex
Version:
Connect all your live interactive storytelling devices and software
745 lines (659 loc) • 27.7 kB
JavaScript
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
}