GAE/Go で Events API を使った Slack ボットをつくる

# はじめに

* Go で Slack のボットを作る記事を読んだので自分でもつくってみた
* とりあえずボットにメンションを送ると何かするというのをやってみたい
* 環境を GAE/Go にしたので WebSocket を使う Real Time Message API はつかえなかった
* そのため同じようなことを HTTP 経由で行う Events API をつかって実装してみる

# ボットのコードを書く

* レポジトリは https://github.com/nirasan/go-slack-bot 

## ディレクトリ構成

```
PROJECT_ROOT
  app // アプリケーションのルートディレクトリ
    handler.go // アプリケーション用のウェブハンドラの実装
    payload.go // リクエストボディの取扱処理
  gae // GAE のルートディレクトリ
    app.go // GAE 起動時にウェブハンドラを登録
    app.yaml // GAE 基本設定ファイル
    secret.yaml // アクセストークンなどコミットできない設定用のファイル
```

## ウェブハンドラの登録

* GAE のルートの app.go の init() でデフォルトのウェブハンドラとしてアプリケーション用ハンドラを登録する

```go:app.go
func init() {
	http.Handle("/", app.NewAppHandler())
}
```

## アプリケーション用ハンドラ構造体の定義

* http.Handler インターフェイスを実装するハンドラ構造体を定義する

```go:handler.go
type AppHandler struct{}

func NewAppHandler() *AppHandler {
	return &AppHandler{}
}

func (h *AppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// 略
}
```

## Slack からのリクエストを取り扱う

* Slack からのリクエストは POST のペイロードに JSON として送られてくる
* イベントの種別によって JSON の構成が変わってくるので `map[string]interface{}` として受け取って文脈によって適宜解釈できるようにしておく

```go:payload.go
type Payload map[string]interface{}

func DecodeJSON(r io.Reader) (Payload, error) {
	data := make(map[string]interface{})
	if err := json.NewDecoder(r).Decode(&data); err != nil {
		return nil, err
	}
	return data, nil
}

func (p Payload) String(key string) string {
	if v, ok := p[key]; !ok {
		return ""
	} else if vv, ok := v.(string); !ok {
		return ""
	} else {
		return vv
	}
}

func (p Payload) Type() string {
	return p.String("type")
}
```

## Slack からのイベント通知先 URL 認証リクエストを正しく処理する

* Events API のイベント通知先の URL を設定すると Slack から認証のためのリクエストが送られてくるので正しく処理できるようにする
  * Slack 側でのイベント通知先の具体的な設定手順は後述

### 認証リクエストの内容

```
{
    "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl",
    "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P",
    "type": "url_verification"
}
```

### 求められるレスポンス

* text/plain の場合 challenge 要素の値をそのまま返却する
* 他の content type の場合は[APIドキュメント](https://api.slack.com/events-api#receiving_events)に詳しい

```
HTTP 200 OK
Content-type: text/plain
3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P
```

### 実装

* POST リクエストのペイロードをパースして Payload 構造体にし type が url_verification の場合は challenge の値をそのまま返す

```go:handler.go
	p, err := DecodeJSON(r.Body)

	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	switch p.Type() {
	case "url_verification":
		// イベント通知先URLの認証用アクセスへのレスポンス
		w.Header().Set("Content-Type", "text/plain")
		w.WriteHeader(http.StatusOK)
		w.Write([]byte(p.String("challenge")))
		return
```

## ボット宛のメッセージにレスポンスを返す

* 動作確認としてボットあてのメッセージを受けたら決まった文言を書き込む処理を実装してみる

```go:handler.go
	// イベント通知共通の種別
	case "event_callback":
		// イベント通知の詳細を取得する
		data, ok := p["event"].(map[string]interface{})
		if !ok {
			http.Error(w, "failed to cast", http.StatusBadRequest)
			return
		}
		pp := Payload(data)
		// ボット宛にメッセージを送信されたらレスポンスを返す
		if pp.String("type") == "message" && strings.Index(pp.String("text"), "<@U5L8SFV5W>") != -1 {
			// GAE から http リクエストを送信する場合は urlfetch ライブラリを利用する必要がある
			slack.SetHTTPClient(urlfetch.Client(ctx))
			// 環境変数からアクセストークンを取得. SLACK_TOKEN は appengine/secret.yaml に定義されている.
			token := os.Getenv("SLACK_TOKEN")
			api := slack.New(token)
			_, _, err = api.PostMessage(pp.String("channel"), "こんにちは", slack.PostMessageParameters{})
			if err != nil {
				log.Debugf(ctx, "failed to post message: %+v", err)
				http.Error(w, err.Error(), http.StatusBadRequest)
				return
			}
			log.Debugf(ctx, "Success")
		}
		w.WriteHeader(http.StatusOK)
		return
```

### イベント通知の詳細を取得する

```go
		data, ok := p["event"].(map[string]interface{})
		if !ok {
			http.Error(w, "failed to cast", http.StatusBadRequest)
			return
		}
		pp := Payload(data)
```

* イベント通知は以下のような JSON で送られてくるので event 要素を更に Payload として変換して具体的なイベント内容を取得する

```
{
        "token": "z26uFbvR1xHJEdHE1OQiO6t8",
        "team_id": "T061EG9RZ",
        "api_app_id": "A0FFV41KK",
        "event": {
                "type": "reaction_added",
                "user": "U061F1EUR",
                "item": {
                        "type": "message",
                        "channel": "C061EG9SL",
                        "ts": "1464196127.000002"
                },
                "reaction": "slightly_smiling_face"
        },
        "event_ts": "1465244570.336841",
        "type": "event_callback",
        "authed_users": [
                "U061F7AUR"
        ]
}
```

### ボットあてのメッセージのみを処理する

```go
		if pp.String("type") == "message" && strings.Index(pp.String("text"), "<@U5L8SFV5W>") != -1 {
```

* チャンネルへのメッセージ送信イベントを購読している場合 type が "message" でイベントが通知される
* このときボット宛であろうとなかろうと全ての書き込みが通知されるため `strings.Index` の部分でボット宛のメンションがついているかどうかを判定して処理を行っている
* この制限を入れない場合、自分のメッセージに自分でレスポンスして無限ループする。実際は無限に投稿できず投稿制限に引っかかってリクエストが止められる（止められた）

### GAE から http リクエストを送信する場合は urlfetch ライブラリを利用する必要がある

* https://cloud.google.com/appengine/docs/standard/go/issue-requests
* Slack のクライアントライブラリ `github.com/nlopes/slack` には HTTP クライアントの設定オプションがあるのでこれを利用する

```go
			slack.SetHTTPClient(urlfetch.Client(ctx))
```

### 環境変数からアクセストークンを取得する

* Slack への投稿には OAuth のアクセストークンが必要
* アクセストークンは Slack app 作成時に自動で発行されるので取得方法は後述する
* アクセストークンはコミットしてオープンにはできないので gae/secret.yaml に定義して gae/app.yaml から include する. 当然 secret.yaml は .gitignore に登録しておく

```go
			token := os.Getenv("SLACK_TOKEN")
			api := slack.New(token)
```

#### 環境変数へアクセストークンを設定する

```yaml:app.yaml
runtime: go
api_version: go1

handlers:
- url: /.*
  script: _go_app

includes:
- secret.yaml
```

```yaml:secret.yaml
env_variables:
  SLACK_TOKEN: 'xoxp-000000000000-000000000000-000000000000-00000000000000000000000000000000'
```

### 送信元のチャンネルにメッセージを送信する

* とりあえず疎通確認のみなので決まった文言を送信する

```go
			_, _, err = api.PostMessage(pp.String("channel"), "こんにちは", slack.PostMessageParameters{})
```

# Slack app をつくる

* Events API でイベントを購読するために Slack App をつくっていく
* https://api.slack.com/slack-apps の "Create a Slack app" から
* アプリ名と所属チームを選択して作成

# Slack app の管理をする

* https://api.slack.com/apps にアプリのリストがある

## Events API を有効に

* 管理ページの左メニュー Features の Event Subscriptions を開く
* Enable Events を on にする
* Request URL にイベント発生時にキックされるサーバーの URL を入力する
  * この時前述の認証イベントが発生する
* 動作確認のためチャンネルへのメッセージ書き込みイベントを購読する
  * "Subscribe to Bot Events" に message.channels を指定する

## アプリをチームに追加

* 管理ページの左メニュー Settings の Basic Information 
* Install your app to your team からアプリをチームに追加する

## Bot User の追加

* 管理ページの左メニュー Features の Bot Users から追加

## アプリのアクセストークンを取得する

* 左メニュー > Features > OAuth & Perissions を開く
* OAuth Access Token からトークンを取得する

## アプリへ権限を付与する

* 動作確認のためメッセージを書き込む権限を追加する
  * 過不足あるかもしれないがとりあえず動いた設定を記録しておく
* 左メニュー > Features > OAuth & Perissions を開く
* Permission Scopes から権限追加
  * CHANNELS から "Access user’s public channels." と "Modify your public channels." を選択
  * CHAT から "Send messages as go-slack." と "Send messages as user." を選択

# 動作確認を行う

* `#random` などにボットユーザーを招待する
* 普通のメッセージとボット宛のメッセージを投稿し、ボット宛のメッセージのみレスポンスがあることを確認する