本記事はdely Advent Calendar 2018の19日目の記事です。
Qiita : dely Advent Calendar 2018 - Qiita
Adventar : dely Advent Calendar 2018 - Adventar
前日は、弊社でSREをしている井上がkurashiruのデプロイについて記事を書きましたので是非読んでみてください! tech.dely.jp
はじめに
こんにちは。サーバーサイドエンジニア兼、新米slackBot整備士のjoe (@joooee0000) です。 前回の11日目の記事では、くだらないslackBotを作るモチベーションやBotアプリの作成方法について書かせていただきました。
本記事では、InteractiveMessages(InteractiveComponents)を用いた哲学slackBotの実装面について紹介させていただきます。
また、slackでは
をつけることができますが、今回はボタンを使っています。
(こちらのような見た目です)
(深い。。。)
機能としては、
- 数百種類の哲学名言の中から毎朝9:30に1つをつぶやく
- メンバーがボタンを押して哲学を評価する
- 誰がどのように評価をしたかがslackにポストされる
というシンプルなBotです。これだけでも、みんなが参加してくれて盛り上がったりします! 1つの哲学に対してみんなの受け取り方が全く違ったりするので、今やとても興味深いです。
構成図
インタラクティブなBotを構成する要素は二種類あります。
- ボタン付きのメッセージをチャンネルにポストする
- ボタン付きのメッセージへのユーザーのリアクションを受け取り、処理する
こちらの二種それぞれについて書いていきます。
また、slackBotの実装を始めるには、slackのアプリを作成します。アプリの作成に関しては、11日目の記事に書いてあるので、参照してください。
ボタン付きメッセージをポストする
最初に、ボタン付きメッセージを決まった時間にチャンネルにポストする部分について書きます。 組み合わせは、下記のシンプルな構成です。
- Go言語
- DynamoDB
- AWS SAM
- AWS Lambda
- AWS CloudWatch
哲学名言はクロールしてDynamoDBにデータを保持しています。クロールの部分やDynamoDBへのデータ保持について話すと長くなってしまうので、あらかじめデータが入ったDynamoDBが用意されている前提で話します。
1. Go言語でメッセージをポストするコードを書く
コードは、Lambdaで実行する用に書いていきます。 こちらが簡略化したサンプルコードです。長くなりすぎないように、DynamoDBから哲学用語を取得する部分などは省略しています。
package main import ( "errors" "log" "math/rand" "strconv" "syscall" "time" "github.com/aws/aws-lambda-go/lambda" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/dynamodb" "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" "github.com/nlopes/slack" ) type PhilosophicalWord struct { PhilosophicalWordId int `dynamodbav:"philosophical_word_id"` FormedText string `dynamodbav:"formed_text"` PlainText string `dynamodbav:"plain_text"` ScholarName string `dynamodbav:"scholar_name"` } func getRandomPhilosophyWordByDynamoDB() (result *PhilosophicalWord, err error) { // DynamoDBから哲学名言を取得する処理 return } func getIconEmoji(scholarName string) (emojiStr string) { // 絵文字の文字列を学者の名前から取得する処理 return func handleSendPayload(client *slack.Client, channelId string, sendText string, scholarName string) error { actionName := "philosophical_value" attachment := slack.Attachment{ Color: "#42cbf4", CallbackID: "philosophical", Fields: []slack.AttachmentField{ { Title: "Please evaluate the word!! :smirk_cat:", }, }, Actions: []slack.AttachmentAction{ { Name: actionName, Text: "すごく微妙", Type: "button", Style: "danger", Value: "minus2", }, { Name: actionName, Text: "微妙", Type: "button", Style: "danger", Value: "minus1", }, { Name: actionName, Text: "普通", Type: "button", Value: "zero", }, { Name: actionName, Text: "良い", Type: "button", Style: "primary", Value: "plus1", }, { Name: actionName, Text: "すごく良い", Type: "button", Style: "primary", Value: "plus2", }, }, } params := slack.PostMessageParameters{ IconEmoji: getIconEmoji(scholarName), Username: scholarName, } // メッセージの本文を定義する処理 msgOptText := slack.MsgOptionText(sendText, true) // 必須項目を定義する処理 msgOptParams := slack.MsgOptionPostMessageParameters(params) // ボタンを定義する処理 msgOptAttachment := slack.MsgOptionAttachments(attachment) // メッセージ送信処理 if _, _, err := client.PostMessage(channelId, msgOptText, msgOptParams, msgOptAttachment); err != nil { log.Println("Slack PostMessage Error") return err } return nil } func LambdaHandler() error { oauthAccessToken, found := syscall.Getenv("OAUTH_ACCESS_TOKEN") if !found { log.Print("OAuth Access Token Not Found") return errors.New("OAuth Access Token Not Found") } channelId, found := syscall.Getenv("CHANNEL_ID") if !found { log.Print("Channel Id Not Found") return errors.New("Channel Id Not Found") } result, err := getRandomPhilosophyWordByDynamoDB() if err != nil { log.Print("DynamoDB Error") return err } sendText := result.FormedText scholarName := result.ScholarName client := slack.New(oauthAccessToken) handleSendPayload(client, channelId, sendText, scholarName) return nil } func main() { lambda.Start(LambdaHandler()) }
slackのAPI Clientには、nlopes/slackというpackageを使用しています。
今回はメセージをチャンネルに送ることが目的なので、こちらのpackageの中でもchat.goのPostMessage
関数を使ってslackチャンネルにメッセージを送信しています。
nlopes/slack
のPostMessage
は、要件をslackメッセージの要素ごとに分解して設定できるようになっています。
ざっくり言うと、
- PostMessageParameters: メッセージ送信時の必須パラメータ部分(ピンク部分)
- ユーザー名やアイコンの設定など
- MsgOptionText: メッセージ本文(黄色部分)
- MsgOptionAttachments: アタッチメント部分(青部分)
- ボタンの設定
という構成です。
nlopes/slack
では、上記であげたそれぞれの要素が構造体として定義されています。PostMessage
の引数は可変引数となっており、必要な型だけを引数として指定することができます。
例えば、ユーザー名やアイコンの設定はデフォルトでよく、ボタンも不必要なメッセージのみを送る場合は、
msgOptText := "適当" client.PostMessage("your-channel-name", msgOptText)
だけでシンプルなメッセージを送信することができます。
また、アタッチメント部分(今回だとボタンを定義している部分)もいくつかの構造体の入れ子になっており、AttachmentField(アタッチメント部分に記載できるテキストなど)やAttachmentAction(ボタンやプルダウンメニューなどのアクションの具体的な内容を指定するところ)を指定できるようになっています。ここら辺の値をやりたいことに対して柔軟に変えることでカスタマイズしていきます。
ここに書いたこと以外にも、nlopes/slack
をつかって様々なことができるので、詳しくはコードを読むか、docsを参照してください。
2. AWS SAMで定期実行するLambdaを構築する
AWS SAMの設定ファイルを作成する
AWS SAMとは、サーバーレスアプリケーションモデルの略で、AWSでサーバーレスアプリケーションを構築するために使用することができるオープンソースフレームワークです。テンプレートに必要な情報を記入することで、サーバーレスアプリケーションの構築を手軽に行うことができます。(本当に手軽にできます)
下記のサンプルは、
- Lambdaを動かすためのIAM Roleの作成
- 毎日朝9:30に稼働するメッセージポスト用Lambdaの設定(1で作成したLambdaコード)
が記述されています。 哲学Botは、DynamoDBに哲学用語をためているため、DynamoDBへのアクセス権限もつけています。
また、ソースコードに載せられないセキュアな情報は環境変数にしてLambdaから呼び出すようにしています。
Systems Manager パラメータを使って設定した変数をLambdaのコード内から呼び出せるように、Parametersという項目を指定します。
今回は、
- チャンネルID (slackのチャンネル名)
- OAuth Access Token (slackのAPIを呼び出すためのトークン)
をパラメータ化して環境変数として呼び出せるようにしています。 Systems Manager パラメータの設定の仕方は、こちらを参照してください。
# template.yml AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: Create Lambda function by using AWS SAM. Parameters: ChannelId: Type: AWS::SSM::Parameter::Value<String> Default: /philosophy_bot/channel_id OauthAccessToken: Type: AWS::SSM::Parameter::Value<String> Default: /philosophy_bot/oauth_access_token Resources: LambdaIamRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: "sts:AssumeRole" Policies: - PolicyName: "philosophy-slack-bot" PolicyDocument: Version: "2012-10-17" Statement: - Effect: "Allow" Action: "dynamodb:*" Resource: "*" - Effect: "Allow" Action: "cloudwatch:*" Resource: "*" PhilosophySlackBot: Type: AWS::Serverless::Function Properties: Handler: philosophy-slack-bot Runtime: go1.x Role: !GetAtt LambdaIamRole.Arn Environment: Variables: CHANNEL_ID: !Ref ChannelId OAUTH_ACCESS_TOKEN: !Ref OauthAccessToken CodeUri: build Description: 'Post philosophical word to slack' Timeout: 30 Events: Timer: Type: Schedule Properties: Schedule: cron(30 0 * * ? *) # JST 09:30
デプロイする
デプロイするにあたって、下記を済ませておく必要があります。
- IAM Roleの設定
- こちらで言う所のIAM Roleの設定は、AWS SAMを動かすための権限付与
- (
正しく解説できる自信がな記事が長くなりすぎるため詳細の説明を省略)
- AWS CLIのセットアップ
先ほど作成したAWS SAMのtemplate.ymlを使って、aws-cliのコマンドでデプロイすることができます。
# deploy.sh #!/usr/bin/env bash cd ./slack_bot GOARCH=amd64 GOOS=linux go build -o ../build/philosophy-slack-bot cd ../ zip build/philosophy-slack-bot.zip build/philosophy-slack-bot aws cloudformation package --profile yourprofile \ --template-file template.yml \ --s3-bucket serverless \ --s3-prefix philosophy-slack-bot \ --output-template-file .template.yml aws cloudformation deploy --profile yourprofile \ --template-file .template.yml \ --stack-name philosophy-slack-bot \ --capabilities CAPABILITY_IAM
こちらのスクリプトでは、前半の部分でGoのコードのデプロイパッケージ(コードと依存関係で構成される .zip ファイル)を作成しています。
windowsにおけるGoのデプロイパッケージの作り方が少し違うようなので、デプロイを実行するOSによって書き方を変える必要があります。こちらのサンプルコードは、MacOSで作成する場合のサンプルとなっています。詳しくは、こちらをご覧ください。
また、ここで紹介しているサンプルでは、下記のようなファイルの階層を想定しています。
. ├── build ├── deploy.sh ├── slack_bot │ └── main.go └── template.yml
これで、deploy.shを実行するだけでLambdaが指定したevent通りの時間に定期実行されるようになります。
ここまでで、メッセージの見た目はslackチャンネルにポストできるようになりました!!
(この時点では、ボタンを押してもなにも起こらない)
ボタン付きメッセージへのユーザーのリアクションを受け取る
ボタンをおしたらアクションが起こるようにしていきます。
1. Go言語でユーザーのリアクション情報を受け取るコードを書く
ユーザーがボタンを押すと、設定したURLにPOSTリクエストが届きます。 なので、常にリクエストを待ち受けるAPIにしておく必要があります。
構成は、下記のようなシンプルなものです。
- Go言語
- DynamoDB
- AWS SAM
- AWS Lambda
- AWS API Gateway
最初に、API Gatewayにslackからリクエストがきた時のLambdaの処理を書いていきます。 処理は、下記のような手順で行います。
- callbackレスポンスをParseする
- VerificationTokenをチェックする
- 結果を確認し、所望の処理をする
- リクエストのレスポンスとして、callbackレスポンスの中のoriginal_messageと同じ形式のレスポンスを返す
1. callbackレスポンスをParseする
リクエストをJSON形式にParseすると、このような形式のレスポンスが返ってきます。
返ってきたレスポンスを、nlopes/slack
packageが定義してくれている slack.InteractionCallback
型にマッピングします。
{ "type":"interactive_message", "actions":[ { "name":"philosophical_value", "type":"button", "value":"plus1" } ], "callback_id":"philosophical", "team":{ "id":"xxx", "domain":"xxx" }, "channel":{ "id":"xxx", "name":"xxx" }, "user":{ "id":"xxx", "name":"joe" }, "action_ts":"1544247183.026560", "message_ts":"1544247178.002000", "attachment_id":"1", "token":"DUMMYDUMMYDUMMY", # VerificationToken "is_app_unfurl":false, "original_message":{ "type":"message", "subtype":"bot_message", "text":"言い違い、聞き違い、読み違い、書き違いは受ける側の願望を表わしてる。 - ジークムント・フロイト- ", "ts":"1544247178.002000", "username":"ジークムント・フロイト", "icons":{ "emoji":":tetsu_freud:" }, "bot_id":"xxxxxx", "attachments":[ { "callback_id":"philosophical", "id":1, "color":"42cbf4", "fields":[ { "title":"please score the word!! :smirk_cat:", "value":"", "short":false } ], "actions":[ { "id":"1", "name":"philosophical_value", "text":"すごく微妙", "type":"button", "value":"minus2", "style":"danger" }, { "id":"2", "name":"philosophical_value", "text":"微妙", "type":"button", "value":"minus1", "style":"danger" }, { "id":"3", "name":"philosophical_value", "text":"普通", "type":"button", "value":"zero", "style":"" }, { "id":"4", "name":"philosophical_value", "text":"良い", "type":"button", "value":"plus1", "style":"primary" }, { "id":"5", "name":"philosophical_value", "text":"すごく良い", "type":"button", "value":"plus2", "style":"primary" } ] } ] }, "response_url":"https:\/\/hooks.slack.com\/actions\/DUMMYDUMMY\/DUMMYDUMMY\/DUMMYDUMMY", "trigger_id":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }
2. VerificationTokenをチェックする
さきほどParseしたリクエストに、token
というkeyがはいっています。こちらのトークンを使って、不正なリクエストでないかを判定することができます。
こちらのtokenと作成したアプリの管理画面で参照できるVerificationTokenが一致するかをチェックします。
3. 結果を確認し、所望の処理をする
哲学Botは、DynamoDBにcallbackとして送られてきた評価を記録していますが、割愛します。
4. リクエストのレスポンスとして、callbackレスポンスの中のoriginal_messageと同じ形式のレスポンスを返す
ParseしたJSONを見ると、original_messageという項目が返ってきているのがわかります。 こちらのJSONを希望のレスポンスに加工して返すことで、slackに反映させることができます。
また、response_type/replace_originalというkeyをoriginal_messageのJSONに追加して返すことで、下記のような様々な見た目のメッセージの反映ができます。
アクションしたユーザーだけが見れる(Only visible to you)
- response_type: ephemeral (default)
メッセージの上書き
- response_type: in_channel
- replace_original: true
新しいメッセージのポスト
- response_type: in_channel
レスポンスの選択肢に関しては こちらを参照しました。
サンプルコード
本記事で紹介している哲学Botのレスポンス形式は、ユーザーのアクションを検知したら、前のメッセージをうわ書かずに新しいメッセージをチャンネルにポストする方式です。
package main import ( "context" "encoding/json" "net/url" "strings" "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" "github.com/nlopes/slack" ) func handleRequest(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { // 1. callbackレスポンスをParseする str, _ := url.QueryUnescape(request.Body) str = strings.Replace(str, "payload=", "", 1) var message slack.InteractionCallback if err := json.Unmarshal([]byte(str), &message); err != nil { return events.APIGatewayProxyResponse{Body: "json error", StatusCode: 500}, nil } // 2. VerificationTokenをチェックする verificationToken, found := syscall.Getenv("VERIFICATION_TOKEN") if !found { return events.APIGatewayProxyResponse{Body: "NoVerificationTokenError", StatusCode: 500}, nil } if message.Token != verificationToken { return events.APIGatewayProxyResponse{Body: "InvalidVerificationTokenError", StatusCode: 401}, nil } // 3. callbackの中身をみて所望の処理をする var score string value := message.ActionCallback.Actions[0].Value switch value { case "plus2": score = "すごく良い" case "plus1": score = "良い" case "zero": score = "普通" case "minus1": score = "微妙" case "minus2": score = "すごく微妙" default: score = "0" } // 4. リクエストのレスポンスとして、callbackレスポンスと同じ形式のレスポンスを返す userName := message.User.Name resMsg := userName + "さんが" + "「" + score + "」" + "と評価しました" orgMsg := message.OriginalMessage orgMsg.Text = "" // 今回はメッセージを上書きせず、チャンネル全体に投稿する orgMsg.ResponseType = "in_channel" orgMsg.Attachments[0].Color = "#f4426e" // ボタンを空にする orgMsg.Attachments[0].Actions = []slack.AttachmentAction{} // 返したいレスポンスを定義する orgMsg.Attachments[0].Fields = []slack.AttachmentField{ { Title: resMsg, Value: "", Short: false, }, } resJson, err := json.Marshal(&orgMsg) if err != nil { return events.APIGatewayProxyResponse{Body: "JsonError", StatusCode: 500}, nil } return events.APIGatewayProxyResponse{Body: string(resJson), StatusCode: 200}, nil } func main() { lambda.Start(handleRequest) }
2. AWS SAMでslackからのリクエストを受け取るAPI Gatewayを構築する
AWS SAMの設定ファイルを作成する
こちらの設定ファイルは、先ほどのボタン付きメッセージをslackにポストする部分も一緒に含まれています。
前回と同様に、VerificationTokenなどのセキュアな情報はSystems Manager パラメータで設定したものを呼び出しています。
AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: Create Lambda function by using AWS SAM. Parameters: ChannelId: Type: AWS::SSM::Parameter::Value<String> Default: /philosophy_bot/channel_id OauthAccessToken: Type: AWS::SSM::Parameter::Value<String> Default: /philosophy_bot/oauth_access_token VerificationToken: Type: AWS::SSM::Parameter::Value<String> Default: /philosophy_bot/verification_token Resources: LambdaIamRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: "sts:AssumeRole" Policies: - PolicyName: "philosophy-slack-bot" PolicyDocument: Version: "2012-10-17" Statement: - Effect: "Allow" Action: "dynamodb:*" Resource: "*" - Effect: "Allow" Action: "cloudwatch:*" Resource: "*" - Effect: "Allow" Action: - "logs:CreateLogGroup" - "logs:CreateLogStream" - "logs:DescribeLogGroups" - "logs:DescribeLogStreams" - "logs:PutLogEvents" - "logs:GetLogEvents" - "logs:FilterLogEvents" Resource: "*" # ボタンつきメッセージのポスト PhilosophySlackBot: Type: AWS::Serverless::Function Properties: Handler: philosophy-slack-bot Runtime: go1.x Role: !GetAtt LambdaIamRole.Arn Environment: Variables: CHANNEL_ID: !Ref ChannelId OAUTH_ACCESS_TOKEN: !Ref OauthAccessToken CodeUri: build Description: 'Post philosophical word to slack incoming webhooks' Timeout: 30 Events: Timer: Type: Schedule Properties: Schedule: cron(30 0 * * ? *) # JST 09:30 # ボタン付きメッセージのレスポンスAPI PhilosophySlackBotInteractiveApi: Type: AWS::Serverless::Function Properties: Handler: philosophy-slack-bot-interactive-api Runtime: go1.x CodeUri: build Timeout: 300 Role: !GetAtt LambdaIamRole.Arn Environment: Variables: VERIFICATION_TOKEN: !Ref VerificationToken Events: Post: Type: Api Properties: Path: /slack Method: post
Lambdaを定期的に動かすタイプの設定と違うところは、Events
のところをAPI Gatewayの設定に変更するだけです。
Events: Post: Type: Api Properties: Path: /slack Method: post
それだけで、API Gatewayが立ち上がり、pathに指定したエンドポイントにアクセスすることができます。
https://{restapi_id}.execute-api.{region}.amazonaws.com/{stage_name}/
このようなエンドポイントが、/Stage(ステージング用)と/Prod(プロダクション用)それぞれ用意されます。
エンドポイントはデプロイ後、AWSコンソールのAPI Gatewayの画面で確認することができます。
デプロイする
こちらのデプロイスクリプトに関しても、ボタン付きメッセージをポストする部分が含まれています。 説明はボタン付きメッセージの時とほぼ一緒なので割愛しますが、2つのデプロイパッケージをつくって同時にデプロイすることが可能です。
#!/usr/bin/env bash cd ./slack_bot GOARCH=amd64 GOOS=linux go build -o ../build/philosophy-slack-bot cd ../ cd ./slack_interactive_api GOARCH=amd64 GOOS=linux go build -o ../build/philosophy-slack-bot-interactive-api cd ../ zip build/philosophy-slack-bot.zip build/philosophy-slack-bot zip build/philosophy-slack-bot-interactive-api.zip build/philosophy-slack-bot-interactive-api aws cloudformation package --profile yourprofile \ --template-file template.yml \ --s3-bucket serverless \ --s3-prefix philosophy-slack-bot \ --output-template-file .template.yml aws cloudformation deploy --profile youprofile \ --template-file .template.yml \ --stack-name philosophy-slack-bot \ --capabilities CAPABILITY_IAM
想定する階層構造
. ├── build │ ├── philosophy-slack-bot │ ├── philosophy-slack-bot-interactive-api │ ├── philosophy-slack-bot-interactive-api.zip │ └── philosophy-slack-bot.zip ├── deploy.sh ├── slack_bot │ └── main.go ├── slack_interactive_api │ └── main.go └── template.yml
./deploy.shをしていただければAPI Gateway/Lambdaに先ほど書いたコードがデプロイされます。
これで、晴れて、インタラクティブなslackBotが完成しました!!
まとめ
初めてのことが多かったので色々な記事を参考にさせていただきました。 こちらの記事も、少しでもslackBotの運用をするきっかけとなれば幸いです。
今後の展望ですが、ランキング機能をつけるなどの拡張を考えるとRDBの方が使い勝手がいいので、近々Aurora Serverlessに載せ替えたいと思っています!
明日はデザインiOSエンジニアのJohnが「デザインについてエンジニアなりに意識していること」というタイトルで投稿します!お楽しみに!