バイセル Tech Blog

バイセル Tech Blogは株式会社BuySellTechnologiesのエンジニア達が知見・発見を共有する技術ブログです。

GolangとAWSサービスでSlack botを作成

テクノロジー開発部の村上です。現在はアーキテクチャ周りを担当しています。
部内の今日の掃除担当を知らせるSlack botを作成したので、その紹介をしたいと思います。

利用技術は、

  1. nlopes/slack (GolangのSlack API用ライブラリ)
  2. AWS Lambda
  3. Amazon API Gateway
  4. AWS Systems Manager パラメータストア
  5. Serverless Framework (デプロイ用)
  6. Google Sheets API (マスターデータ参照用)

となりまして、今回は1から4までの紹介となります。

6についてはPythonで杉田さんが同様のことをしているので、 それを見ていただければと思います。

tech.buysell-technologies.com

要件としては、

  1. ダイレクトメッセージとメンションどちらにも対応
    • ダイレクトメッセージは、特定のチャンネルに匿名で定型句を発信するために必要
      • メンションだと誰がメンションしたかわかってしまうため
  2. 連休モードに切り替えられる

がありまして、それぞれに対する対応方法は、

  1. Slash commandではなくBotにする
    • Slash commandではリクエストとレスポンスで同じチャンネルとなるため
  2. AWS Systems Manager パラメータストアを使用する
    • 環境変数を使うと毎回Lambda関数をデプロイする必要があるので、パラメータストアをプログラムで読み込むようにする

です。
色々と紹介したいことは多いのですが、今回は以下に絞って説明したいと思います。

  1. Bot作成の全体の流れ
  2. 検証
  3. リクエストのハンドリング
  4. パラメータストア

nlopes/slackをSlackのAPIを叩くために採用しました。

1. Botのセットアップの流れ

まず最初にBotをどう作成するかを説明します。以下の流れとなります。

  1. Appを作成
  2. Botユーザーを作成
    • App設定ページのBasic Information > Add features and functionality > Bots から作成
  3. Events APIをセットアップ
    • Event Subscriptionsページで、Request URIと、どの種類のイベントを購読するか設定します
      • Botに反応させたいイベントを規定するということです
  4. BotをWorkspaceにインストール

注意点としては、トークンはAppをインストール後に生成されるということです。
このトークンは認証のためにBotのプログラム内で使用するものです。
Appの設定ページで、Install App > Bot User OAuth Access Tokenから得られます。

2. 検証

必要な検証には以下の2種類があります。

  1. エンドポイントとするURIの登録時に必要な検証
  2. 各リクエストに対する検証

1つ目はnlopesのリポジトリに例が掲載されているので、その通りにすれば大丈夫です。(events.go)
リクエストのjsonに格納されているchallengeというキーの値を、text形式でステータスコード200で返すという流れになります。
今回はLambdaとAPI Gatewayを使用しているので、以下のようになります。

if eventsAPIEvent.Type == slackevents.URLVerification {
    var r *slackevents.ChallengeResponse
    err := json.Unmarshal([]byte(body), &r)
    if err != nil {
        return events.APIGatewayProxyResponse{Body: request.Body, StatusCode: http.StatusInternalServerError}, nil
    }
    return events.APIGatewayProxyResponse{
        Body: r.Challenge,
        Headers: map[string]string{
            "Content-Type": "text"},
        StatusCode: 200,
    }, nil
}

2つ目は、Slackの検証方法が今年から変わったらしく、より厳格なものを採用します。
Signing SecretというAppごとに設定されるトークン(regenerateも可能)があるのですが、Siging Secretとリクエストのbodyやtimestampを組み合わせて生成されたものがリクエストのヘッダーに含まれます。それはHMAC-SHA256を通した署名となっているので、各リクエストが漏れても安全となっています。
ですのでBotの実装者がすべきことは、リクエストのヘッダーに含まれる署名と、Signing Secretとリクエストの内容を使って自分で作成した署名が一致するか検証することです。
nlopesでは、NewSecretsVerifierというメソッドが用意されていて、リクエストのヘッダーやbody、そしてSigning Secretを渡していけば検証が出来ます。
以下のような実装になりまして、errorがnilの場合は検証して問題なかったということなのでSlackへのレスポンスを作成していくという流れになります。

func ConvertHeaders(headers map[string]string) http.Header {
    h := http.Header{}
    for key, value := range headers {
        h.Set(key, value)
    }
    return h
}
func Verify(request events.APIGatewayProxyRequest) error {
    body := request.Body
    headers := ConvertHeaders(request.Headers)
    sv, e := slack.NewSecretsVerifier(headers, SigningSecret)
    if e != nil {
        return e
    }
    sv.Write([]byte(body))
    return sv.Ensure()
}

3. リクエストのハンドリング

これまでで検証が終わったということで、実際にリクエストをハンドリングしてレスポンスを作成していきます。
先程も出てきたevents.goにハンドリングの例が掲載されていて、ほぼそのままで大丈夫です。 ハンドリングの流れとしては、

  1. slackevents.ParseEventの戻り値でイベントの大分類を判定する
    • slackevents.URLVerificationslackevents.CallbackEventなどです
  2. slackevents.CallbackEventはBotへのリクエストとなるので、さらにリクエストの種類を小分類で判定する
    • slackevents.AppMentionEventslackevents.MessageEventなどです
  3. slackevents.MessageEventの場合、チャンネルへのメッセージやダイレクトメッセージなどの判定を更に行う

となります。
今回はBotへのメンション付きリクエストとダイレクトメッセージを受け取りたいので、以下のようなコードとなります。

// どこかで初期化するものですが、今回は省略してこちらに記します
api = slack.New(os.Getenv("SLACK_TOKEN"))

if eventsAPIEvent.Type == slackevents.CallbackEvent {
    innerEvent := eventsAPIEvent.InnerEvent
    switch ev := innerEvent.Data.(type) {
    case *slackevents.AppMentionEvent: // Botユーザーへのメンションの場合
        reply := "応答テキスト"
        api.PostMessage(ev.Channel, slack.MsgOptionText(reply, false))
    case *slackevents.MessageEvent:
        if ev.ChannelType == "im" { // ダイレクトメッセージの場合
            text := ev.Text
            if strings.Contains(text, "キーワード1") {
                reply := "キーワード1に対する応答内容"
                api.PostMessage(ev.Channel, slack.MsgOptionText(reply, false))
            } else if strings.Contains(text, "キーワード2") {
                reply := "キーワード2に対する応答内容"
                // 特定のチャンネルに投稿
                api.PostMessage(TARGET_CHANNEL_ID, slack.MsgOptionText(reply, false))
            }
        }
    }
}

注意すべき点が1つありまして、ダイレクトメッセージにはBot自体の投稿も含まれるのでその制御をしないとループしてしまいます。
今回は単純に、投稿にキーワードを含むかで判定しています。Botの投稿はこちらが完全に制御しているので、キーワードを含む恐れがないからです。

また、Lambda部分について少しだけ紹介しますと、func Handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error)のような関数を実装すればOKです。その関数の中身をこれまで説明した内容を使って実装します。
SlackへのレスポンスはAPIで行うので、events.APIGatewayProxyResponseはステータスコード200を返すようにすれば何でも大丈夫です。

4. パラメータストア

最後にパラメータストアについて説明します。
最初に述べましたように、設定を変えるためだけにデプロイし直したくない場合にパラメータストアは便利です。
UIやAWS CLIで簡単に値を変えられるからです。
その値の取得方法は以下のようになりまして、github.com/aws/aws-sdk-goを使用しています。

import (
    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/ssm"
)

func setupParameters() error {
    sess := session.Must(session.NewSession(&aws.Config{
        Region: aws.String("ap-northeast-1")}))
    svc := ssm.New(sess)
    params := &ssm.GetParameterInput{
        Name: aws.String("/cleaning/enabled"),
    }
    enabledResp, err := svc.GetParameter(params)
    if err != nil {
        return err
    }
    Enabled, _ = strconv.ParseBool(aws.StringValue(enabledResp.Parameter.Value))
    params = &ssm.GetParameterInput{
        Name: aws.String("/cleaning/offset"),
    }
    initialOffsetResp, err := svc.GetParameter(params)
    if err != nil {
        return err
    }
    InitialOffset, _ = strconv.Atoi(aws.StringValue(initialOffsetResp.Parameter.Value))
    return nil
}

ストアは階層構造に出来るという特徴があるので、/cleaningにまとめています。
ひとつ注意しておきたいのは、レスポンスがスカラ値のポインタで返ってくるので、aws.StringValueのように変換メソッドを使って値に変換する必要があるという点です。
このことは、github.com/aws/aws-sdk-go/awsのDocに書かれていて、AWSのAPIで共通なようです。
値を変えたい場合は、AWS CLIでaws ssm put-parameter --name '/cleaning/enabled' --type "String" --value 'false' --overwriteのようなコマンドを叩きます。
こうして、パラメータストアを使うことで、EnabledInitialOffsetの値で動作を変えられるようになりました。

まとめ

今回はnlopes/slackの使い方とパラメータストアに絞って説明させていただきました。 業務ではGolangをまだ使用していないのですが、型があるのとコードが読みやすいのでAPIの理解が非常にしやすい印象を受けました。
特にnlopes/slackは、テストコードや例を見るととてもわかりやすいです。
GolangでLambdaを使用する部分はほぼ割愛させていただきましたが、資料の章に非常にわかりやすいページを掲載しましたので、そちらを参考にしていただければと思います。

資料