2
1

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.

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

Last updated at Posted at 2019-10-12

僕はずっといらすとやさんのグラサン太陽アイコンを使っているのですが、その時の天気に合わせて動的にアイコンを変えたら簡易天気速報みたいになって面白いんじゃないかとふと思い、AWSとGo(特に非同期処理)の勉強にもなりそうだったので実装してみました。
スクリーンショット 2019-10-05 10.23.22.png

ざっくりとした処理の流れ

以下の処理を行うスクリプトをGoで書き、Lambda上で定期実行

使用技術

  • 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:
スクリーンショット 2019-10-05 10.55.50.png

IAM

今回AWS SDK for goを用いてコード内でS3のオブジェクトを取得するので、IAMで有効なセッションを作成します。コンソールからユーザーを作成しAccess key IDとSecret access keyを控えておきます。

コードを書く

Goでコードを書いていきます。

気象情報APIの情報を元にS3上の画像名を取得

Open Weather Map APIにリクエストを飛ばして現在の気象情報を取得します。tokenはLambda側に環境変数としておきます。(以下SlackやTwitterのAPI tokenも同様です。)どこかで処理が失敗した場合は notifyAPIResultToSlackを呼び出してSlackに通知を送ります。(詳細は後述)

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からのレスポンスを元に画像名を選択

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から先ほど取得した画像名をキーにアイコンのオブジェクトを取得します。

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を用いて非同期処理にしました。

	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のドキュメントを参考にリクエストを飛ばす関数を作ります。最後に結果をチャネルを介して大元のゴールーチンに渡します。

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も同様にドキュメントを参考にリクエストを飛ばす関数を作ります。OAuthは便利なライブラリがあったのでそちらを用いました。途中突然リクエストが401 Unauthorizedを返してきてハマりましたが、URL Queryの予約語をエスケープすることで解決しました。

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を使います。

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へのリンクを通知します。
スクリーンショット 2019-10-05 12.52.07.png

コードのアップロード

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

感想

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

ソースコード

2
1
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
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?