概要
この記事は Go(その3) Advent Calendar の19日目に間に合わなかった今更ながらの記事です。
goa の紹介のために,slack の Outgoing-Webhooks を使って mattn さんの書かれた 「deeeetさんの名前を間違えると指摘してくれるbot」を goa で書くつもりで, 去年のアドベントカレンダー用に進めてたネタだったんですが, slack が投げてくるデータの形式が分からなくて頓挫していたのを最近解決したので,今更ながらに書いてみました.
slack で vaaaaanquish さんの名前を間違えると指摘してくれる bot を作ります.
おことわり
これはいわゆるネタですので,slack-bot を goa で作るのをおすすめしている訳ではないことにご注意ください. goa の機能の一端を例を通しながら見ていただければ幸いです.
vaaaaanquish さんとは
名前に a が異様に多くて一発では書けない,いま注目すべき(と勝手に僕が思ってる)一人鍋エンジニアさんです.
これまた勝手にお名前拝借いたします🙇.
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 のドキュメントを参照してください.
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 を追加しておきます.(デフォルトで json,xml は受け付けるので普段はあまり設定しないです)
こうしておくと,データの形式を json か xml か 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 ではトップページにアクセスすると下記のようなページを表示してくれます.
こういった静的なファイルをサーブするには 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) }
ゴゴゴゴゴ・・・
まとめ
今回は goa で slack-bot 作ってみましたが,なんとなく goa の使い方が伝われば幸いです. まぁ,例として適当かどうかはアレですが,雰囲気が伝われば嬉しいです.
heroku にアップできるように heroku ボタンを付けておきましたので, goa を使って足りない機能を追加したり,改造したりして遊んでみてください.
去年ずっと引っかかっていたバグの原因が分かったうれしさで,ざっと記事にまとめてみましたが,やはりアドベントカレンダーの記事にするには微妙だったかな.
今年も Happy Hacking!