UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

670 lines (594 loc) 20.7 kB
/** * sets the routing for incoming calls and sms and routes outgoing sms and calls to phone interface * takes a set of phone/twilio instances * * @requires twilio * * @requires phone * @requires plugin * @requires logic * @requires messenger * @requires body-parser * * * @module twilio/twilio * @copyright Lasse Marburg 2022 * @license MIT */ const phone = require('./phone.js') const twilio_api = require('twilio') const logic = require('../logic/logic.js') const messenger = require('../messenger.js') const urlencoded = require('body-parser').urlencoded var schema = require('./schema.json') const { default: RestException } = require('twilio/lib/base/RestException.js') /** @typedef {import('./phone.js').Phone} Phone */ /** * */ class Twilio extends messenger.Messenger { constructor() { super(schema, "phone_number") this.autoconnect = true /** list of twilio Studio flows that were found under given authentication */ this.flows = {} } async setup(config, game) { await super.setup(config, game) this.makeTemplates() this.app.use(urlencoded({ extended: false })) this.phones.add = async data => { data['url'] = this.game.url + '/twilio/phones/' + data.name data['files'] = this.game.files_url data['account'] = this.settings this.phones.items[data._id] = new phone.Phone(data, this.app, {name: "phones", db_coll: this.game.db["twilio_phones"], emitUpdate: this.phones.emitItemUpdate.bind(this.phones)}, config, this.game) this.schema.definitions.agent.enum.push(data.name) await this.schemaUpdate(this.schema) // await this.phones.items[data._id].connect() // This will happen on this.update this.phones.items[data._id].ready = true } this.phones.update = async (item, item_doc) => { if (item.name != item_doc.name) { let index = this.schema.definitions.agent.enum.indexOf(item.name) this.schema.definitions.agent.enum.splice(index, 1, item_doc.name) await this.schemaUpdate(this.schema) } } await this.phones.loadItems() // await this.update(config.settings) // await this.load({}) return this.schema } makeTemplates() { this.addTemplate("call", (payload, action) => { let title = `twilio ${action.name}` let subtitle = `${payload.agent} call ${payload.to}` let body = [] if(payload.sayplay) { for(let sayplay of payload.sayplay) { if(sayplay.say) { body.push({text: sayplay.say}) } if(sayplay.play) { body.push({text: sayplay.play}) } } } if(payload.next) { body.push({text: `Call done: ${payload.next}`, next: payload.next}) } if(payload.failed?.next) { body.push({text: `Call interrupted: ${payload.failed.next}`, next: payload.failed.next}) } return({title, subtitle, body}) }) this.addTemplate("onCall", (payload, action) => { let title = `twilio ${action.name}` let subtitle = `${payload.agent} await call from ${payload.from}` let body = [] if(payload.sayplay) { for(let sayplay of payload.sayplay) { if(sayplay.say) { body.push({text: sayplay.say}) } if(sayplay.play) { body.push({text: sayplay.play}) } } } if(payload.flow) { body.push({text: `Forward to Studio Flow ${payload.flow}`}) } if(payload.reject) { body.push({text: `Reject call as ${payload.reject}`, next:payload.next}) return({title, subtitle, body}) } if(payload.next) { body.push({text: `Call done: ${payload.next}`, next: payload.next}) } if(payload.failed?.next) { body.push({text: `Call interrupted: ${payload.failed.next}`, next: payload.failed.next}) } return({title, subtitle, body}) }) this.addDialogTemplate("onSms") } async connect(data) { await this.update(data.settings) } /** * (re)establish twilio client with updated settings */ async update(settings) { this.settings = settings if (settings.sid && settings.token) { try { this.log(settings.sid, settings.token) /** @type {twilio_api.Twilio} */ this.client = twilio_api(settings.sid, settings.token, { lazyLoading: false, }) log.info(this.name, 'twilio API connected:') log.info(this.name, settings) for(let item in this.phones.items) { this.phones.items[item].account.sid = settings.sid this.phones.items[item].account.token = settings.token this.phones.items[item].connect() } await this.gatherStudioFlows() this.setConnected(true) } catch (error) { this.setConnected(false) if(error instanceof RestException) { throw new adaptor.ConnectionError("Failed connecting to twilio API", {cause:error}) } else if(error.message.startsWith("account")) { throw new adaptor.InvalidError("Failed connecting to twilio API", {cause:error}) } throw error } } else { this.setConnected(false) throw new adaptor.InvalidError("couldn't connect to twilio service. Sid and or token missing.") } } async load(setup) { if(this.connected) { try { await this.gatherStudioFlows() } catch (error) { log.error(this.name, "Failed to get list of Twilio Studio Flows") log.error(this.name, error) } } return await super.load(setup) } /** currently not in use */ async listMessages() { this.log("Listing messages") let messages = await this.client.messages.list() messages.forEach(function (message) { console.log(message.sid) }) } /** * Store properties of all twilio studio flows that were created in twilio web interface */ async gatherStudioFlows() { this.schema.definitions.flow.enum = [] const flows = await this.client.studio.v2.flows.list() flows.forEach(flow => { this.flows[flow.sid] = flow this.schema.definitions.flow.enum.push(flow.friendlyName) }) await this.schemaUpdate(this.schema) } async sendSms(data, session) { data.chat_reference = data.to data.agent_reference = data.agent delete data.agent let conversation = new Conversation(data, session, this.game) await conversation.setup(this.phones) await conversation.sendMessage({text:data.text}) } async onSms(data, session) { data.chat_reference = data.from data.agent_reference = data.agent delete data.agent delete data.from let dialog = new Dialog(data, session, this.game) await dialog.setup(this.phones) return dialog.cancel.bind(dialog) } async call(data, session) { data.chat_reference = data.to data.agent_reference = data.agent delete data.agent let call = new Call(data, session, this.game) await call.setup(this.phones) call.outbound() return call.cancel.bind(call) } async onCall(data, session) { data.chat_reference = data.from data.agent_reference = data.agent delete data.agent delete data.from let call = new Call(data, session, this.game, this.flows) await call.setup(this.phones) call.inbound() return call.cancel.bind(call) } async flow(data, session) { data.chat_reference = data.to data.agent_reference = data.agent delete data.agent let flow = new Flow(data, session, this.game, this.flows) await flow.setup(this.phones) await flow.execute() } } class Conversation extends messenger.Conversation { constructor(data, session, game, route) { super(data, session, game, route) /** * The twilio phone API client * @type {Phone} */ this.agent } /* getId(chat) { if (!chat.hasOwnProperty('phone_number')) { throw new Error(`Could not start conversation with ${chat._id}. phone number property missing`) } return chat.phone_number } */ /** * @param {{text: string}} - message content * * @returns {Promise<boolean>} true if sending was successful, false otherwise */ sendMessage({text}) { return new Promise((resolve, reject) => { this.session.variables.review(text) .then(reviewed_text => { this.agent.sendSms(this.chat_id, reviewed_text, (error, message) => { if (error) { log.error(this.name, "error when sending sms from " + this.agent.name + " to " + this.chat_id) log.error(this.name, error) return resolve(false) } else { log.info(this.name, this.agent.name + ' sent: "' + message.body + '" to ' + message.to) return resolve(true) } }) }) .catch(error => { log.error(this.name, error) }) }) } } /** * * sms in conversations are started from the beginning if there was a restart! */ class Dialog extends Conversation { constructor(data, session, game) { super(data, session, game, "sms") // this.game = game this.name = session.name + " sms in" /** message routing id as returned by phone agent in route setup */ //this.route_id = undefined /** stacks all incoming messages during this conversation */ this.history = [] } async setup(agents) { await super.setup(agents) // this.route_id = this.agent.on('sms', { id: this.chat_id, priority: this.priority }, this.route.bind(this)) } /** * check incoming messages on matches. * * if any, dispatch first next in list of match Promises */ incomingMessage({phone_number, message}) { return new Promise((resolve, reject) => { message = message.replace(/(\r\n|\n|\r|\\|\'|\")/gm, " ") // replace line breaks, escape characters with single whitespace message = message.replace(/([ ]{2,})/gm, " ") // replace double or more whitespace characters with single whitespace message = message.trim() // remove leading and ending whitespaces this.session.log.info('incoming sms: "' + message + '" from ' + this.chat_id + " to " + this.agent.name) this.history.push(message) var sms_reaction let condition_promises = [] if (this.hasOwnProperty("if")) { for (let if_condition of this["if"]) { if_condition["value"] = message let condition = new logic.Condition(if_condition, this.session, this.game) condition_promises.push(this.match(condition, if_condition)) } } Promise.all(condition_promises) .then(result => { for (let react of result) { if (react) { sms_reaction = react sms_reaction["method"] = "if" return this.session.variables.store({ match: react.match, text: message }) } } if (this.hasOwnProperty("else")) { let else_react = this.reaction(this["else"]) sms_reaction = else_react sms_reaction["method"] = "else" } else { sms_reaction = { type: 'ignore' } this.session.log.info('no match on message "' + message + '" from ' + phone_number + " and no else defined") } return this.session.variables.store({ text: message }) }) .then(result => { if (sms_reaction.hasOwnProperty("text")) { return this.session.variables.review(sms_reaction.text) } return undefined }) .then(result => { if (sms_reaction.hasOwnProperty("text")) { if (result) { sms_reaction.text = result } this.cancelDelayedMessages() this.session.log.info(sms_reaction["method"] + " response to " + this.chat_id + ": " + sms_reaction.text) } if (sms_reaction.hasOwnProperty("next")) { this.session.next(sms_reaction.next) } return resolve(sms_reaction) }) .catch(error => { log.error(this.name, error) }) }) } /** * very similar to logic.Condition.match. Checks for responses before dispatching next */ match(condition_instance, properties) { return new Promise((resolve, reject) => { condition_instance.evaluate() .then(result => { if (result.value) { if (Array.isArray(result.condition)) { if (result.condition) { log.debug(this.name, "checking for '" + result.condition.join(',').substring(0, 30) + "'... in '" + result.value + "'") } } else if (typeof result.condition === "string") { log.debug(this.name, "checking for '" + result.condition.substring(0, 30) + "' in '" + result.value + "'") } else { log.debug(this.name, "checking for '" + result.condition + "' in '" + result.value + "'") } //log.debug(this.name, "checking for '" + result.condition + "' in '" + result.value + "'") return condition_instance.survey(result.value, result.condition) } else { return undefined } }) .then(result => { if (result) { let react = this.reaction(properties) react["match"] = result return resolve(react) } return resolve(undefined) }) .catch(error => { this.session.log.error(error) }) }) } /** * check for next and respond property. * dispatch next if there is no respond or if the last respond is called (count) * write message to local cue storage if it let to dispatching next cue * * returns either what sms text to send (as response), or to ignore * * @param {Object} react - contains information about if/how to react */ reaction(react) { let return_value = {} if (react.hasOwnProperty("respond")) { if (!react.hasOwnProperty("count")) { react["count"] = 0 } else { react.count++ } if (react.count >= react.respond.length - 1 && react.hasOwnProperty("next")) { if (react.next) { return_value["next"] = react.next } } if (react.count < react.respond.length) { return_value["type"] = "respond" return_value["text"] = react.respond[react.count] return (return_value) } log.debug(this.name, "No response left. Ignoring no. " + react.count) return ({ type: 'ignore' }) } else { react["count"] = 0 if (react.hasOwnProperty("next")) { return_value["next"] = react.next } //return({type:'ignore'}) return_value["type"] = "ignore" return (return_value) } } } /** * note on using the say property and special characters: * https://www.twilio.com/docs/voice/twiml/say#hints */ class Call extends Conversation { constructor(data, session, game, flows) { super(data, session, game, "call") this.canceled = false this.waiting = false this.flows = flows /** incoming call routing identifier as returned by phone agent */ this.call_id = undefined /** call status routing identifier as returned by phone agent */ this.status_id = undefined this.name = this.session.name + " call" } /** * resolve variables in sayplay property * */ async setup(agents) { await super.setup(agents) if (Array.isArray(this.sayplay)) { for (let sayplay of this.sayplay) { if (sayplay.say) { sayplay.say = await this.session.variables.review(sayplay.say) } } } } /** * make an outbound call and register call status to get feedback from twilio */ outbound() { this.was_answered = false this.status_id = this.agent.on('status', { id: this.chat_id, priority: this.priority }, this.callStatus.bind(this)) this.agent.call(this.chat_id) } /** * install a listener for incoming calls. * */ inbound() { this.waiting = true this.call_id = this.agent.on('call', { id: this.chat_id, priority: this.priority }, this.incomingCall.bind(this)) } /** * function is executed once a call comes in if inbound calls were registered * forwards to routing and installs listener for call status events from twilio */ incomingCall(phone_number) { this.status_id = this.agent.on('status', { id: this.chat_id, priority: this.priority }, this.callStatus.bind(this)) log.info(this.name, 'incoming call') return this.callStatus("incoming", { From: phone_number, phone_number: phone_number }) } /** * decide how to react on call events from twilio */ callStatus(status, data) { switch (status) { case "completed": this.was_answered = false log.debug(this.name, "Call completed after " + data.CallDuration + " seconds") if (this.hasOwnProperty("next")) { this.session.next(this.next) } this.agent.cancel('status', this.chat_id, this.status_id) return ({ type: "ignore" }) case "in-progress": case "incoming": if (this.was_answered) { return ({ type: "ignore" }) } else { this.was_answered = true if (this.hasOwnProperty("sayplay")) { // Set to player language if not specified otherwise for (let sp of this.sayplay) { if (sp.hasOwnProperty("say")) { if (!sp.hasOwnProperty("language")) { if (this.chat.hasOwnProperty("language")) { if (this.chat.language == "en") { sp["language"] = "en-GB" } else if (this.chat.language == "de") { sp["language"] = "de-DE" } } } } } return ({ type: "sayplay", answered: this.sayplay }) } else if (this.hasOwnProperty("flow")) { for (let flow in this.flows) { if (this.flows[flow].friendlyName == this.flow) { return ({ type: "flow", sid: this.flows[flow].sid, flowname: this.flow, webhook: this.flows[flow].webhookUrl }) } } log.error(this.name, this.flow + " doesn't name an existing Studio Flow") return ({ type: "reject", reason: "busy" }) } else if (this.hasOwnProperty("reject")) { log.debug(this.name, "Reject Call.") return ({ type: "reject", reason: this.reject }) } else { log.warn(this.name, "no reaction on established call defined. Call will be rejected.") return ({ type: "reject", reason: "busy" }) } } case "busy": case "failed": case "no-answer": case "interrupted": this.was_answered = false if (this.hasOwnProperty("failed")) { if (this.failed.hasOwnProperty("next")) { this.session.next(this.failed.next) } } else { log.warn(this.name, "Call failed with no next fail state defined") } log.warn(this.name, "Call failed. Phone Number: " + this.chat_id + " Reason: " + status) this.agent.cancel('status', this.chat_id, this.status_id) return ({ type: "ignore" }) default: return ({ type: "ignore" }) } } /** * cancel inbound listener if there is one and status event listener if there is one */ cancel() { if (this.waiting) { this.agent.cancel('call', this.chat_id, this.call_id) } else { this.agent.cancel('status', this.chat_id, this.status_id) } } } class Flow extends Conversation { constructor(data, session, game, flows) { super(data, session, game) this.flows = flows if (!this.parameters) { this.parameters = {} } } async setup(agents) { await super.setup(agents) this.studio_flow = Object.values(this.flows).find(flow => { if (flow.friendlyName == this.flow) { return true } }) if (!this.studio_flow) { throw new adaptor.NotFoundError(`${this.flow} is not an existing twilio studio flow.`) } } async execute() { let execution = await this.agent.client.studio.v2.flows(this.studio_flow.sid).executions.create( { parameters: this.parameters, to: this.chat_id, from: this.agent.settings.phone_number }) this.session.log("Executed Studio Flow: " + execution.sid + " " + this.flow) } } module.exports.Plugin = Twilio