Slack のスラッシュコマンドをサーバーレス構成で作ってみた
タイトルの通り、 Slack のスラッシュコマンドをサーバーレス構成で作ってみたのでまとめてみます。
なぜ作ったのか
最近朝活コミュニティを始めたのですが、モチベーションが上がるような bot を作りたいと思ったからです。考えた結果、 bot というよりはスラッシュコマンドに落ち着きました。
完成したものはこちらになります。 /check-in
で朝活開始、 /check-out
で朝活終了です。
スラッシュコマンド
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 の設定もできます。便利ですね。
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です。簡単ですね。
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
次にメインのスクリプトを組み立てていきます。最低限のレスポンスを返すだけならこんな感じです。
'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": "お疲れ様でした!"
}
)
};
};
今回はスラッシュコマンドを実行した人の名前を呼ぶようにしたり、ランダムで絵文字を出したり、回数によってメッセージを出したりしています。最終的なコードはこんな感じになりました。
'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
}
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 で自動化しました。
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. このアプリのおかげで順調に朝活できていたのですが、最近サボり気味なので新しい機能が必要かもしれません。早起きは難しいですね。
Discussion