How to make a HN Messenger bot with Go and ECS

Facebook recently announced Messenger Platform, including support for chat bots. Let's explore how to build and deploy one with Go and AWS Elastic Container Service!

I visit Hacker News often; it would be really nice to be sent the top 5 stories so I could see what is happening at a glance. We're going to build a bot that will help us accomplish that!

This post is divided into two sections: writing the bot and deploying the bot. You can find the complete source on GitHub.

Writing the bot

Messenger bots are HTTP servers. Users will message your bot, which will trigger Facebook to POST a webhook to your server. To respond, your server sends a POST request to the Messenger Send API.[1]

There are two types of requests your server needs to handle:

This project is structured in two parts. The first is the logic to set up the bot and fetch stories from Hacker News, and the second is a library the bot uses to communciate with Messenger.

Setting up the bot

First, let's do three things:

package main

import (
	"flag"
	"fmt"
	"net/http"

	"github.com/gorilla/mux"
	"github.com/schmatz/hn-messenger-bot/messenger"
)

var (
	pageAccessToken   = flag.String("page-access-token", "", "The page access token")
	verificationToken = flag.String("token", "", "The challenge verification token")
	port              = flag.Uint("port", 3000, "The port to listen on")
	bot               *messenger.Bot
)

func main() {
	flag.Parse()

	bot = messenger.New(*pageAccessToken, *verificationToken, handleMessaging)

	r := mux.NewRouter()

	r.HandleFunc("/webhook/", bot.HandleVerificationChallenge).Methods("GET")
	r.HandleFunc("/webhook/", bot.HandleWebhookPost).Methods("POST")
	r.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })

	http.ListenAndServe(fmt.Sprintf(":%d", *port), r)
}

Let's break this down a little bit.

This line initializes the bot with the appropriate tokens, as well as a callback to handle messages, which we will define below.

bot = messenger.New(*pageAccessToken, *verificationToken, handleMessaging)

This code sets up our router with the methods we described above, as well as a simple health check for the Elastic Load Balancer we'll use.

r.HandleFunc("/webhook/", bot.HandleVerificationChallenge).Methods("GET")
r.HandleFunc("/webhook/", bot.HandleWebhookPost).Methods("POST")
r.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })

Implementing the handleMessaging function

The handleMessaging function will accept a Messaging struct from the webhook, gather information from HN, and then send a response.

Messenger has the concept of templates; using templates, you can format your messages in fairly complex ways. We'll be using these templates to implement our bot (see the image at the start of the post for the end result.) The template structs used are defined in our library, which we'll cover shortly.

The function below fetches the IDs of top stories, and for the top five fetches story details, creates a generic template element for each one, then sends the reply.

func handleMessaging(m messenger.Messaging) (err error) {
	topStories, err := getHNTopStoryIDs()
	if err != nil {
		return
	}

	var templateItems []messenger.GenericTemplateElement

	numStories := 5
	for i := 0; i < numStories; i++ {
		topStory := topStories[i]
		title, url, description, err := getHNStoryDetails(topStory)
		if err != nil {
			return err
		}

		item := messenger.GenericTemplateElement{
			Title:    title,
			Subtitle: description,
			ItemURL:  url,
		}
		templateItems = append(templateItems, item)
	}

	err = bot.SendGenericTemplateReply(m.Sender.ID, templateItems)

	return
}

Fetching information from HN

HN has a nice API, which is documented here. We'll now implement getHNTopStoryIDs and getHNStoryDetails.

getHNTopStoryIDs fetches the top stories as a JSON array of integers, decodes the array, and returns it.

func getHNTopStoryIDs() (ids []int64, err error) {
	resp, err := http.Get("https://hacker-news.firebaseio.com/v0/topstories.json")
	if err != nil {
		return
	}

	decoder := json.NewDecoder(resp.Body)
	err = decoder.Decode(&ids)

	return
}

getHNStoryDetails fetches the story information, decodes it into the story struct, creates a human-readable description, and returns the story title, URL, and description.

func getHNStoryDetails(storyID int64) (title string, url string, description string, err error) {
	resp, err := http.Get(fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", storyID))
	if err != nil {
		return
	}

	var story struct {
		Title  string `json:"title"`
		URL    string `json:"url"`
		Author string `json:"by"`
		Points int64  `json:"score"`
		Time   int64  `json:"time"`
	}

	decoder := json.NewDecoder(resp.Body)
	err = decoder.Decode(&story)
	if err != nil {
		return
	}

	title = story.Title
	url = story.URL
	description = fmt.Sprintf("%d points by %s %s", story.Points, story.Author, humanize.Time(time.Unix(story.Time, 0)))

	return
}

We've now implemented all of the logic to set up the bot and generate responses.

Writing the bot library

Let's now cover the library that the bot is using to communicate. You can find all of the code here.

Handling the verification challenge

Facebook will send us a verification challenge. This method checks the given verification token is correct and then responds with the challenge value.

// HandleVerificationChallenge allows Facebook to verify this bot.
func (m *Bot) HandleVerificationChallenge(w http.ResponseWriter, r *http.Request) {
	givenVerificationToken := r.URL.Query().Get("hub.verify_token")

	if givenVerificationToken != m.verificationToken {
		http.Error(w, "Incorrect verification token", http.StatusUnauthorized)
	} else {
		w.Write([]byte(r.URL.Query().Get("hub.challenge")))
	}
}

Handling a webhook POST

Our webhook handler will

Note that we're only logging errors at the moment. In a more robust bot, we'd want to handle errors more intelligently (and use a queueing system!)

// HandleWebhookPost executes the messagingHandler callback for each Messaging present.
func (m *Bot) HandleWebhookPost(w http.ResponseWriter, r *http.Request) {
	var webhookData WebhookRequest

	decoder := json.NewDecoder(r.Body)
	err := decoder.Decode(&webhookData)
	if err != nil {
		http.Error(w, "Error decoding request body", http.StatusBadRequest)
		return
	}

	for _, entry := range webhookData.Entries {
		for _, messaging := range entry.Messagings {
			if messaging.Message != nil {
				go func(msg Messaging) {
					err := m.messagingHandler(msg)
					if err != nil {
						log.Println("Error executing messaging handler:", err)
					}
				}(messaging)
			}
		}
	}

	w.WriteHeader(200)
}

Sending a reply

The final method which we need to write sends a reply formatted as a generic template.

// SendGenericTemplateReply uses the Send API to send a message to a user
func (m *Bot) SendGenericTemplateReply(recipientID int64, elements []GenericTemplateElement) (err error) {
	var r GenericTemplateReply

	r.Recipient.ID = recipientID
	r.Message.Attachment.Type = "template"
	r.Message.Attachment.Payload.TemplateType = "generic"
	r.Message.Attachment.Payload.Elements = elements

	marshalled, err := json.Marshal(r)
	if err != nil {
		return
	}

	req, err := http.NewRequest("POST", "https://graph.facebook.com/v2.6/me/messages", bytes.NewBuffer(marshalled))
	if err != nil {
		return
	}

	req.Header.Set("Content-Type", "application/json")

	q := req.URL.Query()
	q.Add("access_token", m.pageAccessToken)
	req.URL.RawQuery = q.Encode()

	resp, err := m.httpClient.Do(req)
	if err != nil {
		return
	}
	defer resp.Body.Close()

	if resp.StatusCode == 200 {
		return
	}

	var sendError SendError

	decoder := json.NewDecoder(resp.Body)
	err = decoder.Decode(&sendError)
	if err != nil {
		return
	}

	return fmt.Errorf("Error sending response: %s", sendError.Error.Message)
}

I've skipped over the definition of the Bot struct and the constructor, which are exactly as boring as you might presume 😛

Note that this method and the structures involved don't cover all the entire Send API; for brevity I am leaving them very specific, but I'm sure someone will write a Go Messenger library shortly 😊

That concludes all of the code we need to write. On to deployment!

Deploying the bot

We're going to use the Elastic Container Service to get our bot deployed.

Building and pushing the container

First, let's write a Dockerfile. Luckily, this is a one liner!

FROM golang:1.6-onbuild

Next, log into AWS, go to the ECS screen, go to Repositories, and create a new one.

The following screen will detail exactly how to build and push your image, something like

aws ecr get-login --region us-west-2
docker build -t hn-messenger-bot:0.0.1 .
docker tag hn-messenger-bot:0.0.1 accountid.dkr.ecr.us-west-2.amazonaws.com/hn-messenger-bot:0.0.1
docker push accountid.dkr.ecr.us-west-2.amazonaws.com/hn-messenger-bot:0.0.1

Configuring ECS

The next step is to configure ECS. If you've used ECS before, great! All you need to do is set up a new service with an ELB and HTTPS listener on the port of your choice.

If you haven't used ECS before, Amazon released a great first run wizard that will take care of everything for you, complete with autoscaling. Just make sure the only box checked is "Deploy a sample application onto an ECS cluster", and in the next screen customize the settings to point at your image. I'm not going to cover ECS in this post, but you can find in-depth documentation here

A final note is to make sure that you configure the health check on the ELB (point it at /health), or the service will not deploy.

Setting up SSL

The only difficulty here is making sure you have an HTTPS ELB listener (Facebook requires bots communicate over HTTPS). In the EC2 Management screen, go to the Load Balancers tab, click the load balancer that has been created, go to the listeners tab, and create a new HTTPS listener from port 443 to whatever port you specified the ECS service should listen on (3000 is the default in the code above.)

The next step is to request a certificate. If you're in the us-east-1 (Virginia) region, great! You have access to the AWS Certificate Manager, which will quickly and easily allow you to provision a certificate and attach it to the ELB. If you aren't as lucky (like me), you'll have to get the certs another way; I used the fantastic Let's Encrypt service (specifically the containerized client.)

Optionally, you can set up a domain name pointing at the balancer with Route53 and provision the SSL cert for that name.

Configuring the bot with Facebook

The final steps are:

These are covered in detail in the getting started guide, so I won't repeat them here. After that, you should be able to message the bot and it will message you back!

Conclusion

I'm sure Messenger bots will become extremely popular; luckily it's not that hard to write one! We've seen how to write a very simple bot and deploy it on AWS.


  1. Facebook also wrote a great quickstart guide which I recommend reading; however I think people may also find this complete tutorial to be helpful! ↩ī¸Ž