adaptorex
Version:
Connect all your live interactive storytelling devices and software
516 lines (465 loc) • 15.3 kB
JavaScript
/**
* connect to twilio Account via webhook
*
* @requires twilio
* @requires messenger
*
* @module twilio/phone
* @copyright Lasse Marburg 2022
* @license MIT
*/
const twilio = require("twilio")
const { default: RestException } = require('twilio/lib/base/RestException.js')
const { Agent } = require("../messenger")
/** twilio xml message response generator */
const MessagingResponse = twilio.twiml.MessagingResponse
const VoiceResponse = twilio.twiml.VoiceResponse
/**
* Phone interface Module to connect to twilio API
*
* Each phone creates it's own twilio API connector.
*
* Twilio Account Webhooks need to be set to HTTP GET
* the phones address routing for incoming call and sms is build like this:
* <adaptor url>/<game name>/twilio/phones/<phone name>/sms
* <adaptor url>/<game name>/twilio/phones/<phone name>/call
*
* @param {Object} config - has to contain all properties necessary to configure twilio interface
* @param {string} config.name - name for this twilio phone instance. First part for Twilio Request address
* @param {string} config.settings.sid - twilio SID
* @param {string} config.settings.token - twilio Token
* @param {string} config.settings.phone_number - twilio phone number
* @param {string} config.url - url for this phones webhook
* @param {Object} app - Express app forwarded from adaptor:ex
*/
class Phone extends Agent {
constructor(config, app, collection, plugin, game) {
super(['call', 'status', 'sms'], config, collection, plugin, game, "phone_number")
this.web_routes = {
'sms': this.smsIn.bind(this),
'call': this.callIn.bind(this),
'callstatus': this.callStatus.bind(this)
}
this.app = app
/** allows to mute outgoing sms to save money or to stop sending in case of emergency */
this.locked = false
this.setRouting(this.name, this.settings)
this.autoconnect = false
this.storeSettings(this.settings)
}
async update({ settings, name }) {
if (!settings.hasOwnProperty('method')) {
settings["method"] = 'POST'
}
let changed_settings = false
let emitted_update = false
if (settings.method != this.settings.method || name != this.name) {
this.removeRouting(this.name)
this.setRouting(name, settings)
changed_settings = true
}
if(settings.sid != this.settings.sid || settings.token != this.settings.token) {
this.settings = settings
this.connect()
emitted_update = true
} else {
this.settings = settings
}
if (changed_settings) {
await this.storeSettings(this.settings)
if(!emitted_update) {
this.emitUpdate()
}
}
this.name = name
}
/**
* (re-)create twilio client
* If phone has specific sid and token use it instead of plugin sid and token
*/
connect() {
if (this.settings.sid && this.settings.token) {
this.connectClient(this.settings.sid, this.settings.token)
} else if(this.account.sid && this.account.token) {
this.connectClient(this.account.sid, this.account.token)
} else {
this.setConnected(false)
throw new adaptor.InvalidError("couldn't connect to twilio service. Sid and or token missing.")
}
}
/**
* Connect Twilio client
*
* @param {string} sid - Twilio account SID
* @param {string} token - Twilio account TOKEN
*/
connectClient(sid, token) {
try {
/** @type {twilio.Twilio} */
this.client = new twilio(sid, token)
log.info(this.name, 'Twilio client connected')
log.debug(this.name, this.settings)
this.setConnected(true)
} catch (error) {
this.setConnected(false)
if(error instanceof RestException) {
throw new adaptor.ConnectionError(`Failed connecting phone ${this.name} to twilio API`, {cause:error})
} else if(error.message.startsWith("account")) {
throw new adaptor.InvalidError(`Failed connecting phone ${this.name} to twilio API`, {cause:error})
}
throw error
}
}
setRouting(name, settings) {
settings.webhooks = {}
if (settings.method && settings.method.toUpperCase() == 'GET') {
settings.webhooks = this.setWebhooks(name, "get")
} else {
settings.webhooks = this.setWebhooks(name, "post")
}
return settings
}
setWebhooks(name, method) {
let webhooks = {}
this.log.info(`enable sms, call and callstatus ${method.toUpperCase()} webhooks at '/twilio/phones/${name}'`)
for (let route in this.web_routes) {
this.app[method](`/phones/${name}/${route}`, this.web_routes[route])
let urls = this.game.getWebhookURLs(`/twilio/phones/${name}/${route}`)
webhooks[route] = {}
urls.forEach(url => {
if(url.name != "local") {
webhooks[route][url.name] = url.url
}
})
}
return webhooks
}
removeRouting(name) {
if(!this.app.stack) {
return
}
this.app.stack.forEach((routing, i, routes) => {
if (routing.route) {
for (let route in this.web_routes) {
if (routing.route.hasOwnProperty('path')) {
log.debug(0, "check " + routing.route.path + " against " + route)
if (routing.route.path == '/phones/' + name + '/' + route) {
routes.splice(i, 1)
log.info(0, 'express route removed: ' + routing.route.path)
}
}
}
}
})
}
/**
* remove express routings
* not sure if twilio client also needs to or can be closed
*/
close() {
this.removeRouting(this.name)
}
/**
* handles sms in requests from twilio
*
* uses request.query to evaluate what message came from whom
* if 'from' property is a registered number, forward to registered callback
*
* the callbacks registered are in a stack per phone number. smsIn always uses the most recent entry, which is on index 0
* routings are added on array bottom see {@link Phone.registerSmsIn}
*
* if query is empty, try body. Useful for test requests with curl or via browser url
*
* If there is no routing registered for the phone number and if the Phone has a default level defined sms in is pending.
* That means a callback is stored in pending object for this phone number and allows the next routing registered for this number to immediately reply
*/
smsIn(req, res) {
/*
log.info("HEADER", req.headers)
log.info("QUERY", req.query)
log.info("BODY", req.body)
log.info("msg", req.query.Body)
*/
let content = {}
if (req.query.From) {
content = req.query
} else if (req.body.From) {
Object.assign(content, req.body)
} else {
log.error(this.name, 'invalid request to /sms webhook')
log.error(this.name, req.headers)
res.status(400)
res.send('invalid request')
return
}
if (this.routing.sms[content.From]) {
this.routing.sms[content.From][0].callback({phone_number: content.From, message: content.Body})
.then(reaction => {
this.messageResponse(res, reaction)
return
})
.catch(error => {
log.error(this.name, error)
})
} else if (this.settings.hasOwnProperty("level")) {
log.info(this.name, "incoming sms from unregistered phone number " + content.From)
this.pending.sms[content.From] = callback => {
delete this.pending.sms[content.From]
callback({phone_number: content.From, message: content.Body})
.then(react => {
this.messageResponse(res, react)
})
.catch(error => {
log.error(this.name, error)
})
}
this.initialContact(content.From, this.settings.level)
.then(result => {
if (result.ignore) {
log.info(this.name, "ignore message. " + result.player._id + " is already assigned to a session based on " + result.level)
this.pending.sms[content.From](data => {
return Promise.resolve({type:'ignore'})
})
} else {
log.info(this.name, "started default level " + result.level + " with player " + result.player.phone_number + "(" + result.player._id + ")")
}
})
} else {
log.warn(this.name, 'no routing for incoming message from ' + content.From + ' and no default level defined.')
log.warn(this.name, content.Body)
this.emptyResponse(res)
}
}
/**
* handle incoming call requests from twilio
*/
callIn(req, res) {
let content = {}
if (req.query.From) {
content = req.query
} else if (req.body.From) {
Object.assign(content, req.body)
} else {
log.error(this.name, 'invalid request to /call')
log.error(this.name, req.headers)
res.status(400)
res.send('invalid request')
return
}
if (this.routing.call[content.From]) {
let reaction = this.routing.call[content.From][0].callback(content.From)
return this.voiceResponse(res, reaction)
} else if (this.settings.hasOwnProperty("level")) {
log.info(this.name, "incoming call from unregistered phone number " + content.From)
this.pending.call[content.From] = callback => {
delete this.pending.call[content.From]
let react = callback(content.From)
this.voiceResponse(res, react)
}
this.initialContact(content.From, this.settings.level)
.then(result => {
if (result.ignore) {
log.info(this.name, "ignore call. " + result.player._id + " is already assigned to a session based on " + result.level)
this.pending.call[content.From](data => {
return { type: "reject", reason: "busy" }
})
} else {
log.info(this.name, "started default level " + result.level + " with player " + result.player._id)
}
})
} else {
log.warn(this.name, 'no routing for incoming call from ' + content.From + ' and no default level defined.')
this.voiceResponse(res, { type: "reject", reason: "busy" })
}
}
/**
* handle call status requests from twilio
*
* clean up pending queue if theres something left
*
* check for routing and get reaction from callbacks accordingly
*/
callStatus(req, res) {
let content = {}
if (req.query.From) {
content = req.query
} else if (req.body.From) {
Object.assign(content, req.body)
} else {
log.error(this.name, 'invalid request to /callstatus')
log.error(this.name, req.headers)
res.status(400)
res.send('invalid request')
return
}
let phone_number = ""
if (content.Direction == "inbound") {
phone_number = content.From
} else if (content.Direction == "outbound" || content.Direction == "outbound-api") {
phone_number = content.To
}
content['phone_number'] = phone_number
log.debug(this.name, content.Direction + " Call Status " + content.CallStatus + " " + phone_number)
if (content.CallStatus == "completed") {
if (content.hasOwnProperty("CallDuration")) {
if (content.CallDuration < 2) {
content.CallStatus = "interrupted"
}
}
if (this.pending.call.hasOwnProperty(phone_number)) {
delete this.pending.call[phone_number]
}
}
if (this.routing.status[phone_number]) {
let reaction = this.routing.status[phone_number][0].callback(content.CallStatus, content)
return this.voiceResponse(res, reaction)
} else {
log.warn(this.name, 'no routing for call status change from ' + content.From)
}
this.respond(res, 'OK')
}
respond(res, response) {
// Close response message
res.writeHead(200, {
'Content-Type': 'text/xml'
});
res.end(response.toString());
}
emptyResponse(res) {
this.respond(res, new MessagingResponse)
}
messageResponse(res, reaction) {
const twiml = new MessagingResponse()
switch (reaction.type) {
case 'respond':
if (this.locked) {
log.warn(this.name, "Try responding with '" + reaction.text + " but sending sms is locked.")
} else {
twiml.message(reaction.text)
}
break
case 'ignore':
break
}
this.respond(res, twiml)
}
voiceResponse(res, reaction) {
const twiml = new VoiceResponse()
switch (reaction.type) {
case "say":
twiml.say({ voice: reaction.voice, language: reaction.language }, reaction.say)
break
case "play":
twiml.play(this.files + '/' + reaction.play)
break
case "sayplay":
for (let answered of reaction.answered) {
if (answered.hasOwnProperty("say")) {
let voice = this.settings.voice
if (answered.hasOwnProperty("voice")) {
voice = answered.voice
}
let language = this.settings.language
if (answered.hasOwnProperty("language")) {
language = answered.language
}
twiml.say({ voice: voice, language: language }, answered.say)
log.debug(this.name, "Say: " + answered.say + " (" + voice + ", " + language + ")")
} else if (answered.hasOwnProperty("play")) {
twiml.play(this.files + '/' + answered.play)
log.debug(this.name, "Play: " + this.files + '/' + answered.play)
}
}
break
case "flow":
// see https://www.twilio.com/docs/voice/twiml/redirect
log.debug(this.name, "Redirecting to Studio Flow " + reaction.flowname + " " + reaction.sid)
twiml.redirect({ method: 'POST' }, reaction.webhook)
break
case "reject":
twiml.reject({ reason: reaction.reason })
break
case "hangup":
twiml.hangup()
break
case "ignore":
break
default:
twiml.reject({ reason: "busy" })
break
}
this.respond(res, twiml)
}
/**
* Send sms
* @param {String} phone_number number to send to
* @param {String} text text of message to send
* @param {function} callback function to call when done sending
*/
sendSms(phone_number, text, callback) {
if(!this.connected) {
throw new adaptor.ConnectionError("Can not send sms. Twilio client is not connected.")
}
if (this.locked) {
log.warn(this.name, "Try sending '" + text + "' to " + phone_number + " but sending sms is locked.")
callback(undefined, { body: "not send", to: "to nobody" })
return
}
this.client.messages.create({
to: phone_number,
from: this.settings.phone_number,
body: text
}, callback)
}
/**
* call out
* See Twilio Reference here:
* https://www.twilio.com/docs/api/voice/making-calls#url-parameter
* Example for Status Callbacks:
* https://www.twilio.com/docs/api/voice/making-calls#make-a-call-and-monitor-progress-events
* @param {String} phone_number - phone number to call
* @param {String} properties.file - path/to/audiofile to play when call established
* @param {function} properties.established - is called when call was established (in a closed handler this would be done internally)
* @param {function} properties.interrupt - is called when call couldnt be established
* @param {function} properties.ended - is called with duration when established call is finished
*/
call(phone_number) {
if(!this.connected) {
throw new adaptor.ConnectionError("Can not send sms. Twilio client is not connected.")
}
this.client.calls.create({
to: phone_number,
from: this.settings.phone_number,
url: this.url + '/callstatus',
method: this.settings.method,
statusCallback: this.url + '/callstatus',
statusCallbackMethod: this.settings.method,
statusCallbackEvent: ["initiated", "ringing", "answered", "completed"]
})
.then(response => {
log.info(this.name, `Calling ${phone_number}`)
log.trace(this.name, response)
})
.catch(err => {
log.error(this.name, `Could not make call to ${phone_number}`)
log.error(this.name, err)
})
}
command(input) {
switch(input[0]) {
case "lock":
this.locked = true
log.info(this.name, "locked")
break
case "unlock":
this.locked = false
log.info(this.name, "unlocked")
break
default:
super.command(input)
}
}
}
module.exports = {
Phone: Phone
}