UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

622 lines (572 loc) 17.6 kB
/** * 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 }