Go + AWS Lambda で Slack Bot を作る

AWS Lambda で動く Slack Bot を Go 言語で実装してみました。

目次

前提

今回作る Bot

  • Bot に対してメンションをしたら、テキストの内容をそのまま返す Bot を作成します

Slack Bot の作成

  • https://api.slack.com/apps の「Create New App」より、アプリを作成します f:id:linkode-okazaki:20200204180448p:plain
  • 「Building Apps for Slack」→「Add features and functionality」→「Bots」を選択します f:id:linkode-okazaki:20200204180453p:plain
  • 「First, assign a scope to your bot token」→「Review Scopes to Add」より、Bot の権限を設定します
    • ひとまず今回は、Bot へのメンションを読み取る権限と Channel への投稿権限を付与しておきます。 f:id:linkode-okazaki:20200204180429p:plain
  • この時点で、一旦「Install App to Workspace」でワークスペースにアプリをインストールしておきます。 f:id:linkode-okazaki:20200204180501p:plain f:id:linkode-okazaki:20200204180448p:plain

  • インストール後発行される「OAuth & Permissions」→「Tokens for Your Workspace」→「Bot User OAuth Access Token」は後ほど使うため、メモしておきます f:id:linkode-okazaki:20200204180436p:plain

  • また、「Basic Information」→「App Credentials」→「Signing Secret」と「Verification Token」の値もメモしておきます f:id:linkode-okazaki:20200204180505p:plain

Go のプロジェクト作成

  • GOPATH の下に Bot プログラム用のディレクトリを作成し、以下のコマンドを実行してテンプレートを作成します:
$ sls create -t aws-go-dep --name [Bot 名]
  • デフォルトで作成されるファイル構成から以下のように変更します:
.
|-- Gopkg.toml
|-- Makefile
|-- postMessageToSlack
|   `-- main.go
`-- serverless.yml

Makefile の編集

  • ビルド対象のファイル名が変更されたため、Makefile を以下のように変更します
    • ビルドコマンドは Lambda で動かす(= Linux 上でも動作するようにさせる)ため、クロスコンパイルの設定としています
.PHONY: build clean deploy

build:
    dep ensure -v
    env GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o bin/postMessageToSlack postMessageToSlack/main.go
    # 複数のLambdaを用意する場合はここにビルド対象を追加していく

clean:
    rm -rf ./bin ./vendor Gopkg.lock

deploy: clean build
    sls deploy --verbose

serverless.yml の編集

  • serverless.yml も編集します。
    • ビルドの生成物の場所が変わったため、変更します。
    • 今回は、Slack で Bot に対してされたメンションに反応する Bot とするため、メソッドが POST の API Gateway も作成する設定にします
service: sample-bot
frameworkVersion: '>=1.28.0 <2.0.0'

provider:
  name: aws
  runtime: go1.x
  stage: dev
  region: us-east-2

package:
  exclude:
    - ./**
  include:
    - ./bin/**

functions:
  postMessage:
    handler: bin/postMessageToSlack
    events:
      - http:
          path: invoke
          method: post
    environment:
        SLACK_ACCESS_TOKEN: [Slack App 作成時に発行された 「Bot User OAuth Access Token」]
        SLACK_VERIFICATION_TOKEN: [Slack App の Verification Token の値]
        SLACK_SIGNING_SECRET: [Slack App の Signing Secret の値]

main.go の作成

  • Slack からの Event API を受け取って処理をするためのコードを書きます。いくつかのポイントに分けて説明します。
    • 全ソースは下に記載しています
  • Go 向けの Slack 用ライブラリ nlopes/slack を利用します。

ハンドラの定義

  • API Gateway 経由でイベントを受け取りとレスポンスの返却を行うため、下記のように定義します:
func HandleRequest(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error)

イベントのパース

  • Slack から送られてきたイベントをパースします。
  • slackevents.ParseEvent を用いると、リクエスト内容のパースと Token の検証が同時にできます。
body := request.Body

eventsAPIEvent, err := slackevents.ParseEvent(
    json.RawMessage(body),
    slackevents.OptionVerifyToken(
        &slackevents.TokenComparator{
            VerificationToken: os.Getenv("SLACK_VERIFICATION_TOKEN"),
        },
    ),
)

// パースもしくは Token の認証がうまく行かなかった場合のエラー処理
if err != nil {
    log.Printf("ERROR: %s", err)
    return events.APIGatewayProxyResponse{Body: request.Body, StatusCode: http.StatusInternalServerError}, nil
}

url_verification イベントの検証

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
}

各リクエストに対する検証

  • Slack App では、上記の URL 認証の他に、より厳格な検証を実装することが推奨されています。
  • 次の2つが一致するかを確認する処理を実装します
    • 受け取ったリクエストのヘッダに含まれる署名
    • リクエストの内容と App ごとに設定されるトークンを用いて作成した署名
  • nlopes では slack.NewSecretsVerifier メソッドを用いて検証することが可能です。
func convertHeaders(headers map[string]string) http.Header {
    header := http.Header{}
    for key, value := range headers {
        header.Set(key, value)
    }
    return header
}

func verifyRequest(request events.APIGatewayProxyRequest) error {
    body := request.Body
    headers := ConvertHeaders(request.Headers)
    sv, err := slack.NewSecretsVerifier(headers, os.Getenv("SLACK_SIGNING_SECRET"))
    if err != nil {
        return err
    }
    sv.Write([]byte(body))
    return sv.Ensure()
}

リクエストをハンドリングする

  • 検証が完了したリクエストに対して、ハンドリングをしてレスポンスの内容を決めます。
  • イベントやリクエストの分類で条件分岐をしていきます
// CallbackEvent の場合
if eventsAPIEvent.Type == slackevents.CallbackEvent {
    innerEvent := eventsAPIEvent.InnerEvent

    switch event := innerEvent.Data.(type){
    // Bot へのメンションの場合
    case *slackevents.AppMentionEvent:
        // 正規表現を用いて、メッセージ内の「@Bot名 」を取り除く
        rep := regexp.MustCompile(`<@[A-Za-z0-9\.-_]*>\s`)
        text := rep.ReplaceAllString(event.Text, "")
        // 特定のチャンネルへのメッセージの投稿
        api.PostMessage(event.Channel, slack.MsgOptionText(text, false))
    }
}

最終的な main.go

main.go

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "os"
    "regexp"

    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    "github.com/nlopes/slack"
    "github.com/nlopes/slack/slackevents"
)

// HandleRequest Lambda のリクエストをハンドルする
func HandleRequest(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
 
    body := request.Body
    log.Printf("Request Body: %s", body)
    eventsAPIEvent, err := slackevents.ParseEvent(
        json.RawMessage(body),
        slackevents.OptionVerifyToken(
            &slackevents.TokenComparator{
                VerificationToken: os.Getenv("SLACK_VERIFICATION_TOKEN"),
            },
        ),
    )

    if err != nil {
        log.Printf("ERROR: %s", err)
        return events.APIGatewayProxyResponse{Body: request.Body, StatusCode: http.StatusInternalServerError}, nil
    }

    log.Printf("EventType: %s", eventsAPIEvent.Type)
    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
    }

    if err := verifyRequest(request); err != nil {
        log.Fatalf("ERROR: %s", err)
        return events.APIGatewayProxyResponse{Body: request.Body, StatusCode: http.StatusInternalServerError}, nil
    }

    if eventsAPIEvent.Type == slackevents.CallbackEvent {
        innerEvent := eventsAPIEvent.InnerEvent
        log.Printf("Inner Event: %s", innerEvent)
     
        switch event := innerEvent.Data.(type){
        case *slackevents.AppMentionEvent:
            rep := regexp.MustCompile(`<@[A-Za-z0-9\.-_]*>\s`)
            text := rep.ReplaceAllString(event.Text, "")
            api := slack.New(os.Getenv("SLACK_ACCESS_TOKEN"))
            api.PostMessage(event.Channel, slack.MsgOptionText(text, false))
        }
    }

    return events.APIGatewayProxyResponse{
        Body: "OK",
        Headers: map[string]string{
            "Content-Type": "text"},
        StatusCode: 200,
    }, nil
}

func convertHeaders(headers map[string]string) http.Header {
    header := http.Header{}
    for key, value := range headers {
        header.Set(key, value)
    }
    return header
}

func verifyRequest(request events.APIGatewayProxyRequest) error {
    body := request.Body
    headers := convertHeaders(request.Headers)
    sv, err := slack.NewSecretsVerifier(headers, os.Getenv("SLACK_SIGNING_SECRET"))
    if err != nil {
        return err
    }
    sv.Write([]byte(body))
    return sv.Ensure()
}

func main() {
    lambda.Start(HandleRequest)
}

Slack の Event Subscriptions の設定

  • ここまで完了したら、make deploy でデプロイします
  • 完了後表示されるメッセージ内の endpoints: タグの URL を Slack App に設定します
    • 設定する箇所は、「Event Subscriptions」から、「Enable Events」を On にした後の「Request URL」です
    • URL 認証がうまくいくと、「Verified」と表示されます。 f:id:linkode-okazaki:20200204181336p:plain
  • 下の「Subscribe to bot events」→「Add Bot User Event」より、「app_mention」を追加します。
    • これで、Slack App がワークスペース内のイベントを拾ってくれるようになります

動作確認

  • 以下のように、Bot に対してメンションを送ると、オウム返ししてくれます f:id:linkode-okazaki:20200204181503p:plain

まとめ

  • AWS Lambda で動く Slack Bot を Go で実装しました。
  • nlopes/slack ライブラリのおかげで Slack の各種イベント等を扱うのが非常に楽になります。
    • 少し複雑なイベントの処理なども挑戦してみようと思います。

参考資料