Wiz テックブログ

Wizは、最新のIoTやICTサービスをお客様に届ける「ITの総合商社」です。

AWSでSlackアプリを作ってみた ~開発環境構築からデプロイまで~

こんにちは、フロントエンドエンジニアの高野です。
最近AWSで簡単なSlackアプリを作成したので、その記録をまとめていきます。

今回話すこと/話さないこと

話すこと

  • Slack側のapp初期設定について
  • 開発環境の構築について
  • Lambdaのフォルダ構成について

話さないこと

  • DynamoDBの詳しいテーブル構成 / 実装方法について
  • Boltの細かい実装について

作りたいもの

Wizでは毎日の業務報告をSlack上で報告しています。 f:id:wiz012:20210706104237p:plain

この報告をGitHubやタスク管理ツールとの連携、 指定期間の業務内容の抽出などをできるようにして楽した〜い! というのが主なモチベーションです。
まずはその足掛かりとして、今回は 毎日の業務報告内容をDBに保存する という簡単なアプリを作成しました。

AWSについては初めての実装だったので、至らない点あるかもしれません...
コメントなどで指摘いただけると嬉しいです。

主な構成

f:id:wiz012:20210706110727p:plain

バックエンドアプリケーションとしては SlackのフレームワークであるBoltを採用しました。サーバーレス!なシンプルな構成です。
CloudWatch Logsを使用して各種ログを保存しています。
また、開発環境/デプロイツールとしてはServerless Frameworkを起用しています。

www.serverless.com

各種コマンドがSlackで叩かれる-> API Gatewayを介してLambdaが走り応答する->場合によってdynamoDBからデータを受け取ったり保存したりする
というのが基本的な流れです。

AWS側の構築

前述したようにServerless Frameworkを用いて構築していくので、serverless.ymlに下記のように記述していきます。

service: antenna

useDotenv: true
frameworkVersion: '2'
custom:
  dynamodb:
    start:
      port: 4000
      inMemory: true
      migrate: true
      seed: true
    seed:
      development:
        sources:
          - table: ${env:INITIAL_TABLE}
            sources: [./migrations/firstTable.json]

plugins:
  - serverless-offline
  - serverless-dynamodb-local
  - serverless-dotenv-plugin

provider:
  name: aws
  runtime: nodejs12.x
  region: ap-northeast-1
  lambdaHashingVersion: 20201221
  environment:
    SLACK_SIGNING_SECRET: ${env:SLACK_SIGNING_SECRET}
    SLACK_BOT_TOKEN: ${env:SLACK_BOT_TOKEN}
    INITIAL_TABLE: ${env:INITIAL_TABLE}
    ROOM_NAME: ${env:ROOM_NAME}

functions:
  app:
    handler: handler.app
    events:
      - http:
          method: post
          path: /slack/events
    role: boltLambdaRole

resources:
  Resources:
    usersTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ${env:INITIAL_TABLE}
        AttributeDefinitions:
          - AttributeName: UserId
            AttributeType: S
          - AttributeName: SortKey
            AttributeType: N
        KeySchema:
          - AttributeName: UserId
            KeyType: HASH
          - AttributeName: SortKey
            KeyType: RANGE
        BillingMode: PAY_PER_REQUEST

    boltLambdaRole:
      Type: AWS::IAM::Role
      Properties:
        Path: /my/cust/path/
        RoleName: boltLambdaRole
        AssumeRolePolicyDocument:
          Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Principal:
                Service:
                  - lambda.amazonaws.com
              Action: sts:AssumeRole
        Policies:
          - PolicyName: lambda
            PolicyDocument:
              Version: '2012-10-17'
              Statement:
                - Effect: Allow
                  Action:
                    - logs:*
                    - dynamodb:*
                  Resource:
                    - arn:aws:logs:*:*:*
                    - arn:aws:dynamodb:*:*:*

※簡単のために、ポリシーを一部簡略化しています。

プラグインとして、以下の三つを使用しています。

plugins:
  - serverless-offline
  - serverless-dynamodb-local
  - serverless-dotenv-plugin

plugin / 開発環境について

serverless-offline
こちらは、ローカルマシン上ででlambdaのエミュレートを実行 できるようにしてくれるありがたいpluginです。
デプロイや実行で課金されずにテストをすることができます。

github.com

serverless-dynamodb-local
serverless-offline はLambdaのみのエミュレートなので、dynamoDBを動かす場合はこのままではローカルテストできません。
なので、また別でserverless-dynamodb-localというプラグインを入れました。

www.serverless.com

serverless-dotenv-plugin
こちらのプラグインはenvファイルをserverless上で読み込むためのプラグインです。

www.serverless.com

また、SlackでAPIを叩くにはlocalhostは当然使用できないので、
ローカルのポートを外部公開するためにngrokを使用します。

ngrok.com

ダウンロード後、

$ ngrok http 3000

と叩けば、 ローカルの3000番ポートを外部公開してくれます。

f:id:wiz012:20210706130339p:plain

上記だと、localhost:3000の内容が伏せている部分(https://xxxxxxxxxx.ngrok.io) として外部公開されているということになります。 少し複雑になってきたので図式化します。

f:id:wiz012:20210706132216p:plain

Slack側で必要な設定

Slack側ではまず、開発者専用のページを開きアプリを登録する必要があります。

api.slack.com

f:id:wiz012:20210706134655p:plain

まずはBoltの認証部分で必要な

  • Signing Secret
  • Bot Token

の二つを探し出します。

Signing Secretは先程のappページのトップをスクロールしていくと見つけることができます。 f:id:wiz012:20210706135152p:plain

Showを押すと表示されるのでこちらをメモしておきます。

また、Bot Tokenは左サイドメニューの「OAuth & Permissions」を選ぶと 「Bot User OAuth Token」として表示されます。
こちらもメモしておきましょう。 f:id:wiz012:20210706135617p:plain

また、Slack側に先程ngrokで発行したURLを登録していきます。
登録箇所は主に3つあります。

  • Event Subscriptions (必須)
  • Interactivity & Shortcuts (応答が必要な場合必要)
  • Slash Commands(スラッシュコマンドを使う場合必要)

Event SubscriptionsではChallenge認証というものが行われ、これがパスされればURLが有効とみなされます。
Boltを使っていた場合、勝手にいい感じでやってくれるのであまり意識する必要はありません。 また、serverless + ngrokを使う場合は、 ngrok発行のURL + /ステージ名/slack/events となります。
ngrok発行以降のURLは、serverless offlineを実行したシェルから確認することができます。
以下の画像の赤枠、緑色の部分です。

f:id:wiz012:20210706140912p:plain

f:id:wiz012:20210706140510p:plain

Interactivity & Shortcutsでは、何かインタラクティブな動作を実行させる際に必要です。
例えばmodalによるフォーム送信、ボタンのアクション、セレクトメニューなどです。
slack側からAPI側に通信するのを許可します。
右上のトグルをOnにした後、URLを登録します。

f:id:wiz012:20210706141409p:plain

Slash Commands では、各種コマンドとその実行先のURLを登録します。
スラッシュコマンドというのは、Slackのメッセージ上から /foo で実行できるコマンドのことです。

f:id:wiz012:20210706141630p:plain

また、Slackアプリのスコープ(権限)を登録していく必要があります。 OAuth & Permission を下にスクロールしていくとScopesというセッションがあるので、必要なスコープを追加していきます。
実行する目的から自動でスコープを選択したい場合は、Event Subscriptionsの Subscribe to bot eventsセクションから選択することも可能です。

Lambdaの構成

普段慣れ親しんでいるといる理由からNode.jsにて記述しました。
特に何もしないままServerlessでアップロードすると、不要なnode_modulesなども含めてZIP化されてしまいます。
Lambdaは一度のアップロードで50MBを超える場合直接のアップロードができなくなってしまうので、なるべくファイルサイズを抑えたいところです。
なので今回はWebpackを入れてバンドルするようにしました。

.
├── build                                   // Serverlessでアップロードするフォルダ
├── node_modules
├── package-lock.json
├── package.json
├── readme.md
├── src                                      // 開発用フォルダ
 │   ├── boltFunc
 │   ├── dynamo.js                     //  ラムダ関数
 │   └── handler.js                     //  ラムダ関数
└── webpack.config.js

webpack.config.jsはこのように記述しています。

const path = require('path')
const TerserPlugin = require('terser-webpack-plugin')

module.exports = {
  mode: 'production',
  entry: {
    handler: './src/handler.js',
    dynamo: './src/dynamo.js',
  },
  target: 'node',
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'build'),
    libraryTarget: 'commonjs2',
  },
  optimization: {
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          mangle: false,
        },
      }),
    ],
  },
}

ポイントとしては、 TerserPluginを使用してWebpackの圧縮方法をカスタマイズし、 mangleをfalseにしています。
これは、API GatewayからLambdaを指定する際に関数名が変わってしまうことを防ぐためです。
例えば、handler.appを指定した場合、Webpackによってhandler.aなどに関数名をリネームされると、エラーになってしまいます。
また、アウトプット形式をlibraryTarget: 'commonjs2' にするのを忘れないようにします。

handler.js(ラムダ関数のトップ)では下記のように、Slack Boltで登録するイベントをまとめて実行しています。

'use strict'

// ------------------------
// dynamoDb
// ------------------------
const AWS = require('aws-sdk')
AWS.config.update({
  region: 'ap-northeast-1',
})
const docClient = new AWS.DynamoDB.DocumentClient()

// ------------------------
// Bolt App Initialization
// ------------------------
const { App, ExpressReceiver } = require('@slack/bolt')
const expressReceiver = new ExpressReceiver({
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  processBeforeResponse: true,
})
const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  receiver: expressReceiver,
})

// ------------------------
// Application Logic
// ------------------------

// bolt
const funcRegister = require('./boltFunc/slash/register.js').default
const funcDaily = require('./boltFunc/slash/daily').default
const funcWeekly = require('./boltFunc/slash/weekly').default
const funcMonthly = require('./boltFunc/slash/monthly').default
const funcEditUser = require('./boltFunc/slash/edituser').default
const funcEditReport = require('./boltFunc/slash/editreport').default

funcRegister(app, docClient)
funcDaily(app, docClient)
funcWeekly(app, docClient)
funcMonthly(app, docClient)
funcEditUser(app, docClient)
funcEditReport(app, docClient)

app.message('goodbye', async ({ message, say }) => {
  // say() sends a message to the channel where the event was triggered
  await say(`See ya later, <@${message.user}> :wave:`)
})

// ------------------------
// AWS Lambda handler
// ------------------------
const awsServerlessExpress = require('@vendia/serverless-express')
module.exports.app = awsServerlessExpress({
  app: expressReceiver.app,
})

また、実際にSlackのUIを構築する際は公式のBlock Kit Builderが非常に重宝しました。(閲覧にはログインが必要です。)

app.slack.com

ぽちぽち必要なコンポーネントを選択していくと、右にblockのコードが出力されます。
これをいい感じにリファレンスを見ながら加工しつつ、自分のコードに落とし込んでいきました。
一例として一つのslash commandのファイルを紹介しておきます。

require('date-utils')

const { checkRegist } = require('../validate/checkRegist') // DBにユーザー登録があるかをチェックする関数
const { putReport } = require('../db/report/putReport')  // DBに業務報告を保存する関数

exports.default = (app, docClient) => {
  app.command('/aq-edituser', async ({ ack, body, client }) => {
    await ack()

    const query = await checkRegist(body, client, docClient, true)
    if (!Object.keys(query).length) return

    try {
      const result = await client.views.open({
        trigger_id: body.trigger_id,
        view: {
          type: 'modal',
          callback_id: 'aq-edituser',
          title: {
            type: 'plain_text',
            text: 'ユーザデータの編集',
          },
          submit: {
            type: 'plain_text',
            text: '編集📝',
            emoji: true,
          },
          blocks: [
            {
              type: 'input',
              block_id: 'type',
              element: {
                type: 'static_select',
                placeholder: {
                  type: 'plain_text',
                  text: 'ユウシャ',
                  emoji: true,
                },
                initial_option: {
                  text: {
                    type: 'plain_text',
                    text: query.Item.JobType,
                    emoji: true,
                  },
                  value: query.Item.JobType,
                },
                options: [
                  {
                    text: {
                      type: 'plain_text',
                      text: 'フロントエンドエンジニア',
                      emoji: true,
                    },
                    value: 'フロントエンドエンジニア',
                  },
                  {
                    text: {
                      type: 'plain_text',
                      text: 'バックエンドエンジニア',
                      emoji: true,
                    },
                    value: 'バックエンドエンジニア',
                  },
                ],
                action_id: 'type',
              },
              label: {
                type: 'plain_text',
                text: '種別',
                emoji: true,
              },
            },
          ],
        },
      })
    } catch (err) {
      console.error(err)
    }
  })

  // モーダル情報のlisten
  app.view('aq-edituser', async ({ ack, body, view, client }) => {
    await ack()
    const values = view.state.values

    const user = body.user
    const typeString = values.type.type.selected_option.text.text

    /**
     * DB保存
     */
    const user_id = user.id
    const user_name = user.name
    const type = values.type.type.selected_option.value
    await editUser(
      {
        user_id,
        user_name,
        type,
      },
      docClient
    )

    /**
     * メッセージ送信
     */
    try {
      await client.chat.postMessage({
        channel: process.env.ROOM_NAME,
        blocks: [
          {
            type: 'section',
            text: {
              type: 'mrkdwn',
              text: `冒険の書を上書きしました📝
              *【名前】*: ${user_name}
              *【種別】*: ${typeString}
              `,
            },
          },
        ],
      })
    } catch (err) {
      console.log(err)
    }
  })
}

動くとこんな感じです。 f:id:wiz012:20210706143142p:plain

デプロイ

serverless.ymlを配置しているフォルダにて、
$ serverless deploy
を実行すればOKです。Serverlessはデプロイがとてもお手軽ですね。
stageを指定したい場合は、$ serverless deploy --stage pro というようにオプションを追加します。
特定のLambdaだけをデプロイする場合は$serverless deploy function -f 関数名 で実行できます。

API GatewayのURLをメモって、先程ngrokのURLを登録したSlack APIのURL認証に登録しなおします。

f:id:wiz012:20210706171022p:plain

これで無事Slack上でアプリを稼働させることができました! f:id:wiz012:20210706171637p:plain

最後に

とても簡単なアプリですが、AWS初挑戦だったので覚えることが沢山あり、苦戦しましたがまた同時にとても楽しかったです。
今後外部サービス連携を強化してもっと実用的なアプリにしていきたいです。
また、Lambdaをモノリスで構成したので、アプリが大きくなってきたら切り分けなども要検討だと思っております。

お決まりの句にはなりますが、Wizではエンジニアを募集中です。

興味のある方は是非覗いてみてください!↓

careers.012grp.co.jp