読者です 読者をやめる 読者になる 読者になる

vaaaaanquishさんの名前を間違えると指摘してくれる slack bot を goa で書く

golang Go言語 goadesign heroku

概要

この記事は Go(その3) Advent Calendar の19日目に間に合わなかった今更ながらの記事です。

goa の紹介のために,slack の Outgoing-Webhooks を使って mattn さんの書かれた 「deeeetさんの名前を間違えると指摘してくれるbot」を goa で書くつもりで, 去年のアドベントカレンダー用に進めてたネタだったんですが, slack が投げてくるデータの形式が分からなくて頓挫していたのを最近解決したので,今更ながらに書いてみました.

slack で vaaaaanquish さんの名前を間違えると指摘してくれる bot を作ります.

おことわり

これはいわゆるネタですので,slack-bot を goa で作るのをおすすめしている訳ではないことにご注意ください. goa の機能の一端を例を通しながら見ていただければ幸いです.

vaaaaanquish さんとは

名前に a が異様に多くて一発では書けない,いま注目すべき(と勝手に僕が思ってる)一人鍋エンジニアさんです.

これまた勝手にお名前拝借いたします🙇.

twitter.com

slack の Outgoing-Webhooks について

slack の Outgoing-Webhooks は設定しておくと,チャンネルで発言した内容を設定しておいた URL に application/x-www-form-urlencoded 形式で メッセージを投げてくれて(なぜ json じゃないのか),これに json 形式で答えると,その答えを slack に表示してくれます.

slack が投げてくるデータは以下のようなサンプルが例示されています:

token=XXXXXXXXXXXXXXXXXX
team_id=T0001
team_domain=example
channel_id=C2147483705
channel_name=test
timestamp=1355517523.000005
user_id=U2147483697
user_name=Steve
text=googlebot: What is the air-speed velocity of an unladen swallow?
trigger_word=googlebot:

発言を投げるタイミングをキーワードを含むときだけにしたり,bot のアイコンを変更したり,いくつか機能がありますが,その辺は slack のドキュメントを参照してください.

api.slack.com

goa で API サーバのデザインを作る

完成品はこちらになります:https://github.com/ikawaha/vaaaaanquish-bot

goa では API デザインを書いて,そこからモックを生成し,ビジネスロジックを埋めます.

まずは API デザインから進めます.

API 定義

var _ = API("vaaaaanquish-bot", func() {
        Title("vaaaaanquish-bot")
        Description("vaaaaanquish さんの名前を間違って発言すると訂正してくれる slack bot です")
        Scheme("http")
        Host("localhost:8080")

        Consumes("application/x-www-form-urlencoded", func() {
                Package("github.com/goadesign/goa/encoding/form")
        })
})

Outgoing-webhooks は API へ送ってくるデータの形式が json ではなく application/x-www-form-urlencoded でくるので, Consumes 関数を使って www-form-urlencoded 用の Decoder を追加しておきます.(デフォルトで jsonxml は受け付けるので普段はあまり設定しないです)

こうしておくと,データの形式を jsonxml か www-form-encoded かということを特に気にすることなくいつも通りに Payload 指定などを書けます.どの Decorder を使うかはアクセス時の Content-Type をみて適切に切り替えてくれます(というコードが生成されます).

Incoming API

var _ = Resource("message", func() {
        BasePath("/v1/slack")
        DefaultMedia(MessageMedia)
        Action("inbound", func() {
                Routing(POST("/inbound"))
                Payload(SlackMessage)
                Response(OK)
        })
})

endpoint は /v1/slack/inbound とします.BasePath 関数を使うとこのリソースで共通する prefix path を指定できます. ここでは /v1/slack がエンドポイントの共通の接頭辞になります.なので Routing 関数で指定するパスは /inbound の部分だけで OK になります. Payload として,SlackMessage をしてしていますが,これはユーザー定義のペイロードです.下記で説明します Response は単に OK を返すように設定されています.これは DefaultMedia 関数でデフォルトのレスポンス・メディアタイプが指定されているので,省略して書けるようになっているからです.指定されている MessageMedia もユーザー指定のレスポンス・メディアタイプで,下記で説明します.

Payload

Payload は slack から送られてくるデータに対応した形式を設定する必要があります. www-form-encoded のデコード指定は API 関数で設定済みですので,ここではデータタイプだけを指定すれば大丈夫です.

var SlackMessage = Type("SlackMessage", func() {
        Attribute("token", String, "Slack Token")
        Attribute("team_id", String, "Team ID")
        Attribute("team_domain", String, "Team Domain")
        Attribute("channel_id", String, "Channel ID")
        Attribute("channel_name", String, "Channel Name")
        Attribute("service_id", String, "Service ID")                  // ← slack のサンプルにないけど,これがないとダメ
        Attribute("timestamp", Number, "Timestamp")
        Attribute("user_id", String, "User ID")
        Attribute("user_name", String, "User Name")
        Attribute("trigger_word", String, "Trigger Word")
        Attribute("text", String, "Message Text") 
})

slack のサンプルに従って項目を列挙していけばいいです.

ただし,service_id は slack のサンプルには出てこないので注意してください.

これではまってアドベントカレンダーには間に合いませんでした. んなもん気づけんわー

MediaType

レスポンスは json で返します.json の形式は bot に slack で発言させたいメッセージを {"text" : "hogehoge"} として返せば最低限事足ります. 必要に応じてレスポンスの項目を追加してください.

var MessageMedia = MediaType("application/vnd.vaaaaanquish.bot.message+json", func() {
        Attributes(func() {
                RequiredAttribute("text", String, "Message Text")
                Attribute("icon_url", String, "ICON URL")
                Attribute("icon_emoji", String, "ICON Emoji")
                Attribute("username", String, "User Name")
                Attribute("channel", String, "Other Channel")
        })
        View("default", func() {
                Attribute("text")
                Attribute("icon_url")
                Attribute("icon_emoji")
                Attribute("username")
                Attribute("channel")
        })
})

静的なファイルをサーブする

mattn さんの deeeet-bot ではトップページにアクセスすると下記のようなページを表示してくれます.

f:id:ikawaha:20170110233958p:plain:w300

こういった静的なファイルをサーブするには goa では Files 関数を利用できます.

var _ = Resource("public", func() {
        Files("/", "./templates/index.tmpl.html")
        Files("/static/*filepath", "./static/")
})

ビジネスロジックを実装する

goagen するとリソースに対応して message.go というファイルが,またメイン関数用に main.go というファイルができるので,これを編集していきます.リソースに対応するファイルには,エンドポイントのアクションのモックが用意されています.

goa で生成したコードで編集するファイルは,リソース関係のモックと main.go だけで,あとは手を入れる必要がありません.(もし,DO NOT MODIFY と書かれたファイルに手を入れようとしてる場合は,goa と達したい目的とが合わない可能性が高いです)

slack のメッセージを受けて応答する (message.go)

slack から来たメッセージは ctx.Payload にすでにデコード済みになりますので,そのテキストを正規表現でチェックして, 名前が間違っていたらそれをレスポンスとして指定して返します.

func (c *MessageController) Inbound(ctx *app.InboundMessageContext) error {
        m := re.FindAllString(ctx.Payload.Text, -1)
        ctx.Payload.Text = ""
        for _, t := range m {
                if t[1:] != "aaaaanquish" {
                        ctx.Payload.Text = t[0:1] + "aaaaanquish です..."
                        break
                }
        }
        res := toAppVaaaaanquishBotMessage(ctx)
        return ctx.OK(res)
}

heroku 用にポートを指定できるようにする (main.go)

デザインではポートは :8080 指定でしたが,heroku で公開できるように PORT という環境変数があれば, ポートをこの値に指定できるように生成されたコードをちょっと変更します.

var addr = flag.String("addr", defaultAddr(), "server address")

func defaultAddr() string {
        if s := os.Getenv("PORT"); s != "" {
                return ":" + s
        }
        return ":8080"
}

こうしておいて,サービスを開始するところを以下のようにします.

// Start service
if err := service.ListenAndServe(*addr); err != nil { // ← addr が適切にセットされているはず
        service.LogError("startup", "err", err)
}

ゴゴゴゴゴ・・・

f:id:ikawaha:20170110235823p:plain

まとめ

今回は goa で slack-bot 作ってみましたが,なんとなく goa の使い方が伝われば幸いです. まぁ,例として適当かどうかはアレですが,雰囲気が伝われば嬉しいです.

heroku にアップできるように heroku ボタンを付けておきましたので, goa を使って足りない機能を追加したり,改造したりして遊んでみてください.

去年ずっと引っかかっていたバグの原因が分かったうれしさで,ざっと記事にまとめてみましたが,やはりアドベントカレンダーの記事にするには微妙だったかな.

今年も Happy Hacking!