Spreadsheet で Slack bot のカスタムレスポンスみたいなのを管理したい

# 概要

本記事は[ちょっとした工夫で効率化！01【PR】パソナテック Advent Calendar 2020](https://qiita.com/advent-calendar/2020/pasonatech-tips) の 17 日目の記事です。
Advent Calendar への参加は初めてなので緊張します。


# 背景

弊社では以前、slackbot で「内線」と打つと各社員の内線番号を表示する、というのをカスタムレスポンスで管理していました。
しかし、改行できず（※）見づらかったり、変更があった場合に情報を一元管理できなかったり……という問題がありました。
その他にも、いまいち融通のきかないカスタムレスポンスを便利にすることで Slack を利用した業務の効率化ができないかな、と考えたのが元になります。

（※）
[ついこないだ知った](https://note.com/mtk5/n/naeb62a2d1e15)のですが、改行コード（`\n`）認識してくれるんですね。
これ知った瞬間「この記事書く意味ほぼなくなったんじゃね？」と思ったんですが、使い方次第で便利さ残ってるし、何より他に思いつかなかったので書いちゃいます :sweat_drops: 


# 手法

- レスポンスしたい内容は Spreadsheet で管理する
- Slack bot を作成し、Spreadsheet の内容を使ってカスタムレスポンスする
    - bot には [Bots を利用する](https://nekojarashi.slack.com/apps/A0F7YS25R-bots)

という方法で作成することにしました。
なお、「実行場所が変わってもいちいち実行環境を準備したくない」「シングルバイナリで管理したい」という考えから、Slack bot の作成には Golang を用いました。
bot のコードと説明はこの記事の一番最後に記載します。


## 構成

![名称未設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88428/e0f6bf3d-47b9-bf1f-6a3a-80aee696f9ba.png)

- bot は定期的に Spreadsheet を見て情報をレスポンス内容を更新する
- ユーザは bot を invite 済みのチャンネルで元ワードを post する
- 元ワードと一致した post を見つけた bot はカスタムレスポンスをそのチャンネルに送信する

という動作をします。

Spreadsheet にこんな感じでメッセージを登録しておいて
![スクリーンショット 2020-12-16 16.21.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88428/4b2d817f-2307-2599-4164-acb317abe7c6.png)

Slack でそのメッセージを post すると、bot がレスポンスしてくれます。
![スクリーンショット 2020-12-16 16.24.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88428/839607d5-3e6a-f7d6-d03f-22fed17fb9a9.png)


## Spreadsheet 管理のメリット

これだけだとただの slackbot の再開発ですが、Spreadsheet にしておくと「カスタムレスポンスの内容を Spreadsheet で動的に更新」することができます。
例で出した `テスト用内線` の内容は、別シートで管理しています。

![スクリーンショット 2020-12-16 16.29.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88428/1732e78f-61e5-0390-fc32-f6dac6825e90.png)

この参照先では、表管理した内線番号を Spreadsheet 関数で結合しています。
例えば、`部署1` の行は、B 列に記載した内容を結合して表示しています。
![スクリーンショット 2020-12-16 16.31.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88428/3db625d4-20ea-2b86-6b67-ccd7b225a8a9.png)
![スクリーンショット 2020-12-16 16.32.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88428/450f7dcb-14f3-dad9-6034-b8a4766de7f8.png)

こうして結合した各部署の行を、さらに結合することで全体のセルを作ることができます。
![スクリーンショット 2020-12-16 16.35.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88428/aa14291b-2189-bb7e-8a0c-33cd23d7bb2b.png)

こうしておくことで、誰でも簡単に内線番号を編集し、さらに内線の情報を表管理することもできます。

さらに、Spreadsheet には便利な関数が多く用意されていますので、例えばこのようなレスポンスを用意しておけば、bot がレスポンス内容を更新したタイミングの最新ドル円レートを表示させる、といったことも可能です。
![スクリーンショット 2020-12-16 16.38.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88428/eab8689e-bc43-930d-1e42-c2003b0f661a.png)

![スクリーンショット 2020-12-16 17.17.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88428/75aafa2b-87f2-0b22-7a7a-a8e710e61975.png)


## Bots を使うメリット

~~ほぼありません~~

- （今後非推奨になっていくようですが）RTM API を使えるので、slash command など新しい使い方を覚えなくても簡単にレスポンスを使える
- 任意の写真をアイコンに使えるようになることで愛着が湧く**（重要）**
    - 愛着があれば「放置されて誰も手を入れられなくなる RPA」みたいなのが減らせると思っています（割と本音）

# まとめ・感想

Spreadsheet の内容でカスタムレスポンスする bot を作ると、Spreadsheet の機能を使って様々な動的メッセージを作れることをご紹介しました。
簡単に内線表を更新したりほぼ最新のドル円レートを表示させたり、Spreadsheet の機能でできることは全部できるはずなので、他にも様々な使い方が考えられると思います。
~~まぁ、昨今の事情で弊社もリモートワークになったので、内線番号は意味を失ってしまったのですが・・・。~~

また、Bot のコード自体はかなり前に書いたものなので、本記事を書く際に改めて見てみたら、色々と粗が目立っていて公開するのを少しためらうほどでした :sweat_smile: 
せっかくなので、もっとちゃんと整理して例外処理も書いてテストも書いたら Github で公開してみようと思います。


# 付録

## Bot の起動方法

Bots の登録方法・基本的な使い方は以前書いたこちらの記事をご参照ください。
https://qiita.com/k-nishigaki/items/3477a93a816c0f864dc1

Go のコードは以下に記載します。
`slackToken` に Bots の API Token を入れて、起動時引数に以下を入れて実行すれば動くかと思います。

- `ss_id` : SpreadSheet の ID 部分
- `token_path` : token.json のパス
- `credentials_path` : credentials.json のパス

```bash:実行方法
/path/to/execution_file -token_path /path/to/token.json -credentials_path /path/to/credentials.json -ss_id [spreadsheet ID]
```

daemon 化などはご利用の OS に応じて行っていただければと思います。

## Spreadsheet API の利用方法

Google のサンプルほぼそのままです。
https://developers.google.com/sheets/api/quickstart/go

token.json や credentials.json はこちらを参考に取得してください。

## Bot のコード

```golang:コード
package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"os"
	"time"

	"github.com/slack-go/slack"
	"golang.org/x/net/context"
	"golang.org/x/oauth2"
	"golang.org/x/oauth2/google"
	"google.golang.org/api/sheets/v4"
)

const slackToken string = "Insert Your Bots API Token"

func run(m *map[string]string, api *slack.Client, ss string, tokPath string, credPath string) int {

	rtm := api.NewRTM()
	go rtm.ManageConnection()
	for {
		msg := <-rtm.IncomingEvents
		log.Printf("MSG: %#v\n", msg.Data)
		switch ev := msg.Data.(type) {
		case *slack.HelloEvent:
			log.Print("Hello Event")
		case *slack.ConnectionErrorEvent:
			log.Print(ev.Error())

		case *slack.MessageEvent:
			log.Printf("Message: %#v\n", ev)
			if value, ok := (*m)[ev.Text]; ok {
				rtm.SendMessage(rtm.NewOutgoingMessage(value, ev.Channel))
			}
		}
	}
}

// Retrieve a token, saves the token, then returns the generated client.
func getClient(config *oauth2.Config, tokPath string) *http.Client {
	// The file token.json stores the user's access and refresh tokens, and is
	// created automatically when the authorization flow completes for the first
	// time.
	tokFile := tokPath
	tok, err := tokenFromFile(tokFile)
	if err != nil {
		tok = getTokenFromWeb(config)
		saveToken(tokFile, tok)
	}
	return config.Client(context.Background(), tok)
}

// Request a token from the web, then returns the retrieved token.
func getTokenFromWeb(config *oauth2.Config) *oauth2.Token {
	authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
	fmt.Printf("Go to the following link in your browser then type the "+
		"authorization code: \n%v\n", authURL)

	var authCode string
	if _, err := fmt.Scan(&authCode); err != nil {
		log.Fatalf("Unable to read authorization code: %v", err)
	}

	tok, err := config.Exchange(context.TODO(), authCode)
	if err != nil {
		log.Fatalf("Unable to retrieve token from web: %v", err)
	}
	return tok
}

// Retrieves a token from a local file.
func tokenFromFile(file string) (*oauth2.Token, error) {
	f, err := os.Open(file)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	tok := &oauth2.Token{}
	err = json.NewDecoder(f).Decode(tok)
	return tok, err
}

// Saves a token to a file path.
func saveToken(path string, token *oauth2.Token) {
	fmt.Printf("Saving credential file to: %s\n", path)
	f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
	if err != nil {
		log.Fatalf("Unable to cache oauth token: %v", err)
	}
	defer f.Close()
	json.NewEncoder(f).Encode(token)
}

func readSS(spreadsheetId string, credPath string, tokPath string) *sheets.ValueRange {
	b, err := ioutil.ReadFile(credPath)
	if err != nil {
		log.Fatalf("Unable to read client secret file: %v", err)
	}

	// If modifying these scopes, delete your previously saved token.json.
	config, err := google.ConfigFromJSON(b, "https://www.googleapis.com/auth/spreadsheets.readonly")
	if err != nil {
		log.Fatalf("Unable to parse client secret file to config: %v", err)
	}

	client := getClient(config, tokPath)

	srv, err := sheets.New(client)
	if err != nil {
		log.Fatalf("Unable to retrieve Sheets client: %v", err)
	}

	// Prints the names and majors of students in a sample spreadsheet:
	// https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit
	//spreadsheetId := "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms"
	readRange := "reaction!B2:C"
	resp, err := srv.Spreadsheets.Values.Get(spreadsheetId, readRange).Do()
	if err != nil {
		log.Fatalf("Unable to retrieve data from sheet: %v", err)
	}

	return resp
}

func refreshReaction(m *map[string]string, ss string, tokPath string, credPath string) {
	resp := readSS(ss, credPath, tokPath)
	if len(resp.Values) == 0 {
		log.Println("No data found.")
	} else {
		for _, row := range resp.Values {
			// Print columns A and E, which correspond to indices 0 and 4.
			key, cast := row[0].(string)
			if cast == false {
				log.Fatalf("Unable read key")
			}

			value, cast := row[1].(string)
			if cast == false {
				log.Fatalf("Unable read value")
			}
			(*m)[key] = value
		}
	}

}

func main() {
	ss := flag.String("ss_id", "Insert Your Default Spreadsheet ID", "SpreadSheet ID")
	tokPath := flag.String("token_path", "token.json", "token.json path")
	credPath := flag.String("credentials_path", "credentials.json", "credentials.json path")
	flag.Parse()

	api := slack.New(slackToken)
	log.Printf("[DEBUG]api: %#v", api)
	m := map[string]string{}
	refreshReaction(&m, *ss, *tokPath, *credPath)

	go run(&m, api, *ss, *tokPath, *credPath)

	for {
		time.Sleep(5 * time.Minute)
		refreshReaction(&m, *ss, *tokPath, *credPath)
	}
}
```