わすれっぽいきみえ

みらいのじぶんにやさしくしてやる

Cloud Functions with Go 1.11 で返信だけはする slack bot を適当に作る

去年の12月に何故か急に Go で slack bot を作りたくなったんだが、急にやる気がなくなって放置してて

f:id:kimikimi714:20191028000649p:plain
全然遊んでなかった

無料トライアル枠なくなるやーんと焦ったので、作ることにする。
ちなみに一番最初は AppEngine でやろうかなと考えてたが、そんな大層なものを作るわけでもなかったので Cloud Function で作り直すことにした。
なお、ほとんどこれの Go 版だと思ってもらえばいい。

qiita.com

環境

現在 Cloud Function でサポートされている Go のバージョンは1.11。 homebrew で素直にインストールすると Go 1.13 になってしまうので

kimikimi714.hatenablog.com

を用いるなどして Go 1.11 を使うことを推奨する。

特に Go 1.11 にはあって Go 1.13 にはない機能は使ってないが再現したいのであれば可能な限り同じバージョンのほうがいい。

とりあえず slack からの post message に反応できるようにする

上記 Qiita の Cloud Functions の準備 に当たる関数を Go で用意すると以下になる。( function.go というファイル名とする。)

package sample

import (
    "encoding/json"
    "fmt"
    "net/http"
)

func Challenge(w http.ResponseWriter, r *http.Request) {
    var d struct {
        Type      string `json:"type"`
        Token     string `json:"token"`
        Challenge string `json:"challenge"`
    }
    if err := json.NewDecoder(r.Body).Decode(&d); err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        fmt.Fprint(w, "Hello World!")
        return
    }
    if d.Type == "url_verification" {
        w.WriteHeader(http.StatusOK)
        fmt.Fprint(w, "{'challenge': %s}", d.Challenge)
        return
    }
    w.WriteHeader(http.StatusOK)
    fmt.Fprint(w, "OK")

}

これを local から deploy するためのスクリプトを用意した。

#!/bin/sh

while getopts f:p: OPT
do
  case $OPT in
    "f" ) FUNCTION="$OPTARG" ;;
    "p" ) ENDPOINT="$OPTARG" ;;
  esac
done

if [ -z $FUNCTION ] ; then
  echo "function 名が指定されていません"
  exit 1;
fi

if [ -z $ENDPOINT ] ; then
  echo "endpoint が指定されていません"
  exit 1;
fi

gcloud functions deploy $FUNCTION --entry-point $ENDPOINT --trigger-http --runtime=go111 --region=asia-northeast1 --env-vars-file .env.yaml

deploy 実行するときは以下

$ scripts/deploy.sh -f function -p Challenge

現時点で想定しているディレクトリ構成はこんな感じになる。

.
├── .env.yaml
├── .gcloudignore
├── .gitignore
├── Makefile
├── go.mod
├── function.go
└── scripts
    └── deploy.sh

.gcloudignore / .gitignore はよくある内容だし、 Makefile改訂2版 みんなのGo言語 に載ってるMakefileと同じような中身なので省略する。go.mod もファイルこそあるが、今回のサンプルで使うようなライブラリは特にない。このタイミングでは .env.yaml は空ファイルで良い。

上記 Qiita の Slack App の作成 は同じ手順になるので、ここでは記載しない。記事通りに作ると良い。

この時点でbotは返信がまだできないが、 Slack から Endpoint が叩かれればOK。

メンションを受け取れる状態にする

package sample

 import (
    "encoding/json"
-  "fmt"
+   "log"
    "net/http"
 )

+type requestBody struct {
+   Type      string `json:"type"`
+   Token     string `json:"token"`
+   Challenge string `json:"challenge"`
+   Event     eventData
+}
+
+type eventData struct {
+   Type           string `json:"type"`
+   UserID         string `json:"user"`
+   Text           string `json:"text"`
+   Timestamp      string `json:"ts"`
+   ChannelID      string `json:"channel"`
+   EventTimestamp string `json:"event_ts"`
+}
+
 func Challenge(w http.ResponseWriter, r *http.Request) {
-  var d struct {
-      Type      string `json:"type"`
-      Token     string `json:"token"`
-      Challenge string `json:"challenge"`
-  }
+   var d requestBody
    if err := json.NewDecoder(r.Body).Decode(&d); err != nil {
        w.WriteHeader(http.StatusInternalServerError)
-      fmt.Fprint(w, "Hello World!")
+       log.Fatalf("failed to parse: %v", r.Body)
        return
    }
    if d.Type == "url_verification" {
        w.WriteHeader(http.StatusOK)
-      fmt.Fprint(w, "{'challenge': %s}", d.Challenge)
+       log.Printf("succeeded to challenge: %s", d.Challenge)
+       return
+   }
+
+   if d.Event.Type == "app_mention" {
+       str, err := json.Marshal(d.Event)
+       if err != nil {
+           w.WriteHeader(http.StatusInternalServerError)
+           log.Fatalf("failed to parse: %v", r.Body)
+       }
+       w.WriteHeader(http.StatusOK)
+       log.Printf("your message is: %s", str)
        return
    }
-  w.WriteHeader(http.StatusOK)
-  fmt.Fprint(w, "OK")

+   w.WriteHeader(http.StatusBadRequest)
+}

このタイミングで slack から飛んでくるリクエストボディとその中に含まれるイベントを構造体で定義しておきパースするようにした。 app_mention で何を受け取ったかはログに出せるように修正した。

ここで deploy すると以下のような感じになる。

f:id:kimikimi714:20191028001418p:plain
botにメンションを飛ばしてみる
f:id:kimikimi714:20191028001649p:plain
ログに出た様子(構造体のtag名を間違ってたので変な出方してるが…)

メンションに返信できるようにする

slack bot からのメッセージは簡単のため incoming webhook で設定した。

https://api.slack.com/apps/{さっき作ったアプリの設定ページ} に飛ぶと一番下の方に

f:id:kimikimi714:20191028231441p:plain

こういうのがあるので、その一番下の「Add New Webhook to Workspace」から新しい incoming webhook のURLを作る。

そこで作ったURLを .env.yaml に記載する。

SLACK_INCOMING_WEBHOOK: https://hooks.slack.com/services/your/webhook

さらに、この webhook を使って slack にメッセージを post する処理を作成する。 ( slack.go というファイル名とする。)

package jimiko

import (
    "encoding/json"
    "net/http"
    "os"
    "strings"
)

func reply() (resp *http.Response, err error) {

    message := map[string]interface{}{
        "text": "返信だよー",
    }

    jsonByte, err := json.Marshal(message)
    return postMessage(string(jsonByte))
}

func postMessage(jsonStr string) (resp *http.Response, err error) {

    reader := strings.NewReader(jsonStr)
    resp, err = http.Post(os.Getenv("SLACK_INCOMING_WEBHOOK"), "application/json", reader)

    if err != nil {
        return nil, err
    }

    return resp, nil
}

こうすることで .env.yaml に記載した slack の incoming webhook をハードコーディングしなくとも参照できるようになる。

次に slack.go で定義した関数を function.go から呼び出せるようにする。

       }
        w.WriteHeader(http.StatusOK)
        log.Printf("your message is: %s", str)
+       _, err = reply()
+       if err != nil {
+           w.WriteHeader(http.StatusServiceUnavailable)
+           log.Fatalf("failed to post message to slack: %v", err)
+       }
        return
    }

今のディレクトリ構成はこうなる。

.
├── .env.yaml
├── .gcloudignore
├── .gitignore
├── Makefile
├── go.mod
├── jimiko.go
├── mention.txt
├── scripts
│   └── deploy.sh
└── slack.go

ここまでで deploy すると以下のような感じになる。

f:id:kimikimi714:20191028002723p:plain
返信くるようになった

ここまでやっておくと、あとは飛んできたメンションの内容を見て返信する内容を変えたりとか好きにカスタマイズできる。

ここからのカスタマイズはまた気が向いたら記事にする。

参考