adaptorex
Version:
Connect all your live interactive storytelling devices and software
915 lines (824 loc) • 34.7 kB
JavaScript
/**
* 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
}