UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

915 lines (824 loc) 34.7 kB
/** * outlines for generic messenger module. * * @requires plugin * * @module messenger * @copyright Lasse Marburg 2021 * @license MIT */ const {Plugin} = require("./plugin") const {PluginItem} = require("./plugin_items") const logic = require ('./logic/logic.js') /** @typedef {import('../game.js').Game} Game */ /** @typedef {import('../types').SessionInterface} Session */ /** * Base class for messenger type plugin classes. * * @param {Object} schema - the plugin schema */ class Messenger extends Plugin { constructor(schema) { schema.definitions.chat = { "anyOf": [ { "type": "string", "title": "custom", "default":"Player", "description": "Variable referring to player, chat or channel", "isAdaptorContent": false }, { "type": "string", "title": "default", "enum": ["Player"], "description": "Variable referring to player, chat or channel", "isAdaptorContent": false } ] } schema.definitions.while_idle = { "type":"array", "title":"while idle", "description":"Send delayed messages as long as there is no match on any incoming message. The delay in seconds is appended to each message.", "format":"table", "items":{ "type":"object", "title":"delayed message", "required":["delay","message"], "additionalProperties":false, "properties":{ "delay":{ "type":"number", "minimum":0 }, "message":{ "type":"string", "minLength":1, "format":"textarea" } } } } schema.definitions.finally = { "type":"object", "title":"finally", "description":"goto next state after all delayed messages in idle passed without match on any incoming message.", "required":["next"], "additionalProperties":false, "properties":{ "delay":{ "type":"number", "minimum":0 }, "next":{"$ref":"#/definitions/next"} } } super(schema) } async load(setup) { let level = await this.game.db.levels.distinct('name', {}) for (let collection in this.schema.collections) { if (this.schema.collections[collection].properties.settings.properties.level) { this.schema.collections[collection].properties.settings.properties.level.enum = level } } this.schemaUpdate(this.schema) setup.schema = this.schema return setup } /** * Create a template for an Incoming Message Dialog action * * @param {string} name - action name */ addDialogTemplate(name) { this.addTemplate(name, (payload, action) => { const title = `${action.plugin} ${action.name}`; const regex = new RegExp(`${action.plugin}\.[a-zA-Z]*\.`); let subtitle = `${payload.agent?.replace(regex, '') || payload.app} await message from ${payload.from || payload.chat}`; const body = []; if (payload.from && payload.chat) { subtitle += ` in ${payload.chat}` } // prettier-ignore const types = ["equals", "contains", "lessThan", "greaterThan", "regex", "javascript", "media", "find", "query"]; const hasProp = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key); const respondStringBuilder = (arr) => { if (arr && arr.length) { return ` (${arr.length} ${arr.length == 1 ? "reply" : "replies"})`; } else { return "" } } if (hasProp(payload, "if")) { for (let i = 0; i < payload.if.length; i++) { const condition = payload.if[i]; let type = types.find((t) => hasProp(condition, t)); if (!type) break; // ignore unknown types let text = `${type[0].toUpperCase() + type.substring(1)}: `; if (Array.isArray(condition[type])) text += condition[type].join(", "); else if (typeof condition[type] === "string") text += condition[type]; else if (typeof condition[type] === "object") text += JSON.stringify(condition[type]); if (hasProp(condition, "respond")) text += respondStringBuilder(condition.respond); let next; if (hasProp(condition, "next")) next = condition.next; body.push({ text, next }); } } if (hasProp(payload, "else")) { const respond = payload.else.respond; const text = `Else: ${respondStringBuilder(respond) || payload.else.next}`; let next; if (hasProp(payload.else, "next")) next = payload.else.next; body.push({ text, next }); } if (hasProp(payload, "while_idle")) { const delays = payload.while_idle.map(msg => msg.delay) let text = `While idle send messages after:\r${delays.join(", ")} sec.`; body.push({ text }); } if (hasProp(payload, "finally")) { let text = `Finally`; if (payload.finally.delay) { text += ` after ${payload.finally.delay} sec.` } let next = payload.finally.next body.push({ text, next }); } if (hasProp(payload, "keep_listening")) { if (payload.keep_listening.enabled) body.push({ text: `Keep listening and queue ✔️`}); } return { title, subtitle, body } }) } } /** * Establish a conversation between an agent item and a collection item (e.g. player) * */ class Conversation { /** * Instance of messenger agent (client, phone ...) that handles the conversation. * @type Agent */ agent /** * @param {Object} data - The action data that defines how to exactly deal with the conversation * @param {Object} session - The current session context * @param {Game} game - The current game context * @param {route} [route] - Name of Listener route * @param {array} [content_types] - Names of possible relevant content properties in incoming messages */ constructor(data, session, game, route, content_types=[]) { /** @type {Session} */ this.session = session /** @type {Game} */ this.game = game Object.assign(this, data) /** Types of message content that can be received for this conversation */ this.content_types = content_types /** name of listener routing for this conversation. Must be one of the agents routing options */ this.route = route } /** * ### Identify agent, chat entity and chat participants (from). * * If agent is string, setup will try to match the agent by name, if agent is a variable * that resolves an object/document setup will try to match by the document _id property. * * agent is obligatory. It has to resolve an existing agent. Either by name or a valid reference. * * chat my be a specific chat document or a player with the required properties. * * From chat document, `this.chat_id` is created. * It is the id found in the chat document that is required for messenger communication e.g. a phone number or messenger specific id * * ### Start delayed message timer * * Only if conversation has delayed messages in while_idle property * * ### Start incoming message listener * * Only if conversation has if/else conditions * * ### Remove pending incoming data * * If the conversation itself is not a listener that has a route, all pending incoming messagesor data for all routes and for the respective agent and chat is deleted. * * @param {Object<string,object>|Array<object>} agents - Plugin item collection with agent items that resolve the required type for this conversation * * @returns {Promise} - return when agent and chat are resolved */ async setup(agents) { if (!this.agent && this.hasOwnProperty("agent_reference")) { let agent_reference_resolved = await this.session.variables.review(this.agent_reference) if(Array.isArray(agents)) { for(let agents_coll of agents) { if(this.getAgent(agents_coll, agent_reference_resolved)) { break } } } else { this.getAgent(agents, agent_reference_resolved) } if (!this.agent) { throw new adaptor.InvalidError(this.agent_reference + " does not resolve a valid messenger agent.") } } else if (!this.agent) { throw new adaptor.InvalidError("conversation is missing agent property") } if (this.hasOwnProperty("chat_reference")) { let chat_reference_resolved = await this.session.variables.review(this.chat_reference) if (typeof chat_reference_resolved === "string") { this.chat = await this.session.variables.get(chat_reference_resolved) } else if (typeof chat_reference_resolved === "object" && !Array.isArray(chat_reference_resolved)) { this.chat = chat_reference_resolved } if (this.chat) { this.chat_id = await this.getId(this.chat) if (!this.chat_id) { throw new Error("could not resolve chat reference for document " + this.chat.name + " " + this.chat._id) } } else { throw new Error("chat can not be resolved " + this.chat_reference) } } if (this.hasOwnProperty("from")) { /** list of chat participants ids the conversation accounts for */ this.from_ids = [] for (let from_reference of this.from) { let from_reference_resolved = await this.session.variables.review(from_reference) let from if (typeof from_reference_resolved === "string") { from = await this.session.variables.get(from_reference_resolved) } else if (typeof from_reference_resolved === "object" && !Array.isArray(from_reference_resolved)) { from = from_reference_resolved } if (from) { let from_id = await this.getId(from, from_reference) if (!from_id) { throw new Error("could not resolve chat reference for document " + from.name + " " + from._id) } this.from_ids.push(from_id) } else { throw new Error("'from' can not be resolved " + from_reference) } } } if(this.session.action.status == "active") { if (this.hasOwnProperty("while_idle") && this.while_idle.length) { this.setDelay(this.while_idle[0].delay) } else if (this.hasOwnProperty("finally")) { this.setDelay(this.finally.delay) } } if (!this.hasOwnProperty("priority")) { this.priority = 1 } if (this.hasOwnProperty("if") || this.hasOwnProperty("else")) { this.listenerCallback = this.session.getCallback(properties => { return new Promise((resolve, reject) => { this.queue.put(properties, response => { if(response && response.error) { return reject(response.error) } return resolve(response) }) }) }) /** queue of unresolved incoming message responses */ this.queue = new adaptor.Queue(this.incomingMessage.bind(this)) this.routing = this.agent.on(this.route, { id: this.chat_id, priority: this.priority }, this.listenerCallback) await this.agent.callPending(this.route, this.chat_id, this.incomingMessage.bind(this)) } if (!this.route) { this.agent.removeAllPending(this.chat_id) } } getAgent(agents, reference) { if (typeof reference === "string") { reference = reference.replace(this.session.action.plugin + '.' + agents.name + '.', '') this.agent = agents.findItem("name", reference) if (this.agent) { this.agent_reference = this.session.action.plugin + '.' + agents.name + '.' + reference return true } } else if (typeof reference === "object" && !Array.isArray(reference)) { if (agents.items.hasOwnProperty(reference._id)) { this.agent = agents.items[reference._id] return true } } } /** * Retrieve identification to allow agent to address the correct chat. * * @param {Object} chat - Conversation chat object to extract the required identification. */ async getId(chat) { let id = adaptor.getPath(chat, this.agent.plugin.id_key) if (!id) { throw new Error(`Could not start conversation with ${chat._id}. ${this.agent.plugin.id_key} property missing`) } return id } async sendMessage({text}) { text = await this.session.variables.review(text) await this.agent.sendMessage(text, this.chat_id) this.session.log(this.agent.name + " sent message to " + this.chat_id + ": " + text) } /** * compare incoming message with conditions. * * goto else response if there is no match. * * ignore if incoming groupchat message is not from the user specified in "from" * * cancel the "no answer" timeout responses if there is any type of response in if or else. * * @param {Object} properties - incoming message properties like text, date, from, to, media etc. * @returns {Promise} - resolve with reaction object signaling how to respond to incoming message or false if there is no response intended */ async incomingMessage(properties) { let content = "" let content_type = "" for(let type of this.content_types) { if(properties[type]) { content_type = type content = properties[type] } } this.session.log.info(`incoming message: '${content}' from '${this.chat_id}' to ${this.agent.name}`) if (this.hasOwnProperty("from")) { if (properties.peer_type == "chat" && !this.from_ids.includes(properties.from_id)) { return false } } if (this.hasOwnProperty("if")) { for (let if_condition of this["if"]) { let condition = new MessengerCondition(if_condition, content, this.session, this.game) let reaction = await condition.match() if (reaction) { await this.respond(reaction, properties) return true } } } if (this.hasOwnProperty("else")) { let reaction = Conversation.getReaction(this["else"], this.session) await this.respond(reaction, properties) return true } else { this.session.log('no match on message "' + content + '" from ' + this.chat_id + " and no else defined") return false } } /** * Respond according to reaction object. Maybe: * - run "next" callback * - download media * - respond to message * - reply to message * - forward message to * * message and response details are stored to dialog level variable: * **match** store what part of the message triggered next cue * **download** store download path and media type * * @param {Object} reaction - object that stores how to react on incoming message * @param {Object} message - message details * @returns {Promise} - resolves undefined once all is done */ async respond(reaction, message) { this.cancelDelayedMessages() if ("next" in reaction) { this.session.next(reaction.next) message["match"] = reaction.match } if ("respond" in reaction){ await this.sendMessage({text: reaction.respond}) } await this.session.variables.store(message) } /** * Find out how to react to the current event. * * returns one of these options: * - what message text to send (as response, reply or forward) * - to ignore * - to dispatch next cue (if there is no respond or if the last respond is called based on count * * @param {Object} react - stores information about if/how to react that are altered with each call of this function * * @returns {Object} - instructions on how to handle current response status. */ static getReaction(react, session, options = {}) { let return_value = {} if (react.hasOwnProperty("download")) { return_value["download"] = react.download } if (react.hasOwnProperty("respond") && react.respond.length > 0) { if (!react.hasOwnProperty("count")) { react["count"] = 0 } else { react.count++ } return_value["responses_sent"] = react.count return_value["responses_left"] = react.respond.length - react.count if (react.count >= react.respond.length - 1) { if (react.next) { return_value["next"] = react.next } else if (options.finally = "loop") { return_value["respond"] = react.respond[react.respond.length - 1] session.log("Loop last response on input no. " + react.count) return return_value } } if (react.count < react.respond.length) { return_value["respond"] = react.respond[react.count] return return_value } session.log("No response left. Ignoring no. " + react.count) return ({ type: 'ignore' }) } else { react["count"] = 0 if (react.hasOwnProperty("next")) { return_value["next"] = react.next } return_value["type"] = "ignore" return (return_value) } } /** * Idle time Event dispatched after delay passed * * If there are more delayed messages the next timer is started. Otherwise (if any) "finally" next state. */ async time() { if(!this.while_idle && this.finally) { return this.session.next(this.finally.next) } if(!this.while_idle.length && this.finally) { return this.session.next(this.finally.next) } let next_message = this.while_idle.shift() try { await this.sendMessage({text: next_message.message}) } catch (error) { if(error instanceof adaptor.AdaptorError) { this.session.log.error(error.name + " when sending delayed message") this.session.log.error(error.message) } } if(this.while_idle.length) { this.setDelay(this.while_idle[0].delay) } else if(this.finally) { if(this.finally.delay) { this.setDelay(this.finally.delay) } else { return this.session.next(this.finally.next) } } else { this.session.log(`No more delayed messages.`) } } setDelay(delay) { if(!this.delayed_messages_canceled) { this.session.log("Next idle response in " + delay + " seconds") this.timer = setTimeout(this.time.bind(this), delay * 1000) } } /** * clear current delayed message timeout and prevent creating new delayed message timeouts */ cancelDelayedMessages() { if(!this.delayed_messages_canceled) { this.session.log("delayed message timeout canceled") this.delayed_messages_canceled = true clearTimeout(this.timer) } } mute() { if(this.hasOwnProperty("while_idle")) { this.cancelDelayedMessages() } this.session.log("conversation muted") } unmute() { this.session.log(`unmute conversation`) } async cancel() { if (this.hasOwnProperty("if") || this.hasOwnProperty("else")) { this.agent.cancel(this.route, this.chat_id, this.routing) await this.queue.cancel() } if(this.hasOwnProperty("while_idle")) { this.cancelDelayedMessages() } this.session.log("conversation canceled") } } /** * Specify to find * * @param {Object} properties - condition properties * @class MessengerCondition * @extends {logic.Condition} */ class MessengerCondition extends logic.Condition { constructor(properties, value, session, game, options={}) { super(properties, session, game) this.properties = properties this.value = value this.options = options } feedback(result) { if (result) { let react = Conversation.getReaction(this.properties, this.session, this.options) react["match"] = result return react } return undefined } } /** * Base class for communication items. Organizes routing for incoming events. * Stacks routings with callbacks from different sessions and paths and sorts out what is being forwarded where. * * * @param {Array<string>} routes - list of route types for this type of agent * @param {Object} config - configuration properties will be assigned to instance * @param {Object} coll - db collection instance for access to agent items collection * * @class Agent * */ class Agent extends PluginItem { constructor(routes, config, collection, plugin, game) { super(config, collection, plugin, game) /** list of route types for this type of agent * @type {Array<string>} */ this.routes = routes /** storage of currently active routes. Each route type has its own entry * @type {Object<string, array|number>} */ this.routing = {} /** storage of pending incoming events * @type {Object<string, function>} */ this.pending = {} for (let route of routes) { this.routing[route] = {} this.pending[route] = {} } /** * 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 } /** * Alias for {@link Agent.addRouting} */ on(route, properties, callback) { return this.addRouting(route, properties, callback) } /** * add response callback to routing stack. * * If routing is ranked based on id (properties.id), the route list will be grouped based on id value. * . * Entries in route list are sorted based on *properties.priority* value. Lower priorities are added behind higher ones. Equal priorities are added at front (Last In First Out) * If no priority is provided, priority defaults to `1`. * * @param {string} route - * @param {Object} properties- Additional properties that are stored with the routing entry * @param {string} [properties.id] - Peer identification. What form it has (phone_number, messenger id) depends on the API * @param {number} [properties.priority=1] - Priority of this routing. * @param {function} callback * * @returns {number} position in routing stack array (unique id) */ addRouting(route, properties, callback) { if(!this.routing[route]) { if(properties.id) { this.routing[route] = {} this.pending[route] = {} } else { this.routing[route] = [] this.pending[route] = [] } } if (typeof properties.priority === 'undefined') { properties.priority = 1 } this.routing_id_count++ let route_item = Object.assign(properties, { callback: callback, route_id: this.routing_id_count}) if(properties.id) { if (this.routing[route].hasOwnProperty(properties.id)) { let pos = this.insertRoute(this.routing[route][properties.id], route_item) this.log(`Add '${route}' routing for id ${properties.id} with priority ${properties.priority} at position ${pos}. Route id: ${this.routing_id_count}`) return this.routing_id_count } else { this.routing[route][properties.id] = [route_item] this.log(`Add '${route}' routing for id ${properties.id} with priority ${properties.priority} at position 0. Route id: ${this.routing_id_count}`) return this.routing_id_count } } else { let pos = this.insertRoute(this.routing[route], route_item) this.log(`Add '${route}' routing with priority ${properties.priority} at position ${pos}. Route id: ${this.routing_id_count}`) return this.routing_id_count } } /** * * @param {array} routing - the segment in this.routing to add element * @param {*} item - the properties to add * @returns {number} - position in list where the route was added */ insertRoute(routing, route_item) { for (let i = 0; i < routing.length; i++) { if (routing[i].priority <= route_item.priority) { routing.splice(i, 0, route_item) return i } } let len = routing.push(route_item) return len - 1 } /** * Run callback for pending data that was stored on latest incoming message/event but was not retrieved since. * * @param {string} route - name of route type * @param {string} id - dialog identification for chat or player or similar entity * @param {Function} callback - Function to call for pending incoming data */ async callPending(route, id, callback) { if(this.pending[route] && this.pending[route][id]) { this.log.info(`Run callback on pending ${route} for ${id}`) return this.pending[route][id](callback) } } /** * @deprecated. Might be nice to reinstall if Agent becomes part of messenger classes also parenting twilio * * @param {*} pending * @param {*} id * @param {*} data */ addPending(pending, id, data) { pending[id] = callback => { delete pending[id] callback(data) .then(react => { this.messageResponse(res, react) }) .catch(error => { this.log.error(error) }) } } /** * Cancel all pending incoming routes listener for a player or chat * * @param {any} id - conversation player or chat id */ removeAllPending(id) { for(let route of this.routes) { if(this.pending && this.pending[route] && this.pending[route].hasOwnProperty(id)) { delete this.pending[route][id] } } } /** * remove routing that was added beforehand. * * @param {number} route_id - routing id as returned by {@link Agent.addRouting} * @param {string} [peer_id] - peer identification the routing was added under (if any) like phone number or messenger id */ removeRouting(route_id, peer_id) { for(let route in this.routing) { if(!peer_id) { for (let i = this.routing[route].length - 1; i >= 0; --i) { if (this.routing[route][i].route_id == route_id) { this.routing[route].splice(i,1) this.log(route + " routing " + i + " removed for " + peer_id) return } } } else if (this.routing[route].hasOwnProperty(peer_id)) { if (this.routing[route][peer_id].length <= 1) { if (this.routing[route][peer_id][0].route_id == route_id) { delete this.routing[route][peer_id] this.log("last " + route + " routing 0 removed for " + peer_id) } else { this.log.warn("can not remove " + route + " routing for " + peer_id + ". No routing registered with route id " + route_id) } return } for (let i = 0; i < this.routing[route][peer_id].length; i++) { if (this.routing[route][peer_id][i].route_id == route_id) { this.routing[route][peer_id].splice(i, 1) this.log(route + " routing " + i + " removed for " + peer_id) return } } this.log.warn("can not remove " + route + " routing for " + peer_id + ". No routing registered with route id " + route_id) } else { this.log.warn("can not remove " + route + " routing for " + peer_id + ". No routing registered.") } } } /** * cancel routing that was installed previously. * * @param {string} type - routing type like 'message'. Must be one of the available routings for this agent * @param {string} id - peer identification like phone number or messenger id * @param {number} route_id - routing id as returned by @see Agent.addRouting */ cancel(type, id, route_id) { if (this.routing[type].hasOwnProperty(id)) { if (this.routing[type][id].length <= 1) { if (this.routing[type][id][0].route_id == route_id) { delete this.routing[type][id] this.log("last " + type + " routing 0 removed for " + id) } else { this.log.warn("can not cancel " + type + " routing for " + id + ". No routing registered with route id " + route_id) } return } for (let i = 0; i < this.routing[type][id].length; i++) { if (this.routing[type][id][i].route_id == route_id) { this.routing[type][id].splice(i, 1) this.log(type + " routing " + i + " removed for " + id) return } } this.log.warn("can not cancel " + type + " routing for " + id + ". No routing registered with route id " + route_id) } else { this.log.warn("can not cancel " + type + " routing for " + id + ". No routing registered.") } } /** * callback executed by agent if there is a contact by a user that has no routing registered. * adds user with messenger chat id property to players collection if it does not exist. * * If player with given chat id (user_id) does not exist create a new player with chat id property. * * telegram_contacts property will be updated whenever a client interacts with this player. * * If player exists and is already part of a session based on the same level, ignore contact. * * 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. * * * @return {Promise} - Object with `level` name and `player` _id if a default level was started. Otherwise `{ignore:true}` * */ async initialContact(chat_id, level, chat_argument) { return await super.initialContact(this.plugin.id_key, chat_id, level, chat_argument || "Player") } command(input) { switch (input[0]) { case "routing": if (input.length == 2) { for (let rout in this.routing[input[1]]) { for (let i = 0; i < this.routing[input[1]][rout].length; i++) { this.log.info(rout + " " + i + ": route id " + this.routing[input[1]][rout][i].route_id + " priority " + this.routing[input[1]][rout][i].priority) } } } else { this.log.info(this.routing) } break case "cancel": if (input.length == 4) { this.cancel(input[1], input[2], input[3]) } else { this.log.info("cancel requires arguments type, chat id and route index") } break case "pending": this.log.info(this.pending) break default: super.command(input) } } } module.exports = { Messenger: Messenger, Conversation: Conversation, MessengerCondition: MessengerCondition, Agent: Agent }