Slack のスラッシュコマンドをサーバーレス構成で作ってみた

9 min read読了の目安(約8700字

タイトルの通り、 Slack のスラッシュコマンドをサーバーレス構成で作ってみたのでまとめてみます。

なぜ作ったのか

最近朝活コミュニティを始めたのですが、モチベーションが上がるような bot を作りたいと思ったからです。考えた結果、 bot というよりはスラッシュコマンドに落ち着きました。

完成したものはこちらになります。 /check-in で朝活開始、 /check-out で朝活終了です。

https://github.com/yuzoiwasaki/bird-san

スラッシュコマンド

Slack のスラッシュコマンドを作成するには、まず Slack の管理画面から「Create New App」でアプリを作ります。アプリができたら左の一覧から「Slash Commands」を選択、「Create New Command」でスラッシュコマンドを実装していきます。

「Command」に作成したいスラッシュコマンドを「Request URL」に POST 先の URL を指定します。「Short Description」にはスラッシュコマンドの概要を記載します。

バードさん(今回作った Slack アプリ)の場合はこんな感じです。

Slack のスラッシュコマンドを作成するには、リクエストを POST する URL とレスポンスを返すサーバが必要です。

普通にサーバを立ててもいいのですが、サーバーレスを勉強したかったので、今回はサーバーレス構成にしてみました。

サーバーレスで作ってみる

サーバーレスとはサーバの構築・保守を意識せず、サーバ上でプログラムを実行できる仕組みです。代表的なサービスに AWS Lambda があります。

サーバーレス構成を作るには Serverless Framework が便利です。 Serverless Framework は npm パッケージとして提供されているためまずはインストールします。

npm install -g serverless

バージョンが正しく表示されていればインストール完了です。

~ $ serverless --version
Framework Core: 2.28.7
Plugin: 4.4.3
SDK: 2.3.2
Components: 3.7.2

次にプロバイダーをセットアップします。今回は AWS を使うので、 Serverless 用の IAM ユーザを発行し AdministratorAccess の管理ポリシーを与え、アクセスキーとシークレットキーを設定しておきます。

プロバイダーの準備ができたら以下のコマンドでサービスを作成します。

serverless create --template aws-nodejs --name bird-san --path bird-san

serverless.yml

Serverless Framework の設定ファイルが serverless.yml です。ここに Lambda や API Gateway の設定を書いていきます。なんと DynamoDB の設定もできます。便利ですね。

serverless.yml
service: bird-san

frameworkVersion: '2'

plugins:
  - serverless-offline

provider:
  name: aws
  runtime: nodejs12.x
  region: ap-northeast-1
  stage: ${opt:stage, 'development'}
  lambdaHashingVersion: 20201221
  apiGateway:
    shouldStartNameWithService: true
  iamRoleStatements:
    - Effect: Allow
      Action:
        - dynamodb:Query
        - dynamodb:Scan
        - dynamodb:GetItem
        - dynamodb:PutItem
        - dynamodb:UpdateItem
        - dynamodb:DeleteItem
      Resource: 'arn:aws:dynamodb:*:*:table/*'

functions:
  check_in:
    handler: handler.check_in
    timeout: 10
    events:
      - http:
          path: /check-in
          method: post
  check_out:
    handler: handler.check_out
    timeout: 10
    events:
      - http:
          path: /check-out
          method: post

resources:
  Resources:
    DynamoDbTable:
      Type: 'AWS::DynamoDB::Table'
      Properties:
        AttributeDefinitions:
          -
            AttributeName: userId
            AttributeType: S
          -
            AttributeName: activityDate
            AttributeType: S
        KeySchema:
          -
            AttributeName: userId
            KeyType: HASH
          -
            AttributeName: activityDate
            KeyType: RANGE
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1
        TableName: Activity

serverless-offline というプラグインを入れておくと、オフラインでも確認ができ便利です。今回は DynamoDB を使ってデータの保存までやっているのでやや長くなっていますが、 Lambda と API Gateway だけなら本当にシンプルに作成することができます。

DynamoDB を使わない場合はこんな感じです。基本的には provider にプロバイダー情報を記載し、 functions にエンドポイントを記載すればOKです。簡単ですね。

serverless.yml
service: bird-san

frameworkVersion: '2'

provider:
  name: aws
  runtime: nodejs12.x
  region: ap-northeast-1
  stage: ${opt:stage, 'development'}
  lambdaHashingVersion: 20201221
  apiGateway:
    shouldStartNameWithService: true

functions:
  check_in:
    handler: handler.check_in
    timeout: 10
    events:
      - http:
          path: /check-in
          method: post
  check_out:
    handler: handler.check_out
    timeout: 10
    events:
      - http:
          path: /check-out
          method: post

handler.js

次にメインのスクリプトを組み立てていきます。最低限のレスポンスを返すだけならこんな感じです。

hander.js
'use strict';

module.exports.check_in = async (event) => {
  return {
    statusCode: 200,
    body: JSON.stringify(
      {
        "response_type": "in_channel",
        "text": "おはようございます!"
      }
    )
  };
};

module.exports.check_out = async (event) => {
  return {
    statusCode: 200,
    body: JSON.stringify(
      {
        "response_type": "in_channel",
        "text": "お疲れ様でした!"
      }
    )
  };
};

今回はスラッシュコマンドを実行した人の名前を呼ぶようにしたり、ランダムで絵文字を出したり、回数によってメッセージを出したりしています。最終的なコードはこんな感じになりました。

hander.js
'use strict'

const qs = require('querystring')
const moment = require('moment')
require('moment-timezone')
const { getUserNameById, getCheckOutEmoji } = require('./slack')
const aws = require('aws-sdk')
const docClient = new aws.DynamoDB.DocumentClient({region: 'ap-northeast-1'})

exports.check_in = async (event) => {
  const parsedBody = qs.parse(event.body)
  const userId = parsedBody['user_id']
  const date = getToday()

  var params = {
    TableName: 'Activity',
    Item: {
      userId: userId,
      activityDate: date
    }
  }
  try {
    await docClient.put(params).promise()
  } catch(error) {
    console.log(error)
  }

  var params = {
    TableName: 'Activity',
    KeyConditionExpression: '#key = :val',
    ExpressionAttributeNames: {
      '#key': 'userId'
    },
    ExpressionAttributeValues: {
      ':val': userId
    }
  }
  try {
    var activityLogs =  await docClient.query(params).promise()
  } catch(error) {
    console.log(error)
  }

  const text = createCheckInText(userId, activityLogs)

  return {
    statusCode: 200,
    body: JSON.stringify(
      {
        'response_type': 'in_channel',
        'text': text
      }
    )
  }
}

exports.check_out = async (event) => {
  const parsedBody = qs.parse(event.body)
  const userId = parsedBody['user_id']
  const text = createCheckOutText(userId)

  return {
    statusCode: 200,
    body: JSON.stringify(
      {
        'response_type': 'in_channel',
        'text': text
      }
    )
  }
}

function getGreetingMessage() {
  moment.tz.setDefault('Asia/Tokyo')
  const hour = Number(moment().format('HH'))

  let message

  if (hour >= 4 && hour < 12) {
    message = 'おはようございます!:hatched_chick:'
  } else if (hour >= 12 && hour < 18) {
    message = 'こんにちは!:rooster:'
  } else {
    message = 'こんばんは!:owl:'
  }

  return message
}

function getToday() {
  moment.tz.setDefault('Asia/Tokyo')
  return moment().format('YYYY-MM-DD')
}

function createCheckInText(userId, activityLogs) {
  const userName = getUserNameById(userId)
  const greetingMessage = getGreetingMessage()
  const activityCount = activityLogs['Count']

  const MEMORIAL_NUMBERS = [
    3,
    5,
    10,
    20,
    30,
    40,
    50,
    60,
    70,
    80,
    90,
    100
  ]

  let text = userName + 'さん、' + greetingMessage

  if (MEMORIAL_NUMBERS.includes(activityCount)) {
    text += 'おめでとうございます!' + activityCount + '日目の朝活です:tada:'
  }

  return text
}

function createCheckOutText(userId) {
  const userName = getUserNameById(userId)
  const emoji = getCheckOutEmoji()
  return userName + 'さん、お疲れ様でした!' + emoji
}
slack.js
const SLACK_USER_MAP = {
  'U01GPV72XQD': 'いわさき'
}

exports.getUserNameById = function getUserNameById(userId) {
  return SLACK_USER_MAP[userId]
}

exports.getCheckOutEmoji = function getCheckOutEmoji() {
  const emojis = [':clock9:', ':tea:', ':coffee:']
  return emojis[Math.floor(Math.random() * emojis.length)]
}

デプロイ

準備ができたら作成したアプリをサーバーレス環境にデプロイします。デプロイ時は以下のコマンドを実行します。 --stage オプションをつけることで、環境ごとにデプロイすることも可能です。

sls deploy

毎回手動でデプロイしてもいいのですが、面倒なので Circle CI で自動化しました。

.circleci/config.yml
version: 2.1

executors:
  nodejs:
    docker:
      - image: circleci/node:12.13.1-stretch

commands:
  setup:
    steps:
      - checkout
      - restore_cache:
          name: Restoring Cache - Yarn
          keys:
            - v1-dependencies-{{ checksum "package.json" }}
      - run:
          command: npm install
      - save_cache:
          paths:
            - node_modules
          key: v1-dependencies-{{ checksum "package.json" }}

jobs:
  deploy:
    executor:
      name: nodejs
    parameters:
      stage:
        type: enum
        enum: [production]
    steps:
      - setup
      - run:
          name: Deploy
          command: npx sls deploy --stage << parameters.stage >>
      - store_artifacts:
          path: .serverless
          destination: serverless

workflows:
  version: 2
  deploy:
    jobs:
      - deploy:
          name: deploy
          stage: production
          filters:
            branches:
              only:
                - main

これで main ブランチが push されるたびに自動的に本番環境にデプロイされるようになります。

さいごに

以上、サーバーレス構成でのスラッシュコマンドの作り方でした。作ってみた感想としては Serverless Framework が想像以上に便利でした。もっと細かく作り込んでいくと痒いところもあるのかもしれませんが、サクッと何かを作りたい時はとても良いと思います。

P.S. このアプリのおかげで順調に朝活できていたのですが、最近サボり気味なので新しい機能が必要かもしれません。早起きは難しいですね。