adaptorex
Version:
Connect all your live interactive storytelling devices and software
622 lines (572 loc) • 17.6 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
}