Posted at

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

More than 1 year has passed since last update.


概要

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