ブラボーな Slack bot をつくる
はじめに
サッカーワールドカップ、非常に盛り上がりましたね!
日本がまさかドイツとスペインに勝てるとは...
(開幕前は予選敗退すると思っていました、すみませんでした。。)
さて、今回はそんな盛り上がったワールドカップに乗じてブラボーな Slack bot を作成しました。
完成イメージ
いくつか簡単なパターンに合わせてブラボーと返事してくれます。
- メンションのみ
- ブラボーとほめてほしい人を指定
- 1人
- 複数人
- インタビュー
- 「今の心境をお聞かせください」
- 「まずは今の気持ちいかがですか」
前提
以下の環境で作成しました。
- WSL2 (Ubuntu20.04)
- AWS CLI 2.9.8
- SAM CLI version 1.66.0
- Node.js v18.12.1
- yarn v1.22.1
AWS CLI と SAM CLI のインストールはこちらを参考にしてます。
公式ドキュメントを参考にして設定してください。
作成の流れ
大まかに以下の流れで作成していきます。
- hello world を返す API の作成
- Slack チャンネル へ hello world と返信する bot の作成
- ブラボーな botに修正
hello world を返す API の作成
SAM プロジェクト作成
任意のディレクトリで sam init
を実行してください。
そうすると対話式にテンプレートの選択ができます。
今回は API を実行すると「hello world」と返してくれるテンプレートを選択します。
また、最近 TypeScript が使えるようになったらしく、TypeScript を選択してみました。
sam init ログ
$sam init
You can preselect a particular runtime or package type when using the `sam init` experience.
Call `sam init --help` to learn more.
Which template source would you like to use?
1 - AWS Quick Start Templates
2 - Custom Template Location
Choice: 1
Choose an AWS Quick Start application template
1 - Hello World Example
2 - Multi-step workflow
3 - Serverless API
4 - Scheduled task
5 - Standalone function
6 - Data processing
7 - Infrastructure event management
8 - Serverless Connector Hello World Example
9 - Multi-step workflow with Connectors
10 - Lambda EFS example
11 - Machine Learning
Template: 1
Use the most popular runtime and package type? (Python and zip) [y/N]: N
Which runtime would you like to use?
1 - aot.dotnet7 (provided.al2)
2 - dotnet6
3 - dotnet5.0
4 - dotnetcore3.1
5 - go1.x
6 - go (provided.al2)
7 - graalvm.java11 (provided.al2)
8 - graalvm.java17 (provided.al2)
9 - java11
10 - java8.al2
11 - java8
12 - nodejs18.x
13 - nodejs16.x
14 - nodejs14.x
15 - nodejs12.x
16 - python3.9
17 - python3.8
18 - python3.7
19 - ruby2.7
20 - rust (provided.al2)
Runtime: 12
What package type would you like to use?
1 - Zip
2 - Image
Package type: 1
Based on your selections, the only dependency manager available is npm.
We will proceed copying the template using npm.
Select your starter template
1 - Hello World Example
2 - Hello World Example TypeScript
Template: 2
Would you like to enable X-Ray tracing on the function(s) in your application? [y/N]: N
Project name [sam-app]: slack-bot
Cloning from https://github.com/aws/aws-sam-cli-app-templates (process may take a moment)
-----------------------
Generating application:
-----------------------
Name: slack-bot
Runtime: nodejs18.x
Architectures: x86_64
Dependency Manager: npm
Application Template: hello-world-typescript
Output Directory: .
Next steps can be found in the README file at ./slack-bot/README.md
Commands you can use next
=========================
[*] Create pipeline: cd slack-bot && sam pipeline init --bootstrap
[*] Validate SAM template: cd slack-bot && sam validate
[*] Test Function in the Cloud: cd slack-bot && sam sync --stack-name {stack-name} --watch
sam init
実行後は以下のようなディレクトリ構成になっているはずです。
$ tree -a -I ".aws-sam|.git|node_modules|coverage"
.
├── .gitignore
├── README.md
├── events
│ └── event.json
├── hello-world
│ ├── .eslintignore
│ ├── .eslintrc.js
│ ├── .npmignore
│ ├── .prettierrc.js
│ ├── app.ts
│ ├── jest.config.ts
│ ├── package.json
│ ├── tests
│ │ └── unit
│ │ └── test-handler.test.ts
│ └── tsconfig.json
└── template.yaml
ビルド
そのままビルドすると以下のようなエラーが出て失敗します。
$ sam build
(省略)
Build Failed
Error: NodejsNpmEsbuildBuilder:EsbuildBundle - Esbuild Failed: cannot find esbuild
そのため、package.json を修正します。
"devDependencies": {
"@types/aws-lambda": "^8.10.92",
"@types/node": "^18.11.4",
"@typescript-eslint/eslint-plugin": "^5.10.2",
"@typescript-eslint/parser": "^5.10.2",
- "esbuild": "^0.14.14",
"esbuild-jest": "^0.5.0",
"eslint": "^8.8.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"jest": "^29.2.1",
"prettier": "^2.5.1",
"ts-node": "^10.9.1",
"typescript": "^4.8.4"
},
+ "dependencies": {
+ "esbuild": "^0.14.14"
+ }
}
その後 yarn を実行します。
npm を使用する方は適宜読み替えてください。
$ cd hello-world
$ yarn
ルートディレクトリに戻って sam build
します。
sam build ログ
$ cd ../
$ sam build
Your template contains a resource with logical ID "ServerlessRestApi", which is a reserved logical ID in AWS SAM. It could result in unexpected behaviors and is not recommended.
Building codeuri: ~/develop/slack-bot/hello-world runtime: nodejs18.x metadata: {'BuildMethod': 'esbuild', 'BuildProperties': {'Minify': True, 'Target': 'es2020', 'Sourcemap': True, 'EntryPoints': ['app.ts']}} architecture: x86_64 functions: HelloWorldFunction
Running NodejsNpmEsbuildBuilder:CopySource
Running NodejsNpmEsbuildBuilder:NpmInstall
Running NodejsNpmEsbuildBuilder:EsbuildBundle
Sourcemap set without --enable-source-maps, adding --enable-source-maps to function HelloWorldFunction NODE_OPTIONS
You are using source maps, note that this comes with a performance hit! Set Sourcemap to false and remove NODE_OPTIONS: --enable-source-maps to disable source maps.
Build Succeeded
Built Artifacts : .aws-sam/build
Built Template : .aws-sam/build/template.yaml
Commands you can use next
=========================
[*] Validate SAM template: sam validate
[*] Invoke Function: sam local invoke
[*] Test Function in the Cloud: sam sync --stack-name {{stack-name}} --watch
[*] Deploy: sam deploy --guided
デプロイ
以下のような .toml ファイルを用意します。
version = 0.1
[default]
[default.deploy]
[default.deploy.parameters]
stack_name = "slack-bot"
s3_prefix = "slack-bot"
region = "ap-northeast-1"
confirm_changeset = true
capabilities = "CAPABILITY_IAM"
disable_rollback = true
image_repositories = []
sam deploy
でデプロイをします。
--guided
オプションを付けると対話式で必要な項目を入力していくことになります。
初回のデプロイ時はこのオプションを付けると良いでしょう。
sam deploy ログ
$ sam deploy --guided
Configuring SAM deploy
======================
Looking for config file [samconfig.toml] : Found
Reading default arguments : Success
Setting default arguments for 'sam deploy'
=========================================
Stack Name [slack-bot]:
AWS Region [ap-northeast-1]:
#Shows you resources changes to be deployed and require a 'Y' to initiate deploy
Confirm changes before deploy [Y/n]: Y
#SAM needs permission to be able to create roles to connect to the resources in your template
Allow SAM CLI IAM role creation [Y/n]: Y
#Preserves the state of previously provisioned resources when an operation fails
Disable rollback [Y/n]: n
HelloWorldFunction may not have authorization defined, Is this okay? [y/N]: y
Save arguments to configuration file [Y/n]: Y
SAM configuration file [samconfig.toml]:
SAM configuration environment [default]:
Looking for resources needed for deployment:
Managed S3 bucket: aws-sam-cli-managed-default-samclisourcebucket-********
A different default S3 bucket can be set in samconfig.toml
Saved arguments to config file
Running 'sam deploy' for future deployments will use the parameters saved above.
The above parameters can be changed by modifying samconfig.toml
Learn more about samconfig.toml syntax at
https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-config.html
Uploading to slack-bot/b70fecc7502d3de3fe519cdc978318ad 1518 / 1518 (100.00%)
Deploying with following values
===============================
Stack name : slack-bot
Region : ap-northeast-1
Confirm changeset : True
Disable rollback : False
Deployment s3 bucket : aws-sam-cli-managed-default-samclisourcebucket-********
Capabilities : ["CAPABILITY_IAM"]
Parameter overrides : {}
Signing Profiles : {}
Initiating deployment
=====================
Uploading to slack-bot/************.template 1428 / 1428 (100.00%)
Waiting for changeset to be created..
CloudFormation stack changeset
---------------------------------------------------------------------------------------------------------------------------------------------------------
Operation LogicalResourceId ResourceType Replacement
---------------------------------------------------------------------------------------------------------------------------------------------------------
+ Add HelloWorldFunctionHelloWorldPermissi AWS::Lambda::Permission N/A
onProd
+ Add HelloWorldFunctionRole AWS::IAM::Role N/A
+ Add HelloWorldFunction AWS::Lambda::Function N/A
+ Add ServerlessRestApiDeploymentd4d193690 AWS::ApiGateway::Deployment N/A
c
+ Add ServerlessRestApiProdStage AWS::ApiGateway::Stage N/A
+ Add ServerlessRestApi AWS::ApiGateway::RestApi N/A
---------------------------------------------------------------------------------------------------------------------------------------------------------
Changeset created successfully. arn:aws:cloudformation:ap-northeast-1:************:changeSet/samcli-deploy1234567890/********-****-****-****-************
Previewing CloudFormation changeset before deployment
======================================================
Deploy this changeset? [y/N]: y
2022-12-19 22:29:10 - Waiting for stack create/update to complete
CloudFormation events from stack operations (refresh every 0.5 seconds)
---------------------------------------------------------------------------------------------------------------------------------------------------------
ResourceStatus ResourceType LogicalResourceId ResourceStatusReason
---------------------------------------------------------------------------------------------------------------------------------------------------------
CREATE_IN_PROGRESS AWS::CloudFormation::Stack slack-bot User Initiated
CREATE_IN_PROGRESS AWS::IAM::Role HelloWorldFunctionRole -
CREATE_IN_PROGRESS AWS::IAM::Role HelloWorldFunctionRole Resource creation Initiated
CREATE_COMPLETE AWS::IAM::Role HelloWorldFunctionRole -
CREATE_IN_PROGRESS AWS::Lambda::Function HelloWorldFunction -
CREATE_IN_PROGRESS AWS::Lambda::Function HelloWorldFunction Resource creation Initiated
CREATE_COMPLETE AWS::Lambda::Function HelloWorldFunction -
CREATE_IN_PROGRESS AWS::ApiGateway::RestApi ServerlessRestApi -
CREATE_IN_PROGRESS AWS::ApiGateway::RestApi ServerlessRestApi Resource creation Initiated
CREATE_COMPLETE AWS::ApiGateway::RestApi ServerlessRestApi -
CREATE_IN_PROGRESS AWS::Lambda::Permission HelloWorldFunctionHelloWorldPermissi -
onProd
CREATE_IN_PROGRESS AWS::ApiGateway::Deployment ServerlessRestApiDeploymentd4d193690 -
c
CREATE_IN_PROGRESS AWS::Lambda::Permission HelloWorldFunctionHelloWorldPermissi Resource creation Initiated
onProd
CREATE_IN_PROGRESS AWS::ApiGateway::Deployment ServerlessRestApiDeploymentd4d193690 Resource creation Initiated
c
CREATE_COMPLETE AWS::ApiGateway::Deployment ServerlessRestApiDeploymentd4d193690 -
c
CREATE_IN_PROGRESS AWS::ApiGateway::Stage ServerlessRestApiProdStage -
CREATE_IN_PROGRESS AWS::ApiGateway::Stage ServerlessRestApiProdStage Resource creation Initiated
CREATE_COMPLETE AWS::ApiGateway::Stage ServerlessRestApiProdStage -
CREATE_COMPLETE AWS::Lambda::Permission HelloWorldFunctionHelloWorldPermissi -
onProd
CREATE_COMPLETE AWS::CloudFormation::Stack slack-bot -
---------------------------------------------------------------------------------------------------------------------------------------------------------
CloudFormation outputs from deployed stack
----------------------------------------------------------------------------------------------------------------------------------------------------------
Outputs
----------------------------------------------------------------------------------------------------------------------------------------------------------
Key HelloWorldFunctionIamRole
Description Implicit IAM Role created for Hello World function
Value arn:aws:iam::************:role/slack-bot-HelloWorldFunctionRole-PF0VG0WCZJFC
Key HelloWorldApi
Description API Gateway endpoint URL for Prod stage for Hello World function
Value https://************.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/
Key HelloWorldFunction
Description Hello World Lambda Function ARN
Value arn:aws:lambda:ap-northeast-1:************:function:slack-bot-HelloWorldFunction-Qtlbtu7fOTYE
----------------------------------------------------------------------------------------------------------------------------------------------------------
Successfully created/updated stack - slack-bot in ap-northeast-1
デプロイが成功すると、AWS に以下のリソースが作成されます。
さて、これで API が実行できるようになりました。
出力された API の URL をブラウザなり curl なりで実行してみましょう。
Value https://************.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/
hello world と帰ってきたら成功です。
{"message":"hello world"}
なお、できた S3 のバケット名は控えておいてください。[1] 再デプロイ時に使用します。
Deployment s3 bucket : aws-sam-cli-managed-default-samclisourcebucket-********
$ SAM_S3_BUCKET="aws-sam-cli-managed-default-samclisourcebucket-********"
$ sam deploy --s3-bucket "${SAM_S3_BUCKET}"
Slack チャンネル へ hello world と返信する bot の作成
Slack App の Event Subscriptions という機能を用いて、bot にメンションしたら特定の URL にリクエストが飛ぶようにします。
まず bot 自体の API の設定とソースコードの修正を行います。
API の 修正
Slack App は POST メソッドでリクエストを送れるようにしないといけません。
本当は新たにエンドポイントを追加するのが良いでしょうが、
今回は横着をして /hello
を GET メソッドから POST メソッドで受け付けるようにしました。
Resources:
HelloWorldFunction:
Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
Properties:
CodeUri: hello-world/
Handler: app.lambdaHandler
Runtime: nodejs18.x
Architectures:
- x86_64
Events:
HelloWorld:
Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
Properties:
Path: /hello
- Method: get
+ Method: post
Challenge 認証対応
Slack App の Event Subscriptions に設定する URL は Challenge 認証が通らないと使えません。
ただ、対応はいたってシンプルです。
Challenge 認証をすると リクエスト の body から challenge
が取れるので、レスポンスとして challenge
の値を返すように修正するだけです。
詳細は以下を参照にしてください。
また、Challenge 認証 は次項のサンプルで合わせて実装しています。
Slack へメッセージ送信
Slack からリクエストを受けたあと、特定のチャンネルにメッセージを送れるようにします。
まず、Slack Web API を使用するために@slack/web-api
をインストールします。
$ cd hello-world
$ yarn add @slack/web-api
次にソースコードを修正します。
以下は chat.postMessage
を使用して 特定のチャンネルへメッセージを送信するサンプルです。
token (SLACK_TOKEN
) と channnel (SLACK_CHANNEL
) を Lambda の環境変数に手で設定しておく前提でコーディングしています。[2]
サンプル
import { ChatPostMessageResponse, WebClient } from "@slack/web-api";
const token = process.env['SLACK_TOKEN'] || '';
const defaultChannel = process.env['SLACK_CHANNEL'] || '';
type PostMessageRequest = {
text: string,
channel?: string,
};
const postMessage = async ({ text, channel = defaultChannel }: PostMessageRequest): Promise<ChatPostMessageResponse> =>
await new WebClient(token).chat.postMessage({ channel, text });
export { postMessage };
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { postMessage } from './slack/message';
export const lambdaHandler = async (event: APIGatewayProxyEvent) => {
try {
const body = JSON.parse(event.body || '{}');
if (isChallenge(body)) {
return {
statusCode: 200,
body: JSON.stringify({
challenge: body.challenge,
}),
};
}
const response = await postMessage({ text: 'hello world' });
return {
statusCode: 200,
body: JSON.stringify({
message: response.message,
}),
};
} catch (err) {
//...
}
};
const isChallenge = ({ challenge }: { challenge: string }) => !!challenge;
修正が終わったら再デプロイをします。
$ SAM_S3_BUCKET="aws-sam-cli-managed-default-samclisourcebucket-********"
$ sam deploy --s3-bucket "${SAM_S3_BUCKET}"
Slack App の設定
bot の修正 および再デプロイが終わったので、今度は Slack 側の設定をしていきます。
大まかに以下が必要となります。
- Slack App 作成
- App の設定 (アイコンや名前等)
- 権限の設定
app_mentions:read
chat:write
chat:write.public
- インストール
- Event Subscriptions の有効化
- Slack App の 設定画面から API の URL を設定して Verified になることを確認
- 任意のチャンネルに Slack App を追加
細かい手順はこちらの記事に非常によくまとまっています。参考にしてください。
ブラボーな botに修正
これまでは hello world しか返さない bot でしたが、ここからもう少し手を加えます。
- リクエストからユーザーが bot に送ったメッセージを抽出する
- メッセージに応じて返答を変える
まず、ユーザーが bot に送ったメッセージを抽出しましょう。
メッセージの抽出
これが厄介で、今回もっともハマったところでした。
大きく2点ポイントがあります。
-
ネストの深さ
以下はリクエストの body になります。非常にネストが深いことがお分かりいただけるかと思います。body
{ "token": "************", "team_id": "************", "api_app_id": "************", "event": { "client_msg_id": "****************************", "type": "app_mention", "text": "<@************> abc\\u3042\\u3044\\u3046\\u3048\\u304a123", "user": "************", "ts": "1671488956.835939", "blocks": [ { "type": "rich_text", "block_id": "=nT", "elements": [ { "type": "rich_text_section", "elements": [ { "type": "user", "user_id": "************" }, { "type": "text", "text": " abc\\u3042\\u3044\\u3046\\u3048\\u304a123" } ] } ] } ], "team": "************", "channel": "************", "event_ts": "1671488956.835939" }, "type": "event_callback", "event_id": "**************", "event_time": 1671488956, "authorizations": [ { "enterprise_id": null, "team_id": "************", "user_id": "************", "is_bot": true, "is_enterprise_install": false } ], "is_ext_shared_channel": false, "event_context": "************************************************************" }
この JSON からメッセージを抜き出すためには、例えば TypeScript で以下のように書けます。
body .event? .blocks?.[0]? .elements?.[0]? .elements? .map(({ text }: { text: string }) => text) .join('')
map の中で
text
というフィールドのみ抜き出しています。[3] -
ユニコード(¥uxxx形式)のアンエスケープ
メッセージの抽出はこれでOKですが、抜き出した文字列がエスケープされてます。
さらに厄介にさせているのが、半角文字はエスケープされておらず、エスケープされている文字とそうでない文字が混在しているところです。abc\\u3042\\u3044\\u3046\\u3048\\u304a123 // abcあいうえお123
これを自力でまじめに対処するとなると非常に厄介なのですが、先人の知恵をお借りしましょう。
以下を追加します。yarn add unescape-js
ソースコードはこちらです。
https://github.com/iamakulov/unescape-jsまた、使い方は以下のようになります。
unescape サンプル
hello-world/essage_extractor.tsimport unescapeJs from 'unescape-js'; export const extractMessage = (body: any): string => { const message = body.event?.blocks?.[0]?.elements?.[0]?.elements?.map(({ text }: { text: string }) => text).join('') || ''; return unescapeJs(message); };
hello-world/app.tsimport { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { postMessage } from './slack/message'; import { extractMessage } from './message_extractor'; export const lambdaHandler = async (event: APIGatewayProxyEvent): => { try { const body = JSON.parse(event.body || '{}'); if (isChallenge(body)) { return { statusCode: 200, body: JSON.stringify({ challenge: body.challenge, }), }; } const message = extractMessage(body); console.log({message}); const response = await postMessage({ text: message }); return { statusCode: 200, body: JSON.stringify({ message: response.message, }), }; } catch (err) { // ... } }; const isChallenge = ({ challenge }: { challenge: string }) => !!challenge;
メッセージに応じて返答を変える
メッセージに応じたブラボーのバリエーションを用意します。
もうちょっと調整していい感じにできるかなと思いますが、何パターンか用意できたので妥協しました。
ブラボーのパターン分け
export const getBravo = (message: string): string => {
if (!message) {
return `ブラボォォー!!!`;
}
const trimedMessages = message.split(' ').filter(m => !!m);
if (trimedMessages.length >= 2) {
return `${trimedMessages.map(m => m + 'も').join('')}みーんなブラボー、すごすぎる。`;
}
if (interviewPatterns.vsGermany.some(m => m.match(trimedMessages[0]))) {
return `みんなブラボー、ブラボォー、、ブラボォォー!!!`;
}
if (interviewPatterns.vsSpain.some(m => m.match(trimedMessages[0]))) {
return `あれ言っていいっすか?小さい声で言うんで、小さい声で\n\nブラボォォー!!!`;
}
return `${trimedMessages[0]}、ブラボォォー!!!`;
};
const interviewPatterns = {
vsGermany: ['今の心境お聞かせください', 'いまの心境お聞かせください'],
vsSpain: ['まずは今の気持ちいかがですか', 'まずはいまの気持ちいかがですか'],
};
最後に仕上げです。app.ts の postMessage() に渡すテキストを上記で取得したブラボーなメッセージに変えます。
ブラボーなメッセージで返答
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { postMessage } from './slack/message';
import { extractMessage } from './message_extractor';
import { getBravo } from './bravo';
export const lambdaHandler = async (event: APIGatewayProxyEvent) => {
try {
console.log(event);
const body = JSON.parse(event.body || '{}');
if (isChallenge(body)) {
return {
statusCode: 200,
body: JSON.stringify({
challenge: body.challenge,
}),
};
}
const message = extractMessage(body);
console.log({message});
const bravo = getBravo(message);
console.log({bravo});
const response = await postMessage({ text: bravo });
return {
statusCode: 200,
body: JSON.stringify({
message: response.message,
}),
};
} catch (err) {
// ...
}
};
const isChallenge = ({ challenge }: { challenge: string }) => !!challenge;
こちらをデプロイすれば bot の完成です🎉
おわりに
流行り(?)に乗っかった bot をネタとして作成しました。
この bot を通して、Slack からの Event を受け取り、Slack にメッセージを投げる方法が分かりました。
発展させていくと、例えば勤怠管理や辞書登録をする実用的なアプリも作成できるかと思います。
次回はこの bot をもう少しエンハンスしてより実用的なアプリの作成にトライしていきたいです。
Discussion