新しいことにはウェルカム

技術 | 電子工作 | ガジェット | ゲーム のメモ書き

AWS Lambda + Serverless Framework でサクッと Slack Bot (ボット)を作る方法

AWS Lambda と Serverless Framework を使って、簡単なSlack Bot(ボット)を作ってみたので、その作業メモです。

Slack Botの作成は手順が多く手間なのですが、AWS Lambdaを使うとサーバー周りのことを気にしなくて済みます。

また、Lambdaの開発にServerless Frameworkを使うと、面倒なAWSコンソールでの作業が不要になるので、割と手軽に作成できてオススメです。

Slack Botの概要的な説明とGUI作業は別記事にしましたので、Slack Botが全く初めての場合は、先にこちらを読んだ方が分かりやすいかと思います。

www.kwbtblog.com

本記事は主に、実装方法についてのメモになります。

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);
    }
}

AWS Lambda + Serverless Framework でサクッと Slack Bot (ボット)を作る方法

せっかくなので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);
    }
}

AWS Lambda + Serverless Framework でサクッと Slack Bot (ボット)を作る方法

以上、もろもろを踏まえた実装は下記のようになります。

認証に必要な情報は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からメッセージを読み取る際、複数メッセージをまとめて読めるのですが、「batchSize」で読み取り数を1だけにしています。
  • Google・GCPの箇所は翻訳API利用のために使用しているだけなので、削除して構いません。

感想など

以上で、Slack Botの雛形ができました。やっぱり面倒くさいですね。

Lambda・API Gateway・SQSが絡み合うので、AWSのコンソールで同じことやろうとすると大変です。

Serverless Frameworkを使うと、AWSのコンソールは一切触らずに、設定ファイルの記述だけで済んでしまうので本当に便利です。Serverless Frameworkが無かったら確実に挫折してました…。

はじめ、エラー処理と応答時間は気にせず、適当に作っててハマりました…。

問題が発生したらエラーコード返すようにしていると(例外をキャッチしないでほっとくとか)、「エラー発生->Slackリトライ->エラー発生…」のループとなり、最後に止まるということになります。

また、イベントを受けた時に、横着してそこで全ての処理を書いてしまうと、時間内に処理が終わらず、「処理中にタイムオーバー->Slackリトライ->処理中にタイムオーバー…」のループとなり、何度も同じ処理(応答)を行って、最後に止まるということになります。

あと、SQSに送れるメッセージサイズは最大256kbなので、本格運用するのであれば、投稿本文はSQSには送らず、SQSメッセージを処理するLambda内で取得し直すような仕組みにした方がいいですね。

関連カテゴリー記事

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com

www.kwbtblog.com