Slack, Twitterのアイコンを定期更新して同僚とフォロワーに今の天気を通知する

僕はずっといらすとやさんのグラサン太陽アイコンを使っているのですが、その時の天気に合わせて動的にアイコンを変えたら簡易天気速報みたいになって面白いんじゃないかとふと思い、AWSとGo(特に非同期処理)の勉強にもなりそうだったので実装してみました。
<img width="710" alt="スクリーンショット 2019-10-05 10.23.22.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/234012/8b495d7f-c2fa-29c3-5bd8-957c592a3d9f.png">


## ざっくりとした処理の流れ
以下の処理を行うスクリプトをGoで書き、Lambda上で定期実行

- まず[気象情報API](https://openweathermap.org/)から現在の天気情報を取得
- 取得した天気情報を元にS3の画像を選択
- 選択した画像を[Slack API/setPhoto](https://api.slack.com/methods/users.setPhoto), [Twitter API](https://developer.twitter.com/en/docs/accounts-and-users/manage-account-settings/api-reference/post-account-update_profile_image)に投げてアイコンをアップデート
- [Slack api/postMessage](https://api.slack.com/methods/chat.postMessage)でアップデートの成功/失敗を会社ワークスペースの自分宛DMに通知

## 使用技術
- Go v1.12
- AWS Lambda
- AWS S3
- AWS SDK for GO
- Slack API
- Twitter API

## 下準備
### Lambda
AWS上で、バイナリ実行環境であるLambdaとアイコン置き場であるS3をセットアップします。LambdaはRuntimeをGoに設定し、Cloudwatch Eventsで3時間ごとにkickされるようにします。
### S3
S3にはいらすとやから貰った画像を適当な名前をつけてアップロード:sunny:
<img width="603" alt="スクリーンショット 2019-10-05 10.55.50.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/234012/13d5d47b-413a-a7a0-1ce1-abbe4f6f097a.png">
### IAM
今回AWS SDK for goを用いてコード内でS3のオブジェクトを取得するので、IAMで有効なセッションを作成します。コンソールからユーザーを作成しAccess key IDとSecret access keyを控えておきます。

## コードを書く
Goでコードを書いていきます。
### 気象情報APIの情報を元にS3上の画像名を取得
[Open Weather Map API](https://openweathermap.org/)にリクエストを飛ばして現在の気象情報を取得します。tokenはLambda側に環境変数としておきます。(以下SlackやTwitterのAPI tokenも同様です。)どこかで処理が失敗した場合は `notifyAPIResultToSlack`を呼び出してSlackに通知を送ります。(詳細は後述)

```golang
type weatherAPIResponse struct {
	Weather []struct {
		ID   int    `json:"id"`
		Main string `json:"main"`
	}
}

func fetchCurrentWeatherID() int {
	log.Println("[INFO] Start fetching current weather info from weather api")

	city := "Tokyo"
	token := os.Getenv("WEATHER_API_TOKEN")
	apiURL := "https://api.openweathermap.org/data/2.5/weather?q=" + city + "&appid=" + token

	log.Printf("[INFO] Weather api token: %s", token)

	resp, _ := http.Get(apiURL)
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		notifyAPIResultToSlack(false)
		log.Fatalf("[ERROR] Fail to read weather api response body: %s", err.Error())
	}
	defer resp.Body.Close()

	var respJSON weatherAPIResponse
	if err = json.Unmarshal(body, &respJSON); err != nil {
		notifyAPIResultToSlack(false)
		log.Fatalf("[ERROR] Fail to unmarshal weather api response json: %s", err.Error())
	}

	log.Printf("[INFO] Current weather: %s", respJSON.Weather[0].Main)
	return respJSON.Weather[0].ID
}
```
APIからのレスポンスを元に画像名を選択

```golang
func getImageName() string {
	currentWeatherID := fetchCurrentWeatherID()

	var imageName string
	// ref: https://openweathermap.org/weather-conditions
	switch {
	case 200 <= currentWeatherID && currentWeatherID < 300:
		imageName = "bokuthunder"
	case currentWeatherID < 600:
		imageName = "bokurainy"
	case currentWeatherID < 700:
		imageName = "bokusnowy"
	default:
		imageName = "bokusunny"
	}

	// 夜は天気に関係なくbokumoonに上書き
	location, _ := time.LoadLocation("Asia/Tokyo")
	if h := time.Now().In(location).Hour(); h <= 5 || 22 <= h {
		imageName = "bokumoon"
	}

	return imageName
}
```
### 選択した画像名をキーとしてS3から画像データを取得
IAM作成時に控えたAccess key IDとSecret access keyでクライアントを認証し、S3から先ほど取得した画像名をキーにアイコンのオブジェクトを取得します。

```golang
func fetchS3ImageObjByName(imageName string) *s3.GetObjectOutput {
	AWSSessionID := os.Getenv("AWS_SESSION_ID")
	AWSSecretAccessKey := os.Getenv("AWS_SECRET")

	log.Println("[INFO] Start fetching image obj from S3.")
	log.Printf("[INFO] AWS session id: %s", AWSSessionID)
	log.Printf("[INFO] AWS secret access key: %s", AWSSecretAccessKey)

	sess := session.Must(session.NewSession())
	creds := credentials.NewStaticCredentials(AWSSessionID, AWSSecretAccessKey, "")

	svc := s3.New(sess, &aws.Config{
		Region:      aws.String(endpoints.ApNortheast1RegionID),
		Credentials: creds,
	})

	obj, err := svc.GetObject(&s3.GetObjectInput{
		Bucket: aws.String("bokuweather"),
		Key:    aws.String(imageName + ".png"),
	})
	if err != nil {
		notifyAPIResultToSlack(false)
		log.Fatalf("[ERROR] Fail to get image object: %s", err.Error())
	}

	return obj
}
```
### Slack API/Twitter APIでアイコンをアップデート
画像をSlack API, Twitter APIに投げます。ここではgo routineを用いて非同期処理にしました。

```go
	imgByte, err := ioutil.ReadAll(obj.Body)
	if err != nil {
		notifyAPIResultToSlack(false)
		log.Fatalf("[Error] Fail to read the image object: %s", err.Error())
	}
	defer obj.Body.Close()

	c := make(chan apiResult, 1)

	go updateSlackIcon(imgByte, c)
	go updateTwitterIcon(imgByte, c)

	result1, result2 := <-c, <-c
	if result1.StatusCode != 200 || result2.StatusCode != 200 {
		notifyAPIResultToSlack(false)
		log.Fatalf("[ERROR] Something went wrong with updateImage func.")
	}
```
[Slack APIのドキュメント](https://api.slack.com/methods/users.setPhoto)を参考にリクエストを飛ばす関数を作ります。最後に結果をチャネルを介して大元のゴールーチンに渡します。

```golang
type slackAPIResponse struct {
	Ok    bool   `json:"ok"`
	Error string `json:"error"`
}

func updateSlackIcon(imgByte []byte, c chan apiResult) {
	imgBuffer := bytes.NewBuffer(imgByte)

	reqBody := &bytes.Buffer{}
	w := multipart.NewWriter(reqBody)
	part, err := w.CreateFormFile("image", "main.go")
	if _, err := io.Copy(part, imgBuffer); err != nil {
		notifyAPIResultToSlack(false)
		log.Fatalf("[Error] Fail to copy the image: %s", err.Error())
	}
	w.Close()

	req, _ := http.NewRequest(
		"POST",
		// "https://httpbin.org/post", // httpテスト用
		"https://slack.com/api/users.setPhoto",
		reqBody,
	)

	token := os.Getenv("SLACK_TOKEN")
	log.Printf("[INFO] Slack token: %s", token)

	req.Header.Set("Content-type", w.FormDataContentType())
	req.Header.Set("Authorization", "Bearer "+token)

	log.Println("[INFO] Send request to update slack icon!")
	client := &http.Client{}
	resp, err := client.Do(req)

	if err != nil {
		notifyAPIResultToSlack(false)
		log.Fatalf("[Error] Something went wrong with the slack setPhoto request : %s", err.Error())
	}
	log.Printf("[INFO] SetPhoto response status: %s", resp.Status)

	defer resp.Body.Close()
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		notifyAPIResultToSlack(false)
		log.Fatalf("[Error] Fail to read the response: %s", err.Error())
	}

	var respJSON slackAPIResponse
	if err = json.Unmarshal(body, &respJSON); err != nil {
		notifyAPIResultToSlack(false)
		log.Fatalf("[Error] Fail to unmarshal the response: %s", err.Error())
	}

	if !respJSON.Ok {
		notifyAPIResultToSlack(false)
		log.Fatalf("[ERROR] Something went wrong with setPhoto request: %s", respJSON.Error)
	}

	c <- apiResult{"slack", resp.StatusCode}
}
```
Twitter APIも同様に[ドキュメント](https://developer.twitter.com/en/docs/accounts-and-users/manage-account-settings/api-reference/post-account-update_profile_image)を参考にリクエストを飛ばす関数を作ります。OAuthは[便利なライブラリ](https://github.com/dghubble/oauth1)があったのでそちらを用いました。途中突然リクエストが401 Unauthorizedを返してきてハマりましたが、URL Queryの予約語をエスケープすることで解決しました。

```golang
func updateTwitterIcon(imgByte []byte, c chan apiResult) {
	oauthAPIKey := os.Getenv("OAUTH_CONSUMER_API_KEY")
	oauthAPIKeySecret := os.Getenv("OAUTH_CONSUMER_SECRET_KEY")
	oauthAccessToken := os.Getenv("OAUTH_ACCESS_TOKEN")
	oauthAccessTokenSecret := os.Getenv("OAUTH_ACCESS_TOKEN_SECRET")

	log.Printf("[INFO] Twitter API Key: %s", oauthAPIKey)
	log.Printf("[INFO] Twitter API Secret Key: %s", oauthAPIKeySecret)
	log.Printf("[INFO] Twitter Access Token: %s", oauthAccessToken)
	log.Printf("[INFO] Twitter Secret Access Token: %s", oauthAccessTokenSecret)

	config := oauth1.NewConfig(oauthAPIKey, oauthAPIKeySecret)
	token := oauth1.NewToken(oauthAccessToken, oauthAccessTokenSecret)

	httpClient := config.Client(oauth1.NoContext, token)

	encodedImg := base64.StdEncoding.EncodeToString(imgByte)
	encodedImg = url.QueryEscape(encodedImg) // replace URL encoding reserved characters
	log.Printf("Encoded icon: %s", encodedImg)

	twitterAPIRootURL := "https://api.twitter.com"
	twitterAPIMethod := "/1.1/account/update_profile_image.json"
	URLParams := "?image=" + encodedImg

	req, _ := http.NewRequest(
		"POST",
		twitterAPIRootURL+twitterAPIMethod+URLParams,
		nil,
	)

	log.Println("[INFO] Send request to update twitter icon!")
	resp, err := httpClient.Do(req)
	if err != nil {
		notifyAPIResultToSlack(false)
		log.Fatalf("[Error] Something went wrong with the twitter request : %s", err.Error())
	}
	defer resp.Body.Close()

	log.Printf("[INFO] Twitter updateImage response status: %s", resp.Status)

	c <- apiResult{"twitter", resp.StatusCode}
}
```
### 結果の通知
両方のAPIからレスポンスが帰って来次第Slackの自分宛DMに結果を送信します。ここでは [chat.postMessage](https://api.slack.com/methods/chat.postMessage)を使います。

```golang
func notifyAPIResultToSlack(isSuccess bool) slackAPIResponse {
	channel := os.Getenv("SLACK_NOTIFY_CHANNEL_ID")
	attatchmentsColor := "good"
	imageName := getImageName()
	attatchmentsText := "Icon updated successfully according to the current weather! :" + imageName + ":"
	iconEmoji := ":bokurainy:"
	username := "bokuweather"

	if !isSuccess {
		lambdaCloudWatchURL := "https://ap-northeast-1.console.aws.amazon.com/cloudwatch/home?region=ap-northeast-1#logStream:group=/aws/lambda/bokuweather;streamFilter=typeLogStreamPrefix"
		attatchmentsColor = "danger"
		attatchmentsText = "Bokuweather has some problems and needs your help!:bokuthunder:\nWatch logs: " + lambdaCloudWatchURL
	}

	jsonStr := `{"channel":"` + channel + `","as_user":false,"attachments":[{"color":"` + attatchmentsColor + `","text":"` + attatchmentsText + `"}],"icon_emoji":"` + iconEmoji + `","username":"` + username + `"}`
	req, _ := http.NewRequest("POST", "https://slack.com/api/chat.postMessage", bytes.NewBuffer([]byte(jsonStr)))

	req.Header.Set("Authorization", "Bearer "+os.Getenv("SLACK_TOKEN"))
	req.Header.Set("Content-Type", "application/json")

	log.Printf("[INFO] Send request to notify outcome. isSuccess?: %t", isSuccess)
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		log.Fatalf("[Error] Something went wrong with the postMessage reqest : %s", err.Error())
	}
	log.Printf("[INFO] PostMessage response states: %s", resp.Status)

	defer resp.Body.Close()
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		log.Fatalf("[Error] Fail to read the response: %s", err.Error())
	}

	var respJSON slackAPIResponse
	if err = json.Unmarshal(body, &respJSON); err != nil {
		log.Fatalf("[Error] Fail to unmarshal the response: %s", err.Error())
	}

	return respJSON
}
```
成功した場合どの画像にアップデートされたかを、失敗した場合Cloudwatch Logsへのリンクを通知します。
<img width="766" alt="スクリーンショット 2019-10-05 12.52.07.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/234012/acd4e406-69ca-73d7-72ec-0e51c53dfe01.png">

## コードのアップロード
```sh
$ GOOS=linux go build main.go
$ zip main.zip main # これでzipされたファイルをLambdaにアップロード
```

## 感想
AWSとGo(特に非同期処理周り)の入門としてこのアプリを作ってみようと思ったのですが、実際に何かを作りながら勉強するとやっぱり楽しいし書籍とにらめっこするより効率がいいと思いました。
また、各種APIやSDKなど本来意図していなかった分野の技術についても理解が深まって有意義でした。
まだまだ初心者なのでおかしい部分などあれば指摘大歓迎です！

## ソースコード
<a href="https://github.com/bokusunny/bokuweather"><img src="https://github-link-card.s3.ap-northeast-1.amazonaws.com/bokusunny/bokuweather.png" width="460px"></a>