Instant Customer Feedback via SMS using Go and Slack

Customer Feedback via SMS using Go and Slack

Posted on

Using an SMS Gateway called 46elks and some Go we can automate a customer feedback process which is usually done manually by calling a customer. This was an idea I had when I worked at a large sales company in Sweden a few years ago and my task was to find things that could be automated to save money and speed things up. To keep track of feedback and manually handle negative comments I also piped the messages to Slack which we will be doing in this guide as a bonus at the end. The following is a how to guide describing how you can create this yourself. You can find the full source code on Github.

SMS Gateway

The first thing we need to is find a SMS Gateway for sending our text messages to our customers. I met 46elks at HackForSweden this year and it seems to perform really well when I used them for this guide. However, you could go with any SMS Gateway and some of the more popular options are Twilio, Amazon or ClockworkSMS. Most gateways will provide similar APIs so it shouldn’t be too hard to replace my 46elks implementation with another provider.

Sending texts and receiving customer feedback

Let’s dive into some code. If you don’t have Go installed I have a guide covering how to install Go. All code for this guide can be found in the Github repository. The first thing we will do is to set up the types and structs that we will need for our methods.

type Message int

const (
	Positive Message = 0
	Negative Message = 1
	Neutral  Message = 2
)

type Customer struct {
	Replied     bool
	Done        bool
	MessageType Message
}

The first thing I do here is to create a new type called Message. This type will have three constants for Positive, Negative or Neutral customer feedback responses. Next we define our Customer struct which represents one customer who is responding with feedback. The struct will hold the type of feedback and keep track of if the customer has replied before and if we are done sending SMS responses to that customer.

Now let’s take a look at the variables we will need.

var (
	port = ":80"

	numberFrom   = ""
	username     = ""
	password     = ""
	slackWebhook = ""

	message1        = "Hello Markus, thank you for visiting GoPHP.io! On a scale from 1 to 10, with 10 being the best, how would you rate your experience?"
	messagePositive = "We are happy you had a good experience at GoPHP.io! PLease reply back to use if you would like to tell us why you liked the expereince"
	messageNegative = "We are sorry GoPHP.io did not live up to your expectations. Please reply back to us if you would like to tell us why you were not satisfied"
	messageFinal    = "Thank you for your feedback, if you would like to get in touch with use please send an email to markus@gophp.io"
)

We begin by setting the port which our program will listen on, in my case this is port 80. The next 4 variables are configuration variables or credentials needed to communicate with the various APIs. These variables will be set as arguments from the command line which will be explained in the next section. The final variables are the message variables which define the text in our text messages. message1 is the first message that we send to a customer. Depending on the response which we hope to be a number between 1 and 10 we will answer with one of the following responses. You will get a better understanding of how these variables are used later in the guide, for now you simply need to remember that they are there.

The main function

Now we can go ahead and create our main function. The main function of a Go program is called automatically when a program runs and is our entry point.

func main() {
	args := os.Args[1:]

	if len(args) != 4 {
		panic("must supply 46elks username and password, from number and a Slack webhook")
	}

	username = args[0]
	password = args[1]
	numberFrom = args[2]
	slackWebhook = args[3]
	customers = make(map[string]Customer)
}

Let’s break down our main function a bit. The first part takes all arguments passed to our program via the command line.

go run main.go [46elks username] [46elks password] [Elks46 phone number] [Slack webhook]

Then we check that the number of arguments is 4. If there are less or more than 4 arguments our program will panic and shutdown without executing anything else.

After that we take our arguments and set them to the variables which we previously declared and also initiate our customers variable with an empty map of map[string]Customer. The string in the map will be the phone number of the incoming text message. This will allow us to handle multiple customers replying at different times with the same program.

Triggering the outgoing text message

To make our program usable we will need an outgoing sms method which will take a field to as an argument. This argument is the phone number which we want to send a text message to. For a production project you might want to add a name field here and add it to our customer map. However, I did not do this for the example in order to keep things simple. I will call this function handleOutgoingSMS.

func handleOutgoingSMS(w http.ResponseWriter, r *http.Request) {

	if err := r.ParseForm(); err != nil {
		fmt.Println(err)
		return
	}

	to := r.FormValue("to")

	sendSMS(to, message1)
}

This function takes two arguments, a http.ResponseWriter and a http.Request. The reason for this is that we will be using this with the http package in the http.HandleFunc function later which requires these arguments. The first thing we will do in this function is to call ParseForm. This will check for post parameters that are send to our function and it is needed before calling FormValue. We will check for an error here but note that we no longer call panic because we don’t want our program to stop. Instead we simply print the error and return which keeps or program running.

After calling ParseForm we call FormValue with the key to as the argument to get it’s value which is the customers phone number as a string. I am not too worried about validation here since I am the only one calling this endpoint but it would be good to do on a production program. Finally we call a function called sendSMS with the to parameter and our variable message1 which we defined earlier. We have not created the sendSMS function yet, so let’s go ahead and do that now.

Sending a text message

func sendSMS(to string, message string) {
	data := url.Values{
		"from":    {numberFrom},
		"to":      {to},
		"message": {message}}

	req, err := http.NewRequest("POST", "https://api.46elks.com/a1/SMS", bytes.NewBufferString(data.Encode()))
	if err != nil {
		fmt.Println(err)
                return
	}
}

Above we have the first part of the sendSMS method. First we declare our data variable which contains an object with several values: the number we are sending from, the number we are sending to and finally the message we are sending. After that we create a new request as our req variable. This request will be a post request and calls the 46elks api endpoint which can be found in the 46elks documentation for sending a SMS. In this request we also include our encoded data as an io.Reader that is the body of our request. At this point we have not sent a request yet. We still need to add some headers and create a http client. Let’s add that now.

func sendSMS(to string, message string) {
        ...
	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Add("Content-Length", strconv.Itoa(len(data.Encode())))
	req.SetBasicAuth(username, password)
}

Please note that I use 3 dots () as a placeholder for the code we already covered in this function. So now that we have a request as a req variable we need to add our headers. The first declares our Content-Type which will be application/x-www-form-urlencoded since we are posting what is essentially form data. Next we specify the Content-Length header which is simply an integer equal to the length of our encoded data. Our last and most important header is the authentication header which is basic auth for 46elks which allows use to use the SetBasicAuth method with the 46elks username and password which can be found inside your 46elks dashboard.

func sendSMS(to string, message string) {
        ...
        client := &http.Client{}
	resp, err := client.Do(req)

	if err != nil {
		fmt.Println(err)
                return
	}

	defer resp.Body.Close()
	body, err := ioutil.ReadAll(resp.Body)

	if err != nil {
		fmt.Println(err)
                return
	}

	fmt.Printf("Sent sms to %s: %s\n", to, message)
	fmt.Println(string(body))

After we have our headers we need to create our http client which has a Do method that we can pass our request to in order to execute it. This method returns a response which we have saved as a variable called resp. The resp variable has a Body which is a io.ReadCloser which is a stream of data, by calling defer on the Close method we are saying that the ReadCloser should close when the function exits and not remain open. We can then read the stream using ioutil.ReadAll and save the content as a byte array we call body. Now we print that a text message has been sent to our console along with the response body. You can easily convert a byte array to a string by typecasting as I do in the code above.

That is all we need to handle outgoing SMS. In order to call this method we can set up a web server. We can do that by adding some more lines to our main function.

A simple web server

func main() {
	...
        http.HandleFunc("/outgoing", handleOutgoingSMS)
	fmt.Println("Listening on port " + port)
	err := http.ListenAndServe(port, nil)
	if err != nil {
		panic(err)
	}
}

We start by telling http to use our handleOutgoingSMS function anytime someone makes a request to /outgoing on our server. Then we call http.ListenAndServe with the port which I have set to 80 but you could set it to pretty much any port number. If you use 80 you should be able to use a program like Postman to make a post request to your endpoint at http://127.0.0.1/outgoing. If you use a different port such as 3000 you might need to call http://127.0.0.1:3000/outgoing instead.

I have added my Postman collection for incoming and outgoing sms on Github so that you can easily import and test these on your own program. Here is the url: https://github.com/markustenghamn/smssalesexample/blob/master/SMS%20Sales%20Example.postman_collection.json

Receiving a text message

Now that we have a working function for sending a text messages we can get started with our function for handling incoming text messages. These messages will be the responses from customers. We hope the responses will be numeric between 1 and 10 but I have learned that it’s impossible to predict responses. Anything from “You guys are #1” to “i tried calling you 10 times and no one answers!” can be misinterpreted very easily if 10 is very good and 1 is very negative. Therefore I will keep things simple and respond positively to anything message with only a number between 8 and 10 as positive. Anything that is 3 or lower will be responded to with a apologetic tone. Any other responses that we get will receive our neutral and final response.

So let’s begin by creating a function called determineMessageType which takes a string and tries to determine if the response is positive, negative or neutral.

func determineMessageType(message string) Message {
	if len(message) <= 2 {
		value, err := strconv.Atoi(message)
		if err == nil {
			if value >= 8 {
				return Positive
			} else if value <= 3 {
				return Negative

			}
		}
	}
	return Neutral
}

This function first checks the length of the incoming message variable which will be the string sent as feedback from our customers. If the length is greater than 2 we directly return Neutral. Neutral in this case is one of the constants for our Message type that we declared at the beginning of this article. Our function has to return a Message type. If the length is less than or equal to two we can continue on to the second line inside our function. Here we try to convert the message string to an integer. If it fails we will have an error in the err variable and return Neutral but if there is no error we continue on to the next if statement. Here we check if the value of the integer is greater to or equal to 8. If that is the case we return Positive and if the value is less than or equal to 3 we return Negative. If value is somewhere in between then we once again return Neutral.

Now that we have that function in place let’s begin planning our function to handle incoming text messages. The following is the structure of the post body we expect to receive from 46elks, our SMS gateway, as specified in their API documentation.

direction=incoming&
id=sf8425555e5d8db61dda7a7b3f1b91bdb&
from=%2B46706861004&to=%2B46706861020&
created=2018-07-13T13%3A57%3A23.741000&
message=Hello%20how%20are%20you%3F

The first key is called direction which will always be incoming in our case. If you want to expand on this guide you could implement methods to receive delivery notifications for outgoing sms. The next key is id which is a good reference in case you need to debug something. The from key is the customers phone number. The created key is the date and time for when the sms was received. Last but not least we have the message key which contains the customer feedback which we are interested in. You may see weird percentage signs or missing characters. This is because the values are url encoded so spaces turn into %20 and plus signs turn into %2B. When we parse these value they are automatically decoded as you will see in the code below.

func handleIncomingSMS(w http.ResponseWriter, r *http.Request) {

	if err := r.ParseForm(); err != nil {
		fmt.Println(err)
		return
	}

	direction := r.FormValue("direction")
	id := r.FormValue("id")
	from := r.FormValue("from")
	to := r.FormValue("to")
	created := r.FormValue("created")
	message := r.FormValue("message")

	fmt.Printf("[%s][%s][%s] %s => %s: %s\n", created, direction, id, from, to, message)
}

Here we use the function ParseForm to read the parameters which have been posted to our endpoint for incoming sms. ParseForm reads any data and url decodes it so that its human readable again and no longer contain the percent signs that we saw in the above example. We can now use the FormValue function which takes a parameter string as an argument. This is done for all parameters and then log them using fmt.Printf. We won’t be using all of these parameters in the function but logging them can help you understand better or spot problems. This is only the beginning of the function and below we will go through the rest. Once again I have used to represent the code we already covered above.

func handleIncomingSMS(w http.ResponseWriter, r *http.Request) {
        ...
        var customer Customer
	if val, ok := customers[from]; ok {
		customer = val
	} else {
		customer = Customer{Replied: false, Done: false, MessageType: Neutral}
	}
	if !customer.Replied && !customer.Done {
		msgType := determineMessageType(message)
		if msgType == Positive {
			sendSMS(from, messagePositive)
			customer.MessageType = msgType
			customer.Replied = true
			customers[from] = customer
			return
		} else if msgType == Negative {
			sendSMS(from, messageNegative)
			customer.MessageType = msgType
			customer.Replied = true
			customers[from] = customer
			return
		}
		// If we are unable to determine a positive or negative answer
		sendSMS(from, messageFinal)
		customer.Replied = true
		customer.Done = true
		customers[from] = customer
		return

	} else if !customer.Done {
		sendSMS(from, messageFinal)
		customer.Done = true
		customers[from] = customer
		return
	}
}

The first thing we do now is create an new variable which is of the Customer struct type. In the first if statement we check if we already have a customer in our slice with this incoming phone number and set that to our customer variable. If we don’t have an existing customer we create a new instance of the Customer struct and use that instead. Next we go on to our second if statement which checks if the customer has replied or if the customer is already done. This if statement should only be true for new customers which we have never replied to and if that is the case we assume the customer should reply with a number, 1-10. We can then use the function from the previous section called determineMessageType to determine if the message is positive or negative.

In the next if statement we check if we have a positive message and if so we send a positive response to our customer via the sendSMS function. We update the customer by setting Replied to true to indicate that we sent the first response and we now expect feedback. We then add the customer variable to our slice of customers so that our first if statement will find this the next time the customer sends us an sms. If the message type was instead negative we do the same thing but reply with a negative response. Finally, if the message type is neither negative or positive we know the user wrote something other than a positive or negative message. If this is the case a final message response is sent and the Done bool is set to true in order to indicate that we will not respond to any more customer replies.

The last else if !customer.Done statement is to handle customer feedback after we ask for it when the customer responded with a number. Here we respond with our final message and once again set Done to true.

Please note that the slice that keeps track of customers responses is currently kept in memory. If you would like to use this in a production environment I would highly recommend keeping track of this and customer responses in a database such as MariaDB. Without a database all of this data is lost when the program or server is restarted.

This covers everything we need in our program to receive incoming sms. You can test this method using a program like Postman. If you would like to get actual updates from an API you will need to make your program accessible publicly which I will cover in the next section.

A public facing server for incoming SMS

To receive a text message you will need to allow your program to be reachable from the rest of the internet. I created one of the smallest possible instances on Amazon Web Services and ran my program there, I wrote a guide for that in another post you can find here. You can also do this by forwarding a port on your router to the computer where the program is running.

Posting incoming customer feedback to Slack

Posting incoming customer feedback to Slack

As a bonus I also want to post all of the customer responses to Slack. I did this for a previous employer in the past who was looking to automate. They had one channel for all sms and then a separate channel for all the negative or neutral sms. The idea was that you could have representatives reach out to help customers who reported problems. This was pretty effective and I think it really made a huge impact on making customers happier.

You will need to create a Slack webhook for this to work. You can read the documentation here which describes how to create a webhook for Slack. Once you have set this up you will get a webhook from Slack which you can pass as an argument to our program. Now, to the code, this is the function which we will use.

func updateSlack(from string, message string, t Message) {
	color := "#C0C0C0"
	if t == Positive {
		color = "#006400"
	} else if t == Negative {
		color = "#8B0000"
	}
	var jsonStr = []byte(`{
			"attachments": [
				{
					"fallback": "` + message + `",
					"color": "` + color + `",
					"text": "` + message + `",
					"fields": [
						{
							"title": "Tel",
							"value": "` + from + `",
							"short": false
						}
					],
				}
    		]}`)
	req, err := http.NewRequest("POST", slackWebhook, bytes.NewBuffer(jsonStr))
	if err != nil {
		fmt.Println(err)
		return
	}
	req.Header.Add("Content-Type", "application/json")
	req.Header.Add("Content-Length", strconv.Itoa(len(jsonStr)))
	req.SetBasicAuth(username, password)

	client := &http.Client{}
	resp, err := client.Do(req)

	if err != nil {
		fmt.Println(err)
		return
	}

	defer resp.Body.Close()
	body, err := ioutil.ReadAll(resp.Body)

	if err != nil {
		fmt.Println(err)
		return
	}

	fmt.Printf("Sent slack message: %s - %s\n", from, message)
	fmt.Println(string(body))
}

I wrote this code according to the current version of the Slack API documentation and this may change as the guide ages.

I created a function called updateSlack which takes three arguments: from which is the phone number of the customer, message which is the user response and t which is our message type of Positive, Negative or Neutral. Next I created a variable called color which is set to #C0C0C0 by default, this is my neutral color of gray. Next I have an if statement to check if the message type is positive or negative. If it’s a positive type I set the color to #006400 which is my green color. If the type is negative I set the color to #8B0000 which is my red color.

Next I declare my jsonStr which is a json string formatted based on the documentation that can be found here. My entire message is an attachment because it allows me to set a nice color to each message. After that we start building a new post request using the function http.NewRequest. We also need to add a few content headers and our SetBasicAuth function will add the authentication headers. A new client is created which takes our post request, executes it and returns a response in the body variable. We then log this using fmt.Printf and fmt.Println.

Now you can add the updateSlack function anywhere you would like to trigger a slack message. I updated the handleIncomingSMS function in four different places. This ensures thatI always get a Slack message when someone sends an sms. Here is the full, complete function.

func handleIncomingSMS(w http.ResponseWriter, r *http.Request) {
	var customer Customer

	if err := r.ParseForm(); err != nil {
		fmt.Println(err)
		return
	}

	direction := r.FormValue("direction")
	id := r.FormValue("id")
	from := r.FormValue("from")
	to := r.FormValue("to")
	created := r.FormValue("created")
	message := r.FormValue("message")

	fmt.Printf("[%s][%s][%s] %s => %s: %s\n", created, direction, id, from, to, message)

	if val, ok := customers[from]; ok {
		customer = val
	} else {received a reply before
		customer = Customer{Replied: false, Done: false, MessageType: Neutral}
	}
	if !customer.Replied && !customer.Done {
		msgType := determineMessageType(message)
		if msgType == Positive {
			sendSMS(from, messagePositive)
			customer.MessageType = msgType
			customer.Replied = true
			customers[from] = customer
			updateSlack(from, message, customer.MessageType)
			return
		} else if msgType == Negative {
			// If we did not have a message with 8 or higher
			sendSMS(from, messageNegative)
			customer.MessageType = msgType
			customer.Replied = true
			customers[from] = customer
			updateSlack(from, message, customer.MessageType)
			return
		}
		sendSMS(from, messageFinal)
		customer.Replied = true
		customer.Done = true
		customers[from] = customer
		updateSlack(from, message, customer.MessageType)
		return

	} else if !customer.Done {
		sendSMS(from, messageFinal)
		customer.Done = true
		customers[from] = customer
		updateSlack(from, message, customer.MessageType)
		return
	}
}

Summary

All of the source code, including postman configurations can be found on Github.

Thank you for reading and I hope you enjoyed the guide. I hope that it’s useful and that some may learn from it. Please do not hesitate to leave a comment with any feedback below. You can also send me a tweet on Twitter!

Got Something To Say?

Your email address will not be published. Required fields are marked *