AWS Lambda と Serverless Framework を使って、簡単なSlack Bot(ボット)を作ってみたので、その作業メモです。
Slack Botの作成は手順が多く手間なのですが、AWS Lambdaを使うとサーバー周りのことを気にしなくて済みます。
また、Lambdaの開発にServerless Frameworkを使うと、面倒なAWSコンソールでの作業が不要になるので、割と手軽に作成できてオススメです。
Slack Botの概要的な説明とGUI作業は別記事にしましたので、Slack Botが全く初めての場合は、先にこちらを読んだ方が分かりやすいかと思います。
本記事は主に、実装方法についてのメモになります。
Slack Bot作成のための要件
Slack Botを作るには、いくつか満たさないといけない要件があるので、その要件と解決方法を中心に説明していきます。
URLとWebサーバーを用意する
Slack Botとはざっくり言うと、Slackのワークスペース上にいるBotユーザーに対して、メッセージ送信などの何らかのアクションを起こすと、Slackから事前に登録していたURLに、そのアクション内容を通知してくれる仕組みです。
つまり、Slack Botとは、BotがSlackでのイベント収集窓口となり、Slackで起こったイベントを、指定URLへWebhookする仕組みです。
URLはBot開発者が用意する必要があるのですが、ドメイン取得やWebサーバーの手配等は結構面倒です。
AWS Lambdaだと、URLはAmazonが発行してくれるし、サーバーの用意も不要です。しかも使っていない時はお金がかからないので、Lambdaを使ってSlack Botを作ることにしました。
URL確認を実装する
SlackにURLを登録する際、そのURLが本当にそのBot用のURLなのか調べるため、SlackがURLに確認メッセージを送ってきます。 そして、そのメッセージに対して正しいレスポンスが返せて初めて、そのURLがSlackに登録されます。
Slackが送ってくるメッセージは下記のようなものです。
{ "token": "xxxx", "challenge": "xxxx", "type": "url_verification" }
サーバー側では、下記のような処理を行います。
- URLは外部にさらされることになるので、まず「token」がBotのものかをチェックして認証します。
- SlackからはURLに対して送ってくるのは、確認のメッセージだけでなく、Botからのイベントも送られてきます。URL確認メッセージは「type」が「url_verification」となるので、チェックします。
- それらが正しければ、「challenge」の値を応答します。
実装は下記のようになります。
index.ts
exports.handlerHttp = async (event: any, context: any) => { try { if (event.body.token !== SLACK_VERIFICATION_TOKEN) { throw new Error('ERROR : invalid verification token'); } // url verify if (event.body.type === 'url_verification') { return { challenge: event.body.challenge }; } console.log('END : do nothing'); } catch (err) { console.error(err); } };
注意:必ずコード200を返す
途中処理がエラーになった場合でも、catch()で受けてthrowはせず、必ずコード200で成功応答するようにしています。
SlackがURLにアクセスしてきて、コード200以外で応答した場合、Slackはアクセスに失敗したと認識し、リトライを行います。これはURL確認メッセージの場合のみでなく、イベント送信の場合も同様です。
そしてリトライが一定回数以上になると、それ以降はSlackからの送信が行われなくなります。
なので、URLにアクセスしてきた後の処理内容に関わらず、Slackには必ずコード200で返答するようにして、Slackからのイベント送信が停止しないようにします。
イベント処理を実装する
無事URL登録が済むと、BotからURLにイベントが送られるようになります。
送られてきた情報がイベントかどうかは「type」が「event_callback」かを見て判断します。
イベントには、どのアプリのイベントかを識別する「api_app_id」が付与されているので、「api_app_id」が正しいものかをチェックします。
実装は下記のようになります。
exports.handlerHttp = async (event: any, context: any) => { try { if (event.body.token !== SLACK_VERIFICATION_TOKEN) { throw new Error('ERROR : invalid verification token'); } // url verify if (event.body.type === 'url_verification') { return { challenge: event.body.challenge }; } if (event.body.api_app_id !== SLACK_APP_ID) { throw new Error('ERROR : invalid app id'); } // slack event if (event.body.type === 'event_callback') { return; } console.log('END : do nothing'); } catch (err) { console.error(err); } };
ここではreturnだけ返して、イベントに対して何もしていません。
どんなメッセージが届こうとも、前述のとおりコード200の応答をするので、リトライは発生せず、Slackから同じイベントが2回以上届くことはないのです。
3秒以内に応答する
Slackはイベントを送って、3秒以内に応答がなかった場合、Slackはアクセスに失敗したと認識し、リトライを行います。そして前述のとおり、リトライが一定以上続くとイベント送信を停止してしまいます。
3秒なんて、ちょっとした処理を書いただけでもすぐに超えてしまいます…。なので、Slackからイベントが届いたら、まずSlackに返答だけしてしまって、本来のやりたかった処理は、返答した後でゆっくりやるようにする必要があります。
その仕組みとして、ここでは、AWSのSimple Queue Service(SQS)を使うことにしました。
AWS Simple Queue Service(SQS)とは?
AWS SQSとは、メッセージを一時的に保存してくれるサービスです。メッセージをSQSに保存して、後からそのメッセージを取りにいくことができます。
SQSとLambdaは簡単に連携して使うことができます。SQSとLambdaの関数を連携させると、Lambdaは定期的にSQSにメッセージが届いていないかチェックし、メッセージが届いていたら、そのメッセージを引数に入れて、連携しているLambda関数を呼びだしてくれます。
Slackからイベントが送られたら、イベント内容をSQSに保存して、まずSlackに応答して関数を終わります。その後、Lambdaは定期的にSQSをチェックしているので、先程保存したイベントを見つけて、Lambdaを呼び出だします。
これにより、Slackには応答して終了しつつ、処理を次のLambdaに非同期的に渡すことができるようになります。
実装は下記のようになります。
exports.handlerHttp = async (event: any, context: any) => { try { if (event.body.token !== SLACK_VERIFICATION_TOKEN) { throw new Error('ERROR : invalid verification token'); } // url verify if (event.body.type === 'url_verification') { return { challenge: event.body.challenge }; } if (event.body.api_app_id !== SLACK_APP_ID) { throw new Error('ERROR : invalidate app id'); } // slack event // send sqs and response if (event.body.type === 'event_callback') { const params = { QueueUrl: AWS_SQS_URL, MessageBody: JSON.stringify(event.body) }; return await AWS_SQS.sendMessage(params).promise(); } console.log('END : do nothing'); } catch (err) { console.error(err); } } exports.handlerSqs = async (event: any, context: any) => { try { const sqs_msg = JSON.parse(event.Records[0].body); return; } catch (err) { console.error(err); } };
イベント内容をSQSに送ってそのままコード200で終了しています。
そうすると、SQSからLambda関数「handlerSqs()」が呼び出されるようになります。ここでは何もせず、returnを返して正常終了させています。
SQSから呼び出されたLambda関数が正常終了した場合は、SQSからメッセージが削除されるので、同じメッセージが何度も届くことはありません。
イベントに応答する
以上で、Slack Botに最低限必要な要件は満たせました。
後はSQSから呼び出されたLambda(handlerSqs())の中で、好きな処理を書くだけです。
Slackに対して返信等するのであれば、Slack APIを使って行います。Botからの返信っぽく見せるのならば、Botユーザーのtokenを使ってSlack APIを呼び出すとそれっぽくなります。
例えば、Botにメッセージを送って、Botからオウム返しさせるのは下記のようになります。
async function postMessage(msg: any) { const event = msg.event; if (!event.bot_id && event.text && event.channel_type === 'im') { const url = `https://slack.com/api/chat.postMessage`; await axios.request({ headers: { authorization: `Bearer ${SLACK_BOT_TOKEN}` }, url, method: 'POST', data: { channel: event.channel, text: event.text, as_user: true } }); } } exports.handlerSqs = async (event: any, context: any) => { try { const sqs_msg = JSON.parse(event.Records[0].body); return await postMessage(sqs_msg); } catch (err) { console.error(err); } }
せっかくなのでBotアプリっぽく、Googleの翻訳APIを使って、話しかけると英語に翻訳して返事するようにしてみました。
async function translateMessage(msg: any) { const event = msg.event; if (!event.bot_id && event.text && event.channel_type === 'im') { // translate into english const [translation] = await GCP_TRANSLATE.translate(event.text, 'en'); const url = `https://slack.com/api/chat.postMessage`; await axios.request({ headers: { authorization: `Bearer ${SLACK_BOT_TOKEN}` }, url, method: 'POST', data: { channel: event.channel, text: translation, as_user: true } }); } } exports.handlerSqs = async (event: any, context: any) => { try { const sqs_msg = JSON.parse(event.Records[0].body); return await translateMessage(sqs_msg); } catch (err) { console.error(err); } }
以上、もろもろを踏まえた実装は下記のようになります。
認証に必要な情報はLambdaの環境変数にセットして渡します。
index.ts
const SLACK_VERIFICATION_TOKEN = process.env.SLACK_VERIFICATION_TOKEN; const SLACK_APP_ID = process.env.SLACK_APP_ID; const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN; const SLACK_USER_TOKEN = process.env.SLACK_USER_TOKEN; const GCP_PROJECT_ID = process.env.GCP_PROJECT_ID; const AWS_SQS_URL = process.env.AWS_SQS_URL; import axios from 'axios'; import * as AWS from 'aws-sdk'; import { Translate } from '@google-cloud/translate'; const AWS_SQS = new AWS.SQS(); const GCP_TRANSLATE = new Translate({ projectId: GCP_PROJECT_ID }); async function translateMessage(msg: any) { const event = msg.event; if (!event.bot_id && event.text && event.channel_type === 'im') { // translate into english const [translation] = await GCP_TRANSLATE.translate(event.text, 'en'); const url = `https://slack.com/api/chat.postMessage`; await axios.request({ headers: { authorization: `Bearer ${SLACK_BOT_TOKEN}` }, url, method: 'POST', data: { channel: event.channel, text: translation, as_user: true } }); } } exports.handlerHttp = async (event: any, context: any) => { try { if (event.body.token !== SLACK_VERIFICATION_TOKEN) { throw new Error('ERROR : invalid verification token'); } // url verify if (event.body.type === 'url_verification') { return { challenge: event.body.challenge }; } if (event.body.api_app_id !== SLACK_APP_ID) { throw new Error('ERROR : invalidate app id'); } // slack event // send sqs and response if (event.body.type === 'event_callback') { const params = { QueueUrl: AWS_SQS_URL, MessageBody: JSON.stringify(event.body) }; return await AWS_SQS.sendMessage(params).promise(); } console.log('END : do nothing'); } catch (err) { console.error(err); } } exports.handlerSqs = async (event: any, context: any) => { try { const sqs_msg = JSON.parse(event.Records[0].body); return await translateMessage(sqs_msg); } catch (err) { console.error(err); } }
Serverless Frameworkを使ってLambdaをデプロイ
プログラムはできたので、後はLambdaにアップして、API Gatewayを設定して、SQSを設定して、Lambdaにマッピングするだけです……。と言いたいのですが、これがかなり面倒です……。
なので、Serverless Frameworkを使うことにしました。
Serverlessの詳しい使い方は省略しますが、今回のLambdaをデプロイする方法は、ざっくりとは下記のとおりです。
npm install -g serverless
でServerless Frameworkをインストール- 「index.js」が置かれているフォルダにServerlessの設定ファイル「serverless.yml」を置く
- Lambdaに設定する環境変数を、ローカルの環境変数に設定する
serverless deploy
でデプロイ
設定ファイルは下記になります。
serverless.yml
service: slack-bot-test provider: name: aws iamManagedPolicies: - 'arn:aws:iam::aws:policy/AmazonSQSFullAccess' endpointType: REGIONAL runtime: nodejs8.10 stage: dev region: ap-northeast-1 deploymentBucket: name: <アップ先S3バケット名> environment: SLACK_VERIFICATION_TOKEN: '${env:SLACK_VERIFICATION_TOKEN}' SLACK_APP_ID: '${env:SLACK_APP_ID}' SLACK_BOT_TOKEN: '${env:SLACK_BOT_TOKEN}' SLACK_USER_TOKEN: '${env:SLACK_USER_TOKEN}' GOOGLE_APPLICATION_CREDENTIALS: '${env:GOOGLE_APPLICATION_CREDENTIALS}' GCP_PROJECT_ID: '${env:GCP_PROJECT_ID}' AWS_SQS_URL: Ref: slackBotTestSqs resources: Resources: slackBotTestSqs: Type: 'AWS::SQS::Queue' Properties: QueueName: 'slackBotTestSqs' package: exclude: - package.json - package-lock.json functions: slack-bot-test-http-function: name: slack-bot-test-http-function handler: index.handlerHttp events: - http: path: slack/bot method: post integration: lambda slack-bot-test-sqs-function: name: slack-bot-test-sqs-function handler: index.handlerSqs events: - sqs: arn: Fn::GetAtt: - slackBotTestSqs - Arn batchSize: 1
Severlessの設定ファイルはシンプルなので、見れば何となくやっていることが分かると思うのですが、簡単に補足説明します。
- Lambdaの環境変数は「env:~」を使って、ローカルの環境変数の値がセットされるようにしています。
- SQSを使うので、Lambdaの実行ポリシーにSQSアクセス権を追加しています。
- SQSはServerlessは自動では作ってくれないので「resources」セクションで明示的に作成しています。
- ちなみに「resources」セクションの記述はAWS CloudFormationのテンプレート書式がそのまま使えます。そしてSQSはCloudFormationを介して作成されます。
- SQSのURLはSQSが作成された時に決まるので、プログラムには環境変数「AWS_SQS_URL」を通じて渡しています。
- SQSのCloudFormationの論理名「slackBotTestSqs」をRefで参照すると、SQSのURLを取得できます。
- SQSからメッセージを読み取る際、複数メッセージをまとめて読めるのですが、「batchSize」で読み取り数を1だけにしています。
- Google・GCPの箇所は翻訳API利用のために使用しているだけなので、削除して構いません。
感想など
以上で、Slack Botの雛形ができました。やっぱり面倒くさいですね。
Lambda・API Gateway・SQSが絡み合うので、AWSのコンソールで同じことやろうとすると大変です。
Serverless Frameworkを使うと、AWSのコンソールは一切触らずに、設定ファイルの記述だけで済んでしまうので本当に便利です。Serverless Frameworkが無かったら確実に挫折してました…。
はじめ、エラー処理と応答時間は気にせず、適当に作っててハマりました…。
問題が発生したらエラーコード返すようにしていると(例外をキャッチしないでほっとくとか)、「エラー発生->Slackリトライ->エラー発生…」のループとなり、最後に止まるということになります。
また、イベントを受けた時に、横着してそこで全ての処理を書いてしまうと、時間内に処理が終わらず、「処理中にタイムオーバー->Slackリトライ->処理中にタイムオーバー…」のループとなり、何度も同じ処理(応答)を行って、最後に止まるということになります。
あと、SQSに送れるメッセージサイズは最大256kbなので、本格運用するのであれば、投稿本文はSQSには送らず、SQSメッセージを処理するLambda内で取得し直すような仕組みにした方がいいですね。