Reacting to messages sent to Twilio

Ryan Rousseau - Apr 22 '20 - - Dev Community

Song recommendation of the day

The Locke and Key soundtrack is 🔥. I had it on repeat for weeks after finishing the show. This song from Billie Eilish is one of the highlights.

The plan

In my last session, I set up an Octopus Deploy subscription for deployments needing approval. The subscription sends a payload to a Firebase cloud function. That cloud function uses the Twilio API to text me about the deployment pending approval.

In this session, I added some more details to the SMS sent via Twilio. I configured replies to the message to approve or reject the deployment with the Octopus API. I reorganized the code a bit too.

More details in the initial text

The subscription payload has a Message property on the event with text like this:

Deploy to Production requires manual intervention  for blog.rousseau.dev release 0.0.8 to Production

I added a link to the deployment screen and instructions for how to respond.

// approvalRaised.js
function convertPayloadToMessage(payload) {
    let message = payload.Event.Message;
    let deploymentId = payload.Event.RelatedDocumentIds.find((id) => id.startsWith("Deployments-"));
    let url = `${payload.ServerUri}/app#/deployments/${deploymentId}`;
    return `${message}\n\nYou can view the deployment at ${url}\n\nReply with either 'Approve' or 'Reject' followed by notes.`;
}

exports.approvalRaised = functions.https.onRequest((req, res) => {
    return octopus.authorizeRequest(req, res)
        .then(octopus.getSubscriptionPayload)
        .then(octwilio.saveApproval)
        .then(convertPayloadToMessage)
        .then(octwilio.sendTwilioMessage)
        .then(() => { return res.status(200).send(); });
});

With the message text sorted out, I turned my attention to handling responses to the alert.

Oh yeah... State

Cloud functions and SMS are inherently stateless. When I reply to that text message, Twilio doesn't connect that to the message that was sent out before.

I have to track the state myself. Firebase has a few database options that are pretty easy to get started with. I created a new Firestore database and updated the code to store the payload.

// octwilio.js
exports.saveApproval = function (payload) {
    return db.collection('approvals').doc(config.twilio.approval.to_number).set(payload)
        .then(() => {
            return payload;
        });
};

// approvalRaised.js
exports.approvalRaised = functions.https.onRequest((req, res) => {
    return octopus.authorizeRequest(req, res)
        .then(octopus.getSubscriptionPayload)
        .then(octwilio.saveApproval)
        .then(convertPayloadToMessage)
        .then(octwilio.sendTwilioMessage)
        .then(() => { return res.status(200).send(); });
});

When a message comes in, I fetch the payload from Firestore based on the number the message is from.

// octwilio.js
exports.getApprovalRecord = function (from) {
    return db.collection('approvals').doc(from).get().then((doc) => {
        if (!doc.exists) {
            console.log("Cannot find document for " + from);
        } else {
            approval = doc.data();
            console.log(approval);
        }

        return approval;
    })
};

This approach has a downside. It only stores one payload per number. So, a user can only respond to the most recent pending approval. That sounds like a fair trade-off for being able to approve or reject by responding to a text.

Responding to messages sent to the Twilio number

You can configure Twilio numbers to send a data payload to a webhook when it receives a message. The payload includes the sender's phone number and the body of the message.

I created a new function named processMessage to handle the Twilio payloads.

If the message starts with "Approve" or "Reject," I process the approval. Otherwise, I ask the user the try again.

// processMessage.js
exports.processMessage = functions.https.onRequest((req, res) => {
    let from = req.body.From;
    let message = req.body.Body;
    let action = null;

    if (message.startsWith("Approve")) {
        action = processApproval(from, "Proceed", message, "Deployment approved.");
    } else if (message.startsWith("Reject")) {
        action = processApproval(from, "Abort", message, "Deployment rejected.");
    }
    else {
        action = octwilio.sendTwilioMessage("Sorry, I didn't understand that. Please try again.");
    }

    return action.then(() => { return res.status(200).send(); });
});

processApproval starts by fetching the approvalRecord. Then we use the Octopus API to find the approval, take responsibility, and submit it. Finally, we delete the approval from the database and respond to the user.

// processMessage.js
function processApproval(from, result, message, response) {
    return octwilio.getApprovalRecord(from)
        .then((approval) => { return createApprovalOptions(approval, result, message); })
        .then(octopus.findInterruption)
        .then(octopus.takeResponsibility)
        .then(octopus.submitInterruption)
        .then(() => { return octwilio.deleteApproval(from); })
        .then(() => { return octwilio.sendTwilioMessage(response); });
}

Calling the Octopus Deploy API

The subscription payload has the information needed to create the target API URLs. I need an API key to authorize the requests. I stored that key in the Firebase config.

firebase functions:config:set octwilio.octopus.apikey="API-MYSECRETISSAFE"

I have been using request-promise to make the API requests, but it is deprecated now. I will have to find a replacement for that.

findInterruption calls the API endpoint for finding interruptions (pending approvals in this case) related to a task. It is possible to have more than one pending approvals for each deployment. I am okay with assuming one approval at a time for this project.

// octopus.js
const rp = require('request-promise');
const octwilio = require('./octwilio');

let config = octwilio.config;
let headers = {
    'X-Octopus-ApiKey': config.octopus.apikey
};

exports.findInterruption = function (options) {
    let octopusUri = `${options.serverUri}\\api\\${options.spaceId}\\interruptions?regarding=${options.serverTaskId}`;
    const requestOptions = {
        method: 'GET',
        uri: octopusUri,
        json: true,
        headers: headers
    }

    return rp(requestOptions).then((results) => {
        console.log(results);
        let interruption = results.Items[0];

        options.interruption = interruption;

        return options;
    });
};

takeResponsibility sets the user responsible for the approval to the owner of the API token used.

// octopus.js
exports.takeResponsibility = function (options) {
    let interruption = options.interruption;

    let octopusUri = `${options.serverUri}/api/${options.spaceId}/interruptions/${interruption.Id}/responsible`;
    const requestOptions = {
        method: 'PUT',
        uri: octopusUri,
        json: true,
        headers: headers
    }

    return rp(requestOptions).then(() => {
        return options;
    });
};

submitInterruption approves or rejects the deployment and adds the message from the user as the notes for the approval.

// octopus.js

exports.submitInterruption = function (options) {
    let interruption = options.interruption;

    let octopusUri = `${options.serverUri}/api/${options.spaceId}/interruptions/${interruption.Id}/submit`;
    const requestOptions = {
        method: 'POST',
        uri: octopusUri,
        json: true,
        headers: headers,
        body: {
            Notes: options.message,
            Result: options.result
        }
    }

    return rp(requestOptions).then(() => {
        return true;
    });
};

Testing it out

Now that I had all these fancy new functions, it was time to give it a proper test run. I queued up a new deployment in Octopus.

Deployment pending approval in Octopus

I received the text message with the new details included.

Text message for pending approval

I replied with Approve - looks good to me. Octwilio sent back a confirmation that the deployment was approved.

Text message approving the deployment and confirming that it was approved

I checked the Octopus deployment page. The deployment was approved, and I can see my comments in the audit history.

Audit history for the deployment

Next time

I am happy with Octwilio in its current state. I need to complete the readme and add some instructions for setting it up. I did leave some room in processMessage to accept other commands via text. It would probably be cleaner to use a separate phone number for that. I don't think it would be too bad to host a few more commands with that one number.

Cover photo by K. Mitch Hodge on Unsplash.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .