18
12

More than 5 years have passed since last update.

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

Posted at

概要

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

さらにgolangのアプリケーションからスクリプトを呼び出して、結果をチャットに返すというボットを作成します。

ニフティクラウドスクリプトとはAWSのLambdaみたいなもので、サーバーサイドのスクリプトをクラウド上で実行できるサービスです。

メリット

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

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

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

作る

slackでBot Userを作る

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

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

続いてコントロールパネルから以下のような簡単なスクリプトをping.jsという名前で作成します。

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

golangでslack botを作る

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

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

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というライブラリを使います
  • 環境変数SLACK_API_TOKENに作成したボットのAPI_TOKENを設定しておきます
  • @bot名 のメッセージがきた場合のみレスポンスを返すようにします

botのコマンドを実行する

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を表示しています

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

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というライブラリを使用します
  • レスポンスとして返されるxmlをパースして返却します

完成

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

仕上げ

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

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
18
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
12