MessageBird recently launched Programmable Conversations. It lets companies blend communications platforms like WhatsApp, Messenger and SMS into their systems — using a single API.
I wanted to give it a whirl, so I built a WhatsApp bot to-do list, because who doesn’t need an automated to-do list to help organize their day? It may sound complicated, but it was actually easy, and I’d like to tell you all about it.
Now, I work at MessageBird, so I could just dive in and start building. If you try this, you’ll need to request early access. But once you’re set up with a WhatsApp channel, you can log on to the Dashboard on the MessageBird website and get started.
The first thing I did was read the docs. I learned that, in order to get messages from the bot, I would have to use a webhook. This meant that my bot would need to be accessible from the internet. Since I was just starting to code it, I decided to use ngrok. It creates a tunnel from the public internet to your dear localhost port 5007. Engage!
ngrok http 5007 -region eu -subdomain todobot
Next, I needed to do a call to the Conversations API to create the webhook. It’s a POST to https://conversations.messagebird.com/v1/webhooks and it looks something like this:
func main() {// define the webhook json payloadwh := struct {Events []string `json:"events"`ChannelID string `json:"channelId"`URL string `json:"url"`} {// we would like to be notified on the URLURL: "https://todobot.eu.ngrok.io/create-hook",// whenever a message gets createdEvents: []string{"message.created"},// on the WhatsApp channel with IDChannelID: "23a780701b8849f7b974d8620a89a279",}// encode the payload to jsonvar b bytes.Buffererr := json.NewEncoder(&b).Encode(&wh)if err != nil {panic(err)}// create the http request and set authorization headerreq, err := http.NewRequest("POST", "https://conversations.messagebird.com/v1/webhooks", &b)req.Header.Set("Authorization", "AccessKey todo-your-access-key")req.Header.Set("Content-Type", "application/json")// fire the http requestclient := &http.Client{}resp, err := client.Do(req)if err != nil {panic(err)}defer resp.Body.Close()// is everything ok?body, _ := ioutil.ReadAll(resp.Body)if resp.StatusCode >= http.StatusBadRequest {panic(fmt.Errorf("Bad response code from api when trying to create webhook: %s. Body: %s", resp.Status, string(body)))} else {log.Println("All good. response body: ", string(body))}}
Sweet. Now the Conversations API is going to do a POST request to https://todobot.eu.ngrok.io/create-hook whenever a new message gets created on the WhatsApp channel you set up earlier.
This is what a webhook payload looks like:
{"conversation":{"id":"55c66895c22a40e39a8e6bd321ec192e","contactId":"db4dd5087fb343738e968a323f640576","status":"active","createdDatetime":"2018-08-17T10:14:14Z","updatedDatetime":"2018-08-17T14:30:31.915292912Z","lastReceivedDatetime":"2018-08-17T14:30:31.898389294Z"},"message":{"id":"ddb150149e2c4036a48f581544e22cfe","conversationId":"55c66895c22a40e39a8e6bd321ec192e","channelId":"23a780701b8849f7b974d8620a89a279","status":"received","type":"text","direction":"received","content":{"text":"add buy milk"},"createdDatetime":"2018-08-17T14:30:31.898389294Z","updatedDatetime":"2018-08-17T14:30:31.915292912Z"},"type":"message.created"}
We want to answer those messages. Let’s start by echoing them, what do you say?
// define the structs where we'll parse the webhook payload intype whPayload struct {Conversation conversation `json:"conversation"`Message message `json:"message"`Type string `json:"type"`}type message struct {ID string `json:"id"`Direction string `json:"direction"`Type string `json:"type"`Content content `json:"content"`}type content struct {Text string `json:"text"`}type conversation struct {ID string `json:"id"`}func main() {http.HandleFunc("/create-hook", createHookHandler)log.Fatal(http.ListenAndServe(*httpListenAddress, nil))}// createHookHandler is an http handler that will handle webhook requestsfunc createHookHandler(w http.ResponseWriter, r *http.Request) {// parse the incoming json payloadwhp := &whPayload{}err := json.NewDecoder(r.Body).Decode(whp)if err != nil {log.Println("Err: got weird body on the webhook")w.WriteHeader(http.StatusInternalServerError)fmt.Fprintf(w, "Internal Server Error")return}if whp.Message.Direction != "received" {// you will get *all* messages on the webhook. Even the ones this bot sends to the channel. We don't want to answer those.fmt.Fprintf(w, "ok")return}// echo: respond what we geterr = respond(whp.Conversation.ID, whp.Message.Content.Text)if err != nil {log.Println("Err: ", err)w.WriteHeader(http.StatusInternalServerError)fmt.Fprintf(w, "Internal Server Error")return}w.WriteHeader(http.StatusOK)fmt.Fprintf(w, "ok")}
Now, for the interesting part. Do a POST request to https://conversations.messagebird.com/v1/conversations/<conversationID>/messages to answer the request.
func respond(conversationID, responseBody string) error {u := fmt.Sprintf("https://conversations.messagebird.com/v1/conversations/%s/messages", conversationID)msg := message{Content: content{Text: responseBody,},Type: "text",}var b bytes.Buffererr := json.NewEncoder(&b).Encode(&msg)if err != nil {return fmt.Errorf("Error encoding buffer: %v", err)}req, err := http.NewRequest("POST", u.String(), &b)req.Header.Set("Authorization", "AccessKey todo-your-access-key")req.Header.Set("Content-Type", "application/json")client := &http.Client{}resp, err := client.Do(req)if err != nil {return err}defer resp.Body.Close()body, _ := ioutil.ReadAll(resp.Body)if resp.StatusCode != http.StatusCreated {return fmt.Errorf("Bad response code from api when trying to create message: %s. Body: %s", resp.Status, string(body))}log.Println("All good. Response body: ", string(body))return nil}
There. This is all you need to create a bot that acts like 5-year-old human.
Now, let’s make a push towards building the whole to-do list. First, modify the createHookHandler function a bit so it calls the new handleMessage function instead of respond.
func createHookHandler(w http.ResponseWriter, r *http.Request) {...err = handleMessage(whp)...}
handle will simplistically parse the messages, do some work, and pick the response. Let’s look at the “add” command:
func handleMessage(whp *whPayload) error {// every conversation has a todo listlist := manager.fetch(whp.Conversation.ID)// parse the command from the message body: it's the first wordtext := whp.Message.Content.Texttext = regexp.MustCompile(" +").ReplaceAllString(text, " ")parts := strings.Split(text, " ")command := strings.ToLower(parts[0])// default messageresponseBody := "I don't understand. Type 'help' to get help."switch command {...case "add":if len(parts) < 2 {return respond(whp.Conversation.ID, "err... the 'add' command needs a second param: the todo item you want to save. Something like 'add buy milk'.")}// get the item from the message bodyitem := strings.Join(parts[1:], " ")list.add(item)responseBody = "added."...return respond(whp.Conversation.ID, responseBody)}
Here, we set up: list := manager.fetch(whp.Conversation.ID). Basically, “manager” is a concurrency safe map that maps conversation IDs to to-do lists.
A to-do list is a concurrency safe string slice. All in memory!
Another important thing! You can archive conversations. In some applications, like CRMs, it’s important to keep track of certain interactions — to track the effectiveness of customer support employees, for example. The Conversations API lets you archive a conversation to “close” the topic. If the user/customer sends another message, the Conversations API will open a new topic automatically.
Also. Doing PATCH request to https://conversations.messagebird.com/v1/conversations/{id} with the right status on the body allows you to archive the conversation with that id. We do this with the “bye” command:
case "bye":archiveConversation(whp.Conversation.ID)manager.close(whp.Conversation.ID)responseBody = "bye!"
archiveConversation will do the PATCH request and manager.close(whp.Conversation.ID) will remove the to-do list conversation.
But hey, Programmable Conversations is an omni-channel solution. What if you wanted to reuse the code of the bot for a different platform, like WeChat? How would you go about it?
Just create a new webhook to target that channel! A webhook that sends requests to the same https://todobot.eu.ngrok.io/create-hook url we used for WhatsApp!
This will work because the handler code always uses the conversationID from the webhook payload to answer the messages instead of a hardcoded channelID. MessageBird’s Conversations API will automatically determine the channel for the conversation to send your message over.
Take a look at the full code on Github: https://github.com/marcelcorso/wabot, request early access to WhatsApp via this link and start building.