Easy HipChat Addons In Go

September 24th 2015 Nicola Paolucci in HipChat, Golang, Atlassian Connect

If you are a Go programmer you know how easy it is to whip up an application that speaks HTTP. Go was born for the task. So it will come as no surprise that it's possible to create an Atlassian Connect for HipChat add-on with less than two hundred lines of commented code. What will this code accomplish? A new custom command /test_hook, installable on any channel you are administrator of:

demo

This post will show you the code to do just that. Our only dependency is the tiny and awesome mux router to simplify creating HTTP Handlers and Golang HipChat.

The data structures

For the bare bones app I have in mind the data structure we need is just a Context to keep track of:

  • The folder that holds the static assets to serve
  • The base URL of our add-on - used for testing with ngrok and to fill the Atlassian Connect descriptor atlassian-connect.json
  • A map of room names to OAuth2 tokens so that our add-on can be installed on any HipChat room independently.
// RoomConfig holds information to send authenticated messages to all rooms
type RoomConfig struct {
    token *hipchat.OAuthAccessToken
    hc    *hipchat.Client
    name  string
}

// Context keep context of the running application
type Context struct {
    baseURL string
    static  string
    rooms   map[string]*RoomConfig
}

The main of our application

The starting point of our add-on, the main, is simple: we initialise the Context parsing some command line parameters, we create the routes we'll respond to and start listening on a port.

func main() {
    var (
        port    = flag.String("port", "8080", "web server port")
        static  = flag.String("static", "./static/", "static folder")
        baseURL = flag.String("baseurl", os.Getenv("BASE_URL"), "local base url")
    )
    flag.Parse()

    c := &Context{
        baseURL: *baseURL,
        static:  *static,
        rooms:   make(map[string]*RoomConfig),
    }

    log.Printf("Base HipChat integration v0.10 - running on port:%v", *port)

    r := c.routes()
    http.Handle("/", r)
    http.ListenAndServe(":"+*port, nil)
}

The baseURL flag can be set manually from the command line but it is initialised from the environment variable BASE_URL, useful thing to have when deploying your application inside a container for example.

Describe the routes

The routes of our web application are defined in a method and defer to the respective methods of our Context:

// routes all URL routes for app add-on
func (c *Context) routes() *mux.Router {
    r := mux.NewRouter()
    //healthcheck route required by Micros
    r.Path("/healthcheck").Methods("GET").HandlerFunc(c.healthcheck)
    //descriptor for Atlassian Connect
    r.Path("/").Methods("GET").HandlerFunc(c.atlassianConnect)
    r.Path("/atlassian-connect.json").Methods("GET").HandlerFunc(c.atlassianConnect)

    // HipChat specific API routes
    r.Path("/installable").Methods("POST").HandlerFunc(c.installable)
    r.Path("/config").Methods("GET").HandlerFunc(c.config)
    r.Path("/hook").Methods("POST").HandlerFunc(c.hook)

    r.PathPrefix("/").Handler(http.FileServer(http.Dir(c.static)))
    return r
}

The HipChat API version 2 will invoke several callback URLs to customise and embed our application. To do this HipChat will scan the capabilities we have specified in atlassian-connect.json.

  • /installable will be invoked when our add-on is installed to a HipChat room.
  • /config will be invoked to display the configuration pane of our application.
  • /hook will be invoked when a command /test_hook is typed in the HipChat room.

A walk-through of the HTTP Handler methods

Just a simple health check to prove that our application is up and running (needed by our internal PaaS):

func (c *Context) healthcheck(w http.ResponseWriter, r *http.Request) {
    json.NewEncoder(w).Encode([]string{"OK"})
}

The technique json.NewEncoder(w).Encode(...) is a neat way to write a data structure to JSON in a HTTP Response.

Next I want to parametrise the descriptor atlassian-connect.json with the proper baseURL so that it works both locally, with ngrok or deployed in staging and production, so we parse the JSON configuration file through the html/template library:

func (c *Context) atlassianConnect(w http.ResponseWriter, r *http.Request) {
    lp := path.Join("./static", "atlassian-connect.json")
    vals := map[string]string{
        "LocalBaseUrl": c.baseURL,
    }
    tmpl, err := template.ParseFiles(lp)
    if err != nil {
        log.Fatalf("%v", err)
    }
    tmpl.ExecuteTemplate(w, "config", vals)
}

Capabilities and callbacks

The next section implements the needed web routes to respond to the HipChat API version 2 as defined in atlassian-connect.json.

Capabilities: Installable

The /installable callback URL is invoked when the add-on is installed and the incoming request contains a couple of critical OAuth strings that we need to store so that we can submit authenticated HipChat API calls. I use a helper function DecodePostJSON to read the request body and store it into a Go map[string]interface{}.

install confirm

func (c *Context) installable(w http.ResponseWriter, r *http.Request) {
    authPayload, err := util.DecodePostJSON(r, true)
    if err != nil {
        log.Fatalf("Parsed auth data failed:%v\n", err)
    }

    credentials := hipchat.ClientCredentials{
        ClientID:     authPayload["oauthId"].(string),
        ClientSecret: authPayload["oauthSecret"].(string),
    }
    roomName := strconv.Itoa(int(authPayload["roomId"].(float64)))
    newClient := hipchat.NewClient("")
    tok, _, err := newClient.GenerateToken(credentials, []string{hipchat.ScopeSendNotification})
    if err != nil {
        log.Fatalf("Client.GetAccessToken returns an error %v", err)
    }
    rc := &RoomConfig{
        name: roomName,
        hc:   tok.CreateClient(),
    }
    c.rooms[roomName] = rc

    util.PrintDump(w, r, false)
    json.NewEncoder(w).Encode([]string{"OK"})
}

Capabilities: Configurable

This callback is called to display the configuration pane of your add-on:

config

func (c *Context) config(w http.ResponseWriter, r *http.Request) {
    signedRequest := r.URL.Query().Get("signed_request")
    lp := path.Join("./static", "layout.hbs")
    fp := path.Join("./static", "config.hbs")
    vals := map[string]string{
        "LocalBaseUrl":  c.baseURL,
        "SignedRequest": signedRequest,
        "HostScriptUrl": c.baseURL,
    }
    tmpl, err := template.ParseFiles(lp, fp)
    if err != nil {
        log.Fatalf("%v", err)
    }
    tmpl.ExecuteTemplate(w, "layout", vals)
}

Capabilities: Webhook

The webhook capability allows to specify room command (/test_hook in the example below) that will trigger a callback to the add-on. In this case we just ping back to the room a simple styled message:

func (c *Context) hook(w http.ResponseWriter, r *http.Request) {
    payLoad, err := util.DecodePostJSON(r, true)
    if err != nil {
        log.Fatalf("Parsed auth data failed:%v\n", err)
    }
    roomID := strconv.Itoa(int((payLoad["item"].(map[string]interface{}))["room"].(map[string]interface{})["id"].(float64)))

    util.PrintDump(w, r, true)

    log.Printf("Sending notification to %s\n", roomID)
    notifRq := &hipchat.NotificationRequest{
        Message:       "nice <strong>Happy Hook Day!</strong>",
        MessageFormat: "html",
        Color:         "red",
    }
    if _, ok := c.rooms[roomID]; ok {
        _, err = c.rooms[roomID].hc.Room.Notification(roomID, notifRq)
        if err != nil {
            log.Printf("Failed to notify HipChat channel:%v\n", err)
        }
    } else {
        log.Printf("Room is not registered correctly:%v\n", c.rooms)
    }
}

Due to us reading up payLoad in a generic interface{} we have to write a long winded cast to get the roomID back.

A couple of utility functions

// PrintDump prints the dump of request, optionally writing it in the response
func PrintDump(w http.ResponseWriter, r *http.Request, write bool) {
    dump, _ := httputil.DumpRequest(r, true)
    log.Printf("%v", string(dump))
    if write == true {
        w.Write(dump)
    }
}

// Decode into a ma[string]interface{} the JSON in the POST Request
func DecodePostJSON(r *http.Request, logging bool) (map[string]interface{}, error) {
    var err error
    var payLoad map[string]interface{}
    decoder := json.NewDecoder(r.Body)
    err = decoder.Decode(&payLoad)
    if logging == true {
        log.Printf("Parsed body:%v", payLoad)
    }
    return payLoad, err
}

See the entire source on Bitbucket. To run this tiny application locally do the following:

  • Configure and launch ngrok with:
ngrok http 8080
  • Note down the unique https URL generated by ngrok for you.
  • Use the URL to pass the proper configuration to the Go program:
BASE_URL=https://808cf232.ngrok.io go run main.go
  • Go to the integrations section of your HipChat room and insert the URL to your running add-on (i.e. https://808cf232.ngrok.io/atlassian-connect.json).

Conclusions

That's it for now. I hope you found this piece interesting and useful. Ping me questions here in the comments or at @durdn.