16
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Go2Advent Calendar 2018

Day 13

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

Posted at

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

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

本棚管理Botとは

普段生活していて、いろんな場面で本を購入することは多いと思います。
しかし、本を買ってみたものの、家に帰って本棚を確認してみたら、両親や兄弟が同じ本を買っていて、同じ本が2冊になってしまったという経験はないでしょうか。家だけではなく、会社や大学の研究室など様々な場所で似たようなことが起きるのではないでしょうか。
同じ本を2冊買うのを防ぐために、購入前にSlackで今本棚にある本の検索ができたら便利だと思い、今回本棚管理Bot(bookshlfという名のBot)を作りました。
できたものは以下です。
スクリーンショット 2018-12-13 22.01.34.png
写真のようにBotにメンションして、search:<文字列>という形式で検索ワードを送ると
スクリーンショット 2018-12-13 22.01.48.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リクエストを送信しています。なぜ、1回のリクエストでメッセージを返さなかったかというと、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 typesapplication/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を作成してみてください。
なお、ここが間違っている、このアーキテクチャはよくないなどのご意見ありましたら、ぜひ、ご指摘よろしくお願いいたします。
スクリーンショット 2018-12-13 23.18.38.png

16
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?