Goのnet/httpとSlackのEventAPIでHTTPベースの本棚管理Botを作ってみた

# Goのnet/httpとSlackのEventAPIで本棚管理Botを作ってみた
POSTが遅くなってしまい申し訳ありません。Go2アドベントカレンダー13日目の記事です。
今回はアドベントカレンダーに向けて、Goのnet/httpパッケージとSlackのEventAPIを使って、書籍の検索を行ってくれる本棚管理ボットを作ってみました。今回はHTTPサーバーベースのBotです。
今回の記事ではその概要と実装について説明していきたいと思います。
実際のコードは[GitHub](https://github.com/hikaru7719/bookshelf-bot)にあります。

## 本棚管理Botとは
普段生活していて、いろんな場面で本を購入することは多いと思います。
しかし、本を買ってみたものの、家に帰って本棚を確認してみたら、両親や兄弟が同じ本を買っていて、同じ本が２冊になってしまったという経験はないでしょうか。家だけではなく、会社や大学の研究室など様々な場所で似たようなことが起きるのではないでしょうか。
同じ本を２冊買うのを防ぐために、購入前にSlackで今本棚にある本の検索ができたら便利だと思い、今回本棚管理Bot(bookshlfという名のBot)を作りました。
できたものは以下です。
<img width="186" alt="スクリーンショット 2018-12-13 22.01.34.png" src="https://qiita-image-store.s3.amazonaws.com/0/136442/11eb69d1-5b8d-8b83-7598-fd6d0c5c54a8.png">
写真のようにBotにメンションして、search:＜文字列＞という形式で検索ワードを送ると
<img width="525" alt="スクリーンショット 2018-12-13 22.01.48.png" src="https://qiita-image-store.s3.amazonaws.com/0/136442/9da27ce7-4baa-e3df-ad56-cad7d8dc8b4c.png">

このように検索結果を返してくれるというものです。後述しますが、この検索結果は本の一覧が書かれたCSVファイルを読み込み、参照した結果を返しています。
今回このBotをGoのnet/httpパッケージとSlackのEventAPIを使ってHTTPサーバーベースのBotを作成しました。

## Slack,EventAPI
まずEventAPIはメンションなどの特定のイベントが発生すると自分が指定したURLにリクエストを投げてくれます。
今回はBotユーザーへのメンションが発生した時に自分のHTTPサーバーのURLにリクエストを送信するように設定します。（設定方法は他の記事参照）

## GoのHTTPサーバー
main.goは以下のようになっています。

```main.go
func main() {
	http.HandleFunc("/", handler.Handle)
	http.ListenAndServe(":8080", nil)
}
```
`/`にリクエストがきたらhandler.Handle関数が呼ばれるようにしています。

## handler.Handle関数
関数はの概要は以下です。長くてすいません。

```handler/handler.go
func Handle(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	defer r.Body.Close()

	byteBody, err := ioutil.ReadAll(r.Body)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	var jsonMap map[string]interface{}
	if err := json.Unmarshal(byteBody, &jsonMap); err != nil {
		fmt.Println(err)
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	fmt.Println(jsonMap)

	token := jsonMap["token"].(string)
	if token != os.Getenv("SLACK_TOKEN") {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	eventType := jsonMap["type"].(string)
	switch eventType {
	case "url_verification":
		challenge := jsonMap["challenge"].(string)
		w.WriteHeader(200)
		w.Write([]byte(challenge))
		return
	case "event_callback":
		event := jsonMap["event"].(map[string]interface{})
		eventTypeString := event["type"].(string)
		if eventTypeString == "app_mention" {
			eventText := event["text"].(string)
			stringReader := strings.NewReader(eventText)
			scanner := bufio.NewScanner(stringReader)
			scanner.Scan()
			scanner.Scan()
			text := scanner.Text()
			splitSlice := strings.Split(text, ":")
			if len(splitSlice) < 1 {
				w.WriteHeader(http.StatusBadRequest)
				return
			}
			switch splitSlice[0] {
			case "search":
				w.WriteHeader(200)
				if err != nil {
					fmt.Fprint(os.Stderr, err)
					return
				}
				service, err := service.NewService()
				if err != nil {
					fmt.Fprint(os.Stderr, err)
					return
				}
				channelName := event["channel"].(string)
				go service.SendAnswer(splitSlice[1], channelName)
				return
			}
		}
	default:
		w.WriteHeader(http.StatusBadRequest)
		return
	}
}
```
jsonのリクエストを受け取り、それを`json.Unmatshal`関数を使って`map[string]interface{}`型にしてそこから型アサーションを使って、type.event.type.textを取り出します。これがユーザーから送られてきたメッセージになります。メッセージは

```
@bookshlf
saerch:HTTP
```
という風になるので`scanner.Scan()`を2回使って、2行目の`saerch:HTTP`を取り出します。その後`strings.Split(text, ":")`を使って:で区切りスライスに入れます。今回でいうとスライスの1番目の要素がHTTPという検索ワードになります。
この検索ワードとchannel名をgoroutineに渡して、goroutine内でCSVを使った検索を行い、Slackのchat.postMessageのAPIにPOSTリクエストを送信しています。なぜ、１回のリクエストでメッセージを返さなかったかというと、EventAPIのページに以下のことが書いてあったからです。

>Respond to events with a HTTP 200 OK as soon as you can.
> Avoid actually processing and reacting to events within
> the same process. Implement a queue to handle inbound events after they are received.

これをみて真っ先にgoroutineだと思いつき、goroutineを使ってみました。
(goroutineの使い方はあっているかはわからない)

## goroutineの中身
goroutineとして呼び出されているservice.SendAnswer関数の中身はどうなっているかというと検索ワードに応じて、CSV内の検索を行い、検索結果を文字列にしてchat.postMessageのAPIにPOSTリクエストを送信しています。service.SendAnswer関数自体の実装はあとで提示します。

## CSV内文字列の検索
以下はCSVの検索の関数です。

```finder/finder.go
type Finder interface {
	Find(searchWord string) ([]domain.Book, error)
	Close()
}

type CSV struct {
	reader io.ReadCloser
}

func (c *CSV) Find(searchWord string) ([]domain.Book, error) {
	bookSlice := make([]domain.Book, 0, 10)
	csvReader := csv.NewReader(c.reader)
	record, err := csvReader.ReadAll()
	if err != nil {
		fmt.Println(err)
		return nil, err
	}
	for _, row := range record {
		if strings.Contains(row[2], searchWord) && row[2] != "" && searchWord != "" {
			newBook := domain.Book{ISBN: row[0], Title: row[2], Author: row[3], Publisher: row[4]}
			bookSlice = append(bookSlice, newBook)
		}
	}
	return bookSlice, nil
}

//Close関数は省略
```
CSVという構造体にreaderというio.ReadCloserを持たせていますが、これはos.File型のcsvファイルが入ります。ちなみにCSV構造体はFinderインターフェースを実装しています。`csv.NewReader(c.reader)`関数と`csvReader.ReadAll()`関数でcsvの読み取りを行なっています。
CSVファイルのフォーマットは

```:csv
ISBN,found,title,author,publisher,volume,series,cover
```
とい風になっており、今回はtitle（row[2]）が検索ワードを含んでいたら、BooK構造体にセットして、スライスに入れていきます。検索ワードを含んでいるかは`strings.Contains(row[2], searchWord)`で確認しています。

## Book構造体
Book構造体は以下のように定義しました。

```
type Book struct {
	ISBN      string
	Title     string
	Author    string
	Publisher string
}
```
今回はCSVのISBN,title,author,publisherの項目だけを用います。
CSV検索関数ではBook構造体のスライスをservice.SendAnswer関数に返します。

## service.SendAnswer関数
service.SendAnswer関数は以下のようになっています。
finder.FinderはCSV構造体が実装している、インターフェースです。実態はCSV構造体が入ります。

```service/book_service.go
type BookService struct {
	finder finder.Finder
}

func (b *BookService) SendAnswer(query string, channelName string) {
	bookSlice, err := b.finder.Find(query)
	if err != nil {
		fmt.Println(err)
	}

	var sendMessage strings.Builder
	length := strconv.Itoa(len(bookSlice))
	fmt.Println(length)
	if len(bookSlice) > 0 {
		sendMessage.WriteString("本あったよ:sunglasses:\n")
		sendMessage.WriteString("検索結果/" + length + "件\n")
	} else {
		sendMessage.WriteString("残念だ...\n")
	}

	for _, book := range bookSlice {
		sendMessage.WriteString("```")
		sendMessage.WriteString(book.ToString())
		sendMessage.WriteString("```")
		sendMessage.WriteString("\n")
	}
	message.SendMessage(channelName, sendMessage.String())
	b.finder.Close()
}
```
b.finder.Find(query)の部分がCSVの検索の関数です。
帰ってきた、Book構造体のスライスをもとにユーザーに送信するメッセージを組み立てています。メッセージの組み立てでは`strings.Builder`を使っています。
`book.ToString()`関数はBook構造体の情報を読みやすい形に整形した文字列を返す関数です。
メッセージの組み立てが終わったら、message.SendMessage(channelName, sendMessage.String())関数を呼び出します。この関数でchat.postMessageのAPIにPOSTリクエストを送信しています。



## message.SendMessage関数

```message/send_message.go
func SendMessage(channelName string, message string) {
	values := url.Values{}
	token := os.Getenv("SLACK_OAUTH_TOKEN")
	values.Add("token", token)
	values.Add("channel", channelName)
	values.Add("text", message)
	values.Add("mrkdwn", "true")
	resp, err := http.PostForm("https://slack.com/api/chat.postMessage", values)
	if err != nil {
		fmt.Println(err)
	}
	defer resp.Body.Close()

	byteString, _ := ioutil.ReadAll(resp.Body)
	fmt.Println(string(byteString))
}
```
今回は`http.PostForm`でPOSTリクエストを送信しています。chat.PostMessageのAPIは`content types`が`application/x-www-form-urlencoded`形式をサポートしています。`application/json`形式もサポートしていますが、Bodyの組み立てが簡単そうな`application/x-www-form-urlencoded`を使いました。
`url.Values{}`を使ってリクエストのBodyを組み立てていきます。
メッセージを送るためのtokenと送信するchannelとメッセージであるtextとメッセージのマークダウン形式を認めるmrkdwnオプションを追加します。
その後`http.PostForm("https://slack.com/api/chat.postMessage", values)`でメッセージが送信されます。

## まとめ
今回はGoのnet/httpとSlackのEventAPIを使って、HTTPベースのBotを作成しました。HTTPベースでBotが作れるのはだいぶ大きかったです。皆さんも是非HTTPベースでお好みのBotを作成してみてください。
なお、ここが間違っている、このアーキテクチャはよくないなどのご意見ありましたら、ぜひ、ご指摘よろしくお願いいたします。
<img width="241" alt="スクリーンショット 2018-12-13 23.18.38.png" src="https://qiita-image-store.s3.amazonaws.com/0/136442/b409d0d0-505f-294f-87e7-81352418931e.png">