golangでスクリプトを実行するSlack Botを作ってみた

# 概要

Slack Botで実行したいコマンドは通常hubotなどで立ち上げてその中にコードを記述することが多いと思いますが、今回はコマンドの処理を全てニフティクラウドの[スクリプト](http://cloud.nifty.com/service/script.htm)というサービスで一元管理してみたいと思います。

さらにgolangのアプリケーションから[スクリプト](http://cloud.nifty.com/service/script.htm)を呼び出して、結果をチャットに返すというボットを作成します。

[ニフティクラウドスクリプト](http://cloud.nifty.com/service/script.htm)とはAWSのLambdaみたいなもので、サーバーサイドのスクリプトをクラウド上で実行できるサービスです。


# メリット

botコマンドを全てニフティクラウドスクリプトにすることで、クライアント側に囚われず同じ処理を実行できるようになります。

例えばSlack以外の別のチャットツールを使うことになった場合も、ニフティクラウドスクリプトを呼び出すことで同じコマンドを実行することができます。

botコマンドのソースをエンハンスする場合、ニフティクラウドスクリプトのコードを修正するだけで全てのクライアントから呼び出すコマンドに修正が反映されるので、デプロイも簡単です。

# 作る

## slackでBot Userを作る

まず https://my.slack.com/services/new/bot からBot Userを作成します。
作成が完了したら、APIトークンをめもっておきましょう。

## ニフティクラウドスクリプトのセットアップ

続いて[コントロールパネル](https://console.cloud.nifty.com/web/#script/script)から以下のような簡単なスクリプトをping.jsという名前で作成します。

```js:ping.js
module.exports = (req, res) => {
  res.send("pong")
}
```

## golangでslack botを作る

以下の３ファイルに分けて記述します

### botを生成し、webSocketから情報を取得する

```go:main.go
package main

import (
        "os"
        "strings"

        "github.com/nlopes/slack"
)

var (
        botId   string
        botName string
)

func main() {
        token := os.Getenv("SLACK_API_TOKEN")

        bot := NewBot(token)

        go bot.rtm.ManageConnection()

        for {
                select {
                case msg := <-bot.rtm.IncomingEvents:
                        switch ev := msg.Data.(type) {
                        case *slack.ConnectedEvent:
                                botId = ev.Info.User.ID
                                botName = ev.Info.User.Name

                        case *slack.MessageEvent:
                                user := ev.User
                                text := ev.Text
                                channel := ev.Channel

                                if ev.Type == "message" && strings.HasPrefix(text, "<@"+botId+">") {
                                        bot.handleResponse(user, text, channel)
                                }
                        }
                }
        }
}

```

ポイント

* golangからSlackを操作するために[github.com/nlopes/slack](https://godoc.org/github.com/nlopes/slack#UserDetails)というライブラリを使います
* 環境変数SLACK_API_TOKENに作成したボットのAPI_TOKENを設定しておきます
* `@bot名` のメッセージがきた場合のみレスポンスを返すようにします

### botのコマンドを実行する

```go:bot.go
package main

import (
        "github.com/nlopes/slack"
        "github.com/smartystreets/go-aws-auth"

        "fmt"
        "os"
        "strings"
)

const botIcon = ":innocent:"

var (
        commands = map[string]string{
                "help": "Displays all of the help commands.",
                "ping": "Reply with pong.",
        }
)

type Bot struct {                                                                                                                                                                 
        api *slack.Client
        rtm *slack.RTM
}

func NewBot(token string) *Bot {
        bot := new(Bot)
        bot.api = slack.New(token)
        bot.rtm = bot.api.NewRTM()
        return bot
}

func (b *Bot) handleResponse(user, text, channel string) {
        var cmd string

        commandArray := strings.Fields(text)
        if len(commandArray) <= 1 {
                cmd = "help"
        } else {
                cmd = commandArray[1]
        }

        var attachment slack.Attachment
        var err error

        switch cmd {
        case "ping":
                attachment, err = b.ping()
        case "help":
                attachment = b.help()
        default:
                attachment = b.help()
        }

        if err != nil {
                b.rtm.SendMessage(b.rtm.NewOutgoingMessage(fmt.Sprintf("Sorry %s is error... %s", cmd, err), channel))
                return
        }

        params := slack.PostMessageParameters{
                Attachments: []slack.Attachment{attachment},
                Username:    botName,
                IconEmoji:   botIcon,
        }

        _, _, err = b.api.PostMessage(channel, "", params)

        if err != nil {
                b.rtm.SendMessage(b.rtm.NewOutgoingMessage(fmt.Sprintf("Sorry %s is error... %s", cmd, err), channel))
                return
        }
}

func (b *Bot) help() (attachment slack.Attachment) {
        fields := make([]slack.AttachmentField, 0)

        for k, v := range commands {
                fields = append(fields, slack.AttachmentField{
                        Title: "@" + botName + " " + k,
                        Value: v,
                })
        }

        attachment = slack.Attachment{
                Pretext: botName + "Command List",
                Color:   "#B733FF",
                Fields:  fields,
        }
        return attachment
}

func (f *Bot) ping() (attachment slack.Attachment, err error) {

        scriptClient := NewScriptClient(awsauth.Credentials{
                AccessKeyID:     os.Getenv("NIFTY_ACCESS_KEY_ID"),
                SecretAccessKey: os.Getenv("NIFTY_SECRET_KEY")})

        res, err := scriptClient.ExecuteScript(ExecuteScriptParams{ScriptIdentifier: "ping.js", Method: "GET"})

        if err != nil {
                return
        }

        attachment = slack.Attachment{
                Pretext: res.ExecuteScriptResult.Result.ResponseData,
                Color:   "#A9F5F2",
        }

        return attachment, nil
}

```

ポイント

* 環境変数NIFTY_ACCESS_KEY_ID/NIFTY_SECRET_KEYに自分のアカウントに認証情報を設定しておきます
* ping が実行された場合ニフティクラウドスクリプトのping.jsを呼び出すようにします
* ping 以外のコマンドが実行された場合はhelpを表示しています

### ニフティクラウドスクリプトを呼び出す

```go:script.go
package main

import (
        "encoding/xml"
        "fmt"
        "net/http"
        "net/http/httputil"
        "net/url"
        "strings"

        "github.com/smartystreets/go-aws-auth"
)

const (
        scriptEndpoint = "https://script.api.cloud.nifty.com/2015-09-01"
        scriptVersion  = "2015-09-01"
)

type ExecuteScriptParams struct {
        ScriptIdentifier string
        Method           string
        Query            string
        Body             string
        Header           string
}

type ExecuteScriptResponse struct {
        ExecuteScriptResult struct {
                Result struct {
                        ResponseData string `xml:"ResponseData"`
                } `xml:"Result"`
        } `xml:"ExecuteScriptResult"`
}

type ErrorResponse struct {
        Error     Error  `xml:"Error"`
        RequestID string `xml:"RequestId"`
}

type Error struct {
        Type    string `xml:"Type"`
        Message string `xml:"Message"`
        Code    string `xml:"Code"`
}

func (err Error) Error() string {
        return err.Message
}

type ScriptClient interface {
        ExecuteScript(params ExecuteScriptParams) (res *ExecuteScriptResponse, err error)
}

type scriptClient struct {
        credentials []awsauth.Credentials
}

func NewScriptClient(credentials ...awsauth.Credentials) ScriptClient {
        return &scriptClient{
                credentials: credentials,
        }
}

func (c *scriptClient) ExecuteScript(params ExecuteScriptParams) (res *ExecuteScriptResponse, err error) {
        const action = "ExecuteScript"

        if params.Query == "" {
                params.Query = "{}"
        }
        if params.Body == "" {
                params.Body = "{}"
        }
        if params.Header == "" {
                params.Header = "{}"
        }

        body := map[string]string{
                "ScriptIdentifier": params.ScriptIdentifier,
                "Method":           params.Method,
                "Query":            params.Query,
                "Body":             params.Body,
                "Header":           params.Header,
        }
        res = new(ExecuteScriptResponse)
        err = c.doRequest(c.makeRequest(action, body), res)
        return
}

func (c *scriptClient) makeRequest(action string, body map[string]string) (req *http.Request) {
        endpoint, _ := url.Parse(scriptEndpoint)

        encoceBody := url.Values{}
        for k, v := range body {
                encoceBody.Add(k, v)
        }

        req, _ = http.NewRequest("POST", endpoint.String(), strings.NewReader(encoceBody.Encode()))

        req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
        req.Header.Add("X-Amz-Target", scriptVersion+"."+action)
        awsauth.Sign4(req, c.credentials...)
        return
}

func (c *scriptClient) doRequest(req *http.Request, result interface{}) (err error) {
        res, err := http.DefaultClient.Do(req)

        if err != nil {
                return
        }

        dumpReq, _ := httputil.DumpRequest(req, true)
        dumpRes, _ := httputil.DumpResponse(res, true)

        if res.StatusCode != 200 {
                fmt.Printf("%s", dumpReq)
                fmt.Printf("%s", dumpRes)
                var errResp = ErrorResponse{}
                err = xml.NewDecoder(res.Body).Decode(&errResp)
                if err != nil {
                        return
                }
                return errResp.Error
        }

        err = xml.NewDecoder(res.Body).Decode(&result)
        defer res.Body.Close()
        return
}

```

ポイント

* API認証のために、[go-aws-auth](https://github.com/smartystreets/go-aws-auth)というライブラリを使用します
* レスポンスとして返されるxmlをパースして返却します

# 完成

go run *.go でbotを起動します。
Slack から `@bot名 ping` を打って pongが返ってくれば成功です。

# 仕上げ

BOTが落ちないようにデーモン化させましょう
Dockerコンテナとして立ち上げるのがお勧めです

```bash:Dockerfile
FROM golang:1.7

RUN go get github.com/nlopes/slack
RUN go get github.com/smartystreets/go-aws-auth

ADD . /go/src/mygit.com/myname/bot
RUN go install mygit.com/myname/bot

ENTRYPOINT /go/bin/bot
```

```
$ docker build -t bot .
$ docker run -d --env-file ./env bot
```