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