SlackのサーバーレスBOT – リモートワークの開始と終了宣言をSlackのリアクションで済ませたい
今回は Slack で出勤、退勤を連絡するときにちょっと便利なリアクション投稿BOTをサーバーレスで作りました。背景にフレックスタイム制、リモートワーク可としているクラスメソッドの勤務体系があります。詳しくはこちら。
Slack に出勤・退勤を書き込むけど...ちょっと面倒
私が所属するCX事業本部は、開発をメイン業務に据えるメンバーも多く、またチームでの作業が基本です。拠点、労働時間のばらばらなメンバーが連携するのは思ったよりたいへんです。そこで一部のメンバーが、簡単に出退勤を知らせる目的で、出勤/退勤時、Slackへ投稿する試みを行いました。
しかしすぐに課題が出ます。チャンネルが、出勤・退勤の連絡で埋まってしまい、とても見通しが悪いのです。そこで、Slackに平日投稿のリマインダを設定し、そのスレッドにリプライする形で連絡するようにしました。
/remind #勤怠 to 開始 終了 はこのスレで! at 5:00am every weekday
出退勤と特別な連絡がうまい具合に区別され、とてもよい感じです。ところがこれでもまだ課題がありました。スレッドにリプライすると、その会話に参加したとみなされ、自動でスレッドがフォローされるのです。そうすると、他の人の勤怠報告でスレッドが未読状態になるため、本当にチェックが必要なスレッドと混ざってしまいノイズになります。
結果、多少面倒ですが、出退勤のリプライを行った後に手動でスレッドのフォローをはずす運用としました。ここで面倒であるという課題が残りましたね。今回の話に限らず、あらゆる課題をまず「面倒くさい」というところまで落とし込めばプログラマーの出番です。
Slack Events API によるリアクションの自動リプライBOT
サーバーレスアプリケーションでの解決を試みます。もともと、「リマインダの投稿に出退勤のリアクションを行えばよいのではないか」という案がありました。リアクションするだけなら、スレッドが自動でフォローされることはありません。よい案です。ただリアクションは、誰が行ったものなのかはわかりますが、いつ行ったかまではわかりません。なのでどうにかしてリアクションを行った時間を記録したいと考えました。そこで、特定のスレッドにリアクションがあったら、その内容をBOTが自動でリプライするというアプリケーションを組んでみます。
- Events api の受け口です。イベントが受け取れるかチェックし、特定のイベント(今回は
reactions_added
) をAPIに対して流すよう設定します。 - Data Streams に流れてきたSlackのイベントをホワイトリストでフィルタします。
- 勤怠チャンネルかつ、
USLACKBOT
ユーザーへのリアクションを拾うようにしましす。 - ホワイトリストとなるチャンネルのリストは DynamoDB に保存したものを使います。
- 勤怠チャンネルかつ、
- リアクションの情報から、ユーザー情報を取得します。ユーザーのプロファイル写真を取得するためです。
chat.postMessage
を使ってユーザーがリアクションしたスレッドにリプライします。
Slack Events API を受け取れるようにする
1で用意するAPIは、Slackから送られるテストリクエストをあらかじめ受け取っておく必要があります。Slack api のアプリケーション設定で、Event Subscriptions を開きます。URLを設定する項目があります。
Events APIのドキュメントによると、設定したURLに送られるテストリクエストに対し応答すればよさそうです。
After you've completed typing your URL, we'll dispatch a HTTP POST to your request URL. We'll verify your SSL certificate and we'll send a application/json POST body containing three fields:
{ "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl", "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P", "type": "url_verification" }This event does not require a specific OAuth scope or subscription. You'll automatically receive it whenever configuring an Events API > request URL. ... Responding to the challenge
Once you receive the event, complete the sequence by responding with HTTP 200 and the challenge attribute value.
HTTP 200 OK Content-type: application/json {"challenge":"3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P"} Once URL verification is complete, you'll see a green check mark celebrating your
まずはこの要件を満たすようなAPIを作成・デプロイしましょう。npmツールtsas
をインストールして使います。これは TypeScript の Lambda Function を作成・デプロイするためのツールです。
$ npm install -g tsas $ mkdir attendance-management-server $ cd attendance-management-server $ tsas init What your serverless application name? [attendance-management-server]attendance What your serverless application nameSpace? used for S3 bucket prefix. [ns]gs What your default region? [ap-northeast-1]
serverless application name
: アプリケーション名。Lambda Function や S3バケットのリソース名に利用されます。serverless application nameSpace
: 名前空間。主にS3バケット名などグローバルリソースの競合を避けるために指定します。default region
: デプロイコマンドでデプロイ先となるリージョン名です。東京リージョンを指定します。
初期化が完了するとサンプルアプリケーションが用意されています。これを改変してAPIを作ります。
import 'source-map-support/register'; import * as Console from 'console'; export async function handler(event: Challenge): Promise<ChallengeResponse> { Console.log(event); return Promise.resolve({ challenge: event.challenge }); } interface Challenge { token: string; challenge: string; type: string; } interface ChallengeResponse { challenge: string; }
webpack で新しいエンドポイント用のファイルを作るよう指定します。
module.exports = { mode: 'development', target: 'node', entry: { 'hello-world': path.resolve(__dirname, './src/lambda/handlers/api-gw/api-gw-greeting.ts'), 'event': path.resolve(__dirname, './src/lambda/handlers/api-gw/slack-event.ts'), // 追加 }, ...
これで、デプロイ時、dist
ディレクトリの event
ディレクトリに index
ファイルが作成されます。その後、AWS SAM のテンプレートを修正して、 API Gateway と Lambda Function が追加でデプロイされるようにします。
AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Resources: # Lambda Function の定義 (1) SlackEventLambda: Type: AWS::Serverless::Function Properties: FunctionName: !Sub ${Env}-${AppName}-event Role: !GetAtt SlackIntegrationLambdaRole.Arn Handler: event/index.handler Runtime: nodejs10.x CodeUri: Bucket: !Ref DeployBucketName Key: !Sub ${ChangeSetHash}/dist.zip Timeout: 10 Environment: Variables: ENV: !Ref Env Events: AttendanceApi: Type: Api Properties: Path: /event Method: POST RestApiId: !Ref AttendanceApi # Lambda Function に設定する IAM Role の定義 (2) SlackIntegrationLambdaRole: Type: 'AWS::IAM::Role' Properties: RoleName: !Sub ${Env}-${AppName}-lambda-role ManagedPolicyArns: - 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' Policies: - PolicyName: PermissionToPassAnyRole PolicyDocument: Version: '2012-10-17' Statement: Effect: Allow Action: - iam:PassRole Resource: !Sub arn:aws:iam::${AWS::AccountId}:role/* AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: 'Allow' Principal: Service: - 'lambda.amazonaws.com' Action: - 'sts:AssumeRole' # API の定義 (3) AttendanceApi: Type: 'AWS::Serverless::Api' Properties: Name: !Sub ${Env}-${AppName}-attendance-api StageName: !Ref ApiVersion EndpointConfiguration: REGIONAL DefinitionBody: swagger: "2.0" info: version: "1.0" x-amazon-apigateway-request-validators: params-only: validateRequestBody: false validateRequestParameters: true all: validateRequestBody: true validateRequestParameters: true paths: /event: post: consumes: - "application/json" produces: - "application/json" parameters: - name: Subscribe in: "body" responses: "200": {} x-amazon-apigateway-request-validator: all x-amazon-apigateway-integration: type: AWS uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${SlackEventLambda.Arn}/invocations requestTemplates: application/json: | $input.json('$') responses: default: statusCode: "200" responseTemplates: application/json: | $input.json('$') passthroughBehavior: "WHEN_NO_MATCH" httpMethod: "POST"
- (1) Lambda Function を定義します。webpackで生成するファイルは
webpack.config.js
のentry
に従います。 Handler はevent
ディレクトリに生成されるindex
ファイルのhandler
関数を指すよう指定します。 - (2) Lambda Function に指定する IAM Role を作成します。
- (3) API を定義します。
/event
パスに対するPOSTリクエスト Lambda Function へ渡し、 Lambda Function からの戻り値をレスポンスJSONとして返すシンプルなものです。
デプロイします。AWS環境へアクセスするこれらのコマンドの前に、スイッチロールを済ませておいてください。
- tilfin/homebrew-aws: AWS commands easy to manipulate on terminal
- remind101/assume-role: Easily assume AWS roles in your terminal.
- cm-wada-yusuke/aws_swrole: Switch AWS IAM Roles to set envriomnent variables.
$ tsas param push --env stg # パラメーターストアを更新 $ tsas deploy serverless --env stg # lambda.yaml をコードとともにデプロイ ... 1. Deploy functions. stg-attendance-lambda-stack: creating CloudFormation changeset... Initiating execution of changeset stg-attendance-lambda-stack-5c9c72da-67f5-4f74-811e-XXXXXXXX on stack stg-attendance-lambda-stack Execution of changeset stg-attendance-lambda-stack-5c9c72da-67f5-4f74-811e-XXXXXXXX on stack stg-attendance-lambda-stack has started; waiting for the update to complete... Stack stg-attendance-lambda-stack has completed updating Cleaning up... All done.
AWS コンソールの API Gateway を確認し、v1
ステージのURLを取得します。そのURLを Slcak の Event API Subscrive 設定に貼ります。Verified となれば完了です。
Slack Events API の reaction_added を Kinesis Data Streams へ流す
検証リクエストのために Lambda Function を生成しましたが、実際には API Gateway で受け取ったリアクションデータはすぐに Kinesis Streams へ流します。これは、AWS SAM テンプレートを修正することで実現できます。さらに、Kinesis Data Streams へ流したデータはコンシューマとなる Lambda Function が必要です。最終的にはコンシューマがSlackへリプライを投稿するわけですが、まずはコンソールにログを出すだけの関数を作り、 Kinesis Data Streams との接続を確認しましょう。
import 'source-map-support/register'; import * as Console from 'console'; import { SubscribeEvent } from '../slack-event'; export async function handler(event: KinesisRecords): Promise<void> { Console.log(event); const subscribeEvents: SubscribeEvent[] = event.Records.map(record => { Console.log('record.kinesis', record.kinesis); Console.log('record.kinesis.data', record.kinesis.data); const payloadString = Buffer.from(record.kinesis.data, 'base64').toString('utf-8'); Console.log('decoded', payloadString); return JSON.parse(payloadString) as SubscribeEvent; }); Console.log('origin:', subscribeEvents); } interface KinesisRecords { Records: Record[]; } interface Record { kinesis: { data: string; } }
export interface SubscribeEvent { event: { type: string, user: string, reaction: string, item_user: string, item: { type: string, channel: string, ts: string }, event_ts: string } }
AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Resources: AttendanceApi: Type: 'AWS::Serverless::Api' Properties: Name: !Sub ${Env}-${AppName}-attendance-api StageName: !Ref ApiVersion EndpointConfiguration: REGIONAL DefinitionBody: swagger: "2.0" info: version: "1.0" x-amazon-apigateway-request-validators: params-only: validateRequestBody: false validateRequestParameters: true all: validateRequestBody: true validateRequestParameters: true paths: /event: post: consumes: - "application/json" produces: - "application/json" responses: "200": {} x-amazon-apigateway-request-validator: all requestParameters: integration.request.header.Content-Type: "'application/x-amz-json-1'" # Lambda ではなく Kinesis と統合 (1) x-amazon-apigateway-integration: type: AWS credentials: !GetAtt SlackIntegrationApiRole.Arn httpMethod: POST uri: !Sub arn:aws:apigateway:${AWS::Region}:kinesis:action/PutRecord requestTemplates: application/json: !Sub - | { "StreamName": "${EventStream}", "Data": "$util.base64Encode($input.body)", "PartitionKey": "$context.requestId" } - { EventStream: !Ref SlackEventSubscribeStream } responses: default: statusCode: "200" responseTemplates: application/json: | $input.json('$') passthroughBehavior: "WHEN_NO_TEMPLATES" SlackSubscribeLambda: Type: AWS::Serverless::Function Properties: FunctionName: !Sub ${Env}-${AppName}-subscribe Role: !GetAtt SlackIntegrationLambdaRole.Arn Handler: subscribe/index.handler Runtime: nodejs10.x CodeUri: Bucket: !Ref DeployBucketName Key: !Sub ${ChangeSetHash}/dist.zip Timeout: 10 Environment: Variables: ENV: !Ref Env REGION: !Ref AWS::Region # Kinesis Data Streams からデータを受け取るようイベントを設定 Events: EventSubscribeStream: Type: Kinesis Properties: Stream: !GetAtt SlackEventSubscribeStream.Arn StartingPosition: LATEST BatchSize: 100 Enabled: true SlackIntegrationLambdaRole: Type: 'AWS::IAM::Role' Properties: RoleName: !Sub ${Env}-${AppName}-lambda-role ManagedPolicyArns: - 'arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess' - 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' - 'arn:aws:iam::aws:policy/AmazonKinesisFullAccess' Policies: - PolicyName: PermissionToPassAnyRole PolicyDocument: Version: '2012-10-17' Statement: Effect: Allow Action: - iam:PassRole Resource: !Sub arn:aws:iam::${AWS::AccountId}:role/* AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: 'Allow' Principal: Service: - 'lambda.amazonaws.com' Action: - 'sts:AssumeRole' # API Gateway が Kinesis Data Streams へ流すための IAM Role が必要 SlackIntegrationApiRole: Type: 'AWS::IAM::Role' Properties: RoleName: !Sub ${Env}-${AppName}-api-role ManagedPolicyArns: - 'arn:aws:iam::aws:policy/AmazonKinesisFullAccess' - 'arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs' AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: 'Allow' Principal: Service: - 'apigateway.amazonaws.com' Action: - 'sts:AssumeRole' # Kinesis Data Streams を作成 SlackEventSubscribeStream: Type: AWS::Kinesis::Stream Properties: Name: !Sub ${Env}-${AppName}-slack-event-subscribe RetentionPeriodHours: 24 ShardCount: 1
- (1) Kinesis Data Streams と直接つなぐために、
x-amazon-apigateway-integration
を修正します。credentials
: API Gateway が Kinesis Data Streams へデータをPUTするための IAM Role を指定します。uri
: Kinesis Data Streams と統合するための uri を指定します。このuriを形成するための情報がやや見つけにくていと感じます。公式ドキュメントの API 統合リクエストの基本タスク と Kinesis Data Streams の ARN 形式を参考に設定しました。requestTemplates
: Kinesis Data Streams が求めるリクエストボディを設定します。ストリーム名、Base64エンコードされたデータ、パーティションキーを渡すよう設定しました。- Lambda Function のイベントソースを API Gateway から Kinesis Data Streams に変更します。今回は Lambda Function の起動回数を少なくおさえたいため、一度にデータを取得する
BatchSize
を最大値の100としました。
これでデプロイします。Slackでリアクションするとそのデータが Kinesis Data Streams に流れ、サブスクライブしている kinesis-event-subscribe.ts
により CloudWatch Logs へ出力されるはずです。
$ tsas deploy serverless --env stg
Slackにリアクションを追加すると:
kinesis-event-subscribe.ts の CloudWatch Logs へ出力されるはず:
ログの出力を確認できました。リアクション情報が流れています。
参考情報
リアクションイベントをフィルタする
Slack アプリケーションを有効にすると、チャンネル、スレッドを問わずにリアクション内容が Kinesis Data Streams を通して流れてきます。今回の場合は一部のリアクションのみ利用するので、フィルタ処理が必要です。
- 勤怠チャンネルで発生したリアクションのみ対象とする
- リマインダに対するリアクションのみ対象とする
こととします。
勤怠チャンネルで発生したリアクションのみ対象とする
チャンネルのホワイトリストを DynamoDB に保存して利用します。チャンネルのIDは、Web版のSlackでアクセスするとURLから割り出せます。
リマインダに対するリアクションのみ対象とする
リマインダとして投稿されたメッセージは投稿元の名前が USLACKBOT
になります。これを利用して、リアクション先のメッセージ投稿主が USLACKBOT
の場合だけリプライ対象とするようにします。
コード修正
コンソールに出力するだけの処理を修正しましょう。まずはハンドラのレイヤから。このレイヤからみると、リアクションのリストを渡したら、よしなにフィルタされたリアクションのリストが返ってくる という動きを期待します。
import 'source-map-support/register'; import * as Console from 'console'; import { SubscribeEvent } from '../slack-event'; import { EventInfo, ReactionAttendanceUseCase } from '../../domains/attendance/reaction-attendance-use-case'; export async function handler(event: KinesisRecords): Promise<void> { Console.log(event); const subscribeEvents: SubscribeEvent[] = event.Records.map(record => { Console.log('record.kinesis', record.kinesis); Console.log('record.kinesis.data', record.kinesis.data); const payloadString = Buffer.from(record.kinesis.data, 'base64').toString('utf-8'); Console.log('decoded', payloadString); return JSON.parse(payloadString) as SubscribeEvent; }); Console.log('origin', subscribeEvents); // (1) const filtered = await KinesisEventSubscribeController.filterEvents(subscribeEvents); Console.log('filtered', filtered); } class KinesisEventSubscribeController { public static async filterEvents(events: SubscribeEvent[]): Promise<SubscribeEvent[]> { try { const eventInfos: EventInfo[] = events.map(e => ({ itemUser: e.event.item_user, channel: e.event.item.channel })); // (2) const filteredInfos = await ReactionAttendanceUseCase.filterEvents(eventInfos); return events.filter(e => filteredInfos.map(i => i.channel).includes(e.event.item.channel)); } catch (e) { Console.log('filterEvents failed. Reason: ', e); return []; } } } interface KinesisRecords { Records: Record[]; } interface Record { kinesis: { data: string; } }
- (1) 追加。
SubscribeEvent
インターフェースに変換したイベント情報の配列をフィルタ関数に渡します。 - (2) 追加。フィルタ関数は、さらに必要最小限の情報
EventInfo
にしぼり、ユースケースにフィルタ処理を依頼します。
.src/lambda/domains/attendance/reaction-attendance-use-case.ts import { ApiClientSlack } from '../../infrastructures/slack/api-client-slack'; import * as Console from 'console'; import { DynamodbWhiteChannelTable } from '../../infrastructures/dynamo/dynamodb-white-channel-table'; export class ReactionAttendanceUseCase { public static async filterEvents(events: EventInfo[]): Promise<EventInfo[]> { // ホワイトリストに登録されていること const whiteChannelList: Channel[] = await DynamodbWhiteChannelTable.scan(); Console.log('whiteList', whiteChannelList); const whiteEvents = events.filter(e => whiteChannelList.map(c => c.channel).includes(e.channel)); // SlackBotに対するリアクションであること return whiteEvents.filter(e => e.itemUser === 'USLACKBOT'); } } export interface EventInfo { itemUser: string; channel: string; } export interface Channel { channel: string; }
ここで、 DynamoDB からスキャンしてチャンネルIDを取得し、それでフィルタをかけています。さらにその後、USLACKBOT
ユーザーの投稿に対するリアクションに絞っていますね。 DynamoDB からスキャンするコードは次のようになります。
import { DocumentClient } from 'aws-sdk/lib/dynamodb/document_client'; import { Channel } from '../../domains/attendance/reaction-attendance-use-case'; import ScanInput = DocumentClient.ScanInput; const WhiteChannelTableName = process.env.WHITE_CHANNEL_TABLE_NAME!; // (1) const Region = process.env.REGION!; const DYNAMO = new DocumentClient( // (2) { apiVersion: '2012-08-10', region: Region } ); export class DynamodbWhiteChannelTable { public static async scan(): Promise<Channel[]> { const params: ScanInput = { TableName: WhiteChannelTableName, }; const response = await DYNAMO.scan(params).promise(); return response.Items!.map(item => ({ channel: item.channelId // (3) })); } }
- (1) テーブル名を環境変数から取得しています。つまり、 Lambda Function にテーブル名の環境変数をセットする必要があります。
- (2) DynamoDB の DocumentClient を利用します。 DynamoDB をそのまま使うよりもインターフェースがシンプルです。
- (3) スキャンしたItemはany型です。ユースケースが求める型に変換した上で返します。
この Lambda Function の AWS SAM テンプレートを修正します。まずは DynamoDB テーブルを追加します。その後、そのテーブル名を参照するように Lambda Function の環境変数をセットします。
SlackSubscribeLambda: Type: AWS::Serverless::Function Properties: FunctionName: !Sub ${Env}-${AppName}-subscribe Role: !GetAtt SlackIntegrationLambdaRole.Arn Handler: subscribe/index.handler Runtime: nodejs10.x CodeUri: Bucket: !Ref DeployBucketName Key: !Sub ${ChangeSetHash}/dist.zip Timeout: 10 Environment: Variables: ENV: !Ref Env REGION: !Ref AWS::Region WHITE_CHANNEL_TABLE_NAME: !Ref WhiteChannelTableName # 追加 Events: EventSubscribeStream: Type: Kinesis Properties: Stream: !GetAtt SlackEventSubscribeStream.Arn StartingPosition: LATEST BatchSize: 100 Enabled: true # 追加 AttendanceWhiteChannelTable: Type: AWS::DynamoDB::Table Properties: TableName: !Sub ${Env}-${AppName}-white-list BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: channelId AttributeType: S KeySchema: - AttributeName: channelId KeyType: HASH
デプロイします。
$ tsas deploy serverless --env stg
動作確認しましょう。まず、 DynamoDB にホワイトリストとなるチャンネルIDを設定します。以下のコマンドを実行します。ABCDEFG
にホワイトリストにしたいチャンネルIDを指定してください。
$ aws dynamodb put-item --table-name stg-attendance-white-list --item '{"channelId":{"S":"ABCDEFG"}}'
これで Slack にリアクションを行い、CloudWatch Logs に期待するログが出るか確認します。
- ホワイトリストに登録していないチャンネルでリアクションを行うと、フィルタリングの結果が空
- ホワイトリストに登録しているチャンネルで、リマインダではないメッセージにリアクションを行うと、フィルタリングの結果が空
- ホワイトリストに登録しているチャンネルで、リマインダにリアクションを行うと、フィルタリングの結果が空ではない
次の結果は最後の項目の確認(勤怠チャンネルでのリマインダに対するリアクション)です。
2019-09-05T04:03:57.628Z beb9a7f6-0ccf-4477-9cc3-be84d346860a INFO filtered [ { token: 'tBei42WK7UaQBQyyPRbyWqW9', team_id: ...
これでほしいリアクションだけに絞り込むことができました。あとは、リアクションの情報からユーザー情報を取得し、リプライするのみです。
ユーザー情報を取得し、リプライする
まず、Slackのアプリケーション設定で次のことができるよう権限を追加します。
- ユーザープロファイルを取得できる
- BOTとしてメッセージを投稿できる
ユーザープロファイルを取得できるようにする
Slack の Your App > OAuth & Permissions > Select Permission Scopes で users:read
を追加します。
BOTとしてメッセージを投稿できるようにする
次に Bot User を追加します。名前は何でもよいです。
終わったら Install App で再度アプリケーションをインストールします。これでSlack側の準備は完了です。
トークンの登録
Slack にアプリケーションをインストールすると、OAuth Access Token
と Bot User OAuth Access Token
が手に入るはずです。これらは Lambda Function から Slack API を叩く際必要になるので tsas param put
を通して個別にパラメータストアに設定、 Lambda Function の環境変数に追加します。
$ tsas param put BotAccessToken xoxb-9999999999-8888888888-6wDpcEahPDk6sAWs --env stg $ tsas param put OAuthAccessToken xoxp-9999999999-8888888888-77777777-bbehswy4wr56uui6nftmau8j --env stg
AWS SAM テンプレートを修正し、環境変数を追加します。
SlackSubscribeLambda: Type: AWS::Serverless::Function Properties: FunctionName: !Sub ${Env}-${AppName}-subscribe Role: !GetAtt SlackIntegrationLambdaRole.Arn Handler: subscribe/index.handler Runtime: nodejs10.x CodeUri: Bucket: !Ref DeployBucketName Key: !Sub ${ChangeSetHash}/dist.zip Timeout: 10 Environment: Variables: ENV: !Ref Env REGION: !Ref AWS::Region OAUTH_ACCESS_TOKEN: !Ref OAuthAccessToken # 追加 BOT_ACCESS_TOKEN: !Ref BotAccessToken # 追加 WHITE_CHANNEL_TABLE_NAME: !Ref AttendanceWhiteChannelTable Events: EventSubscribeStream: Type: Kinesis Properties: Stream: !GetAtt SlackEventSubscribeStream.Arn StartingPosition: LATEST BatchSize: 100 Enabled: true
どんなリプライを投稿したいか
さて、これでユーザーのプロファイル情報が取得できるわけですが、コードを修正する前に、どんなリプライをしたいか を考えましょう。課題は、「出退勤をリアクションで済ませたいものの、時間がわからない」です。なので次の情報を表示したいと考えました。
- リアクション内容
- リアクションした時間
- ユーザー名
- ユーザープロファイル画像
1と2はリアクションのイベントからそのまま利用できます。イベントから手に入るユーザー情報はユーザーIDだけですので、3と4はユーザープロファイルを取得するAPIからとってくる必要がありそうです。さて、これらを使って投稿した場合、どんな感じのメッセージになるか見てみたいですね。Slack には メッセージブロックの構築を支援する便利なツール、Block Kit Builder があります。これをついかいましょう。
[ { "type": "section", "text": { "type": "mrkdwn", "text": ":ghost:" } }, { "type": "context", "elements": [ { "type": "image", "image_url": "https://s.gravatar.com/avatar/xxxxxxxxxxxxxxxxxxxx?s=80", "alt_text": "Example Image" }, { "type": "mrkdwn", "text": "yusuke Reacted: Jan 1, 2019" } ] }, { "type": "divider" } ]
プレビューはこのようになります。
このメッセージ形式を採用しましょう。
ユーザープロファイルの取得、リプライ投稿
ユーザープロファイルは Slack API の users.info
を利用すれば取得できそうです。リクエストに必要なものはユーザーIDと OAuth Access Token で、前者は流れてきたリアクションのイベント情報から、後者は Lambda Function に設定した環境変数から取得できます。メッセージ投稿は chat.postMessage
を利用します。
import axios from 'axios'; import * as qs from 'qs'; import { ReactionContent, UserProfile } from '../../domains/attendance/reaction-attendance-use-case'; import * as Console from 'console'; const OAuthAccessToken = process.env.OAUTH_ACCESS_TOKEN!; const BotAccessToken = process.env.BOT_ACCESS_TOKEN!; export class ApiClientSlack { /** * ユーザープロファイルを取得し、UserProfile として返します。 * @param userId Slack ユーザーID */ static async getUser(userId: string): Promise<UserProfile> { const param = { user: userId, token: OAuthAccessToken, }; const query = qs.stringify(param); const url = `https://slack.com/api/users.info?${query}`; Console.log(url); try { const response = await axios.get(url, { headers: { 'Content-Type': 'application/json;utf-8', } }); Console.log(response); const user: UsersInfoResponse = response.data as UsersInfoResponse; return { id: user.user.id, name: user.user.name, // プロファイル画像を設定することにしたのでユーザープロファイルに含まれる `image_24` も取得します。 image24: user.user.profile.image_24 } } catch (e) { Console.log(e); throw e; } } /** * リアクション内容をリプライとしてスレッドに投稿します。 * @param reaction リアクションイベント情報 * @param profile ユーザープロファイル */ public static async postReactionDetail(reaction: ReactionContent, profile: UserProfile): Promise<void> { const request: PostMessageRequest = { token: BotAccessToken, channel: reaction.item.channel, as_user: false, thread_ts: reaction.item.ts, // thread_ts を指定すると特定のスレッドに対するリプライになります。 username: 'Attendancer', blocks: ApiClientSlack.createBlockString(reaction, profile) }; try { const response = await axios.post('https://slack.com/api/chat.postMessage', request, { headers: { 'Content-Type': 'application/json;utf-8', 'Authorization': `Bearer ${BotAccessToken}` } }); Console.log(response); } catch (e) { Console.log(e); throw e; } } /** * リプライメッセージを構築します。Slack Message Builder で準備した形式を参考にします。 * @param reaction リアクションイベント情報 * @param profile ユーザープロファイル */ private static createBlockString(reaction: ReactionContent, profile: UserProfile): string { return JSON.stringify([ { 'type': 'section', 'text': { 'type': 'mrkdwn', 'text': `:${reaction.reaction}:` } }, { 'type': 'context', 'elements': [ { 'type': 'image', 'image_url': profile.image24, 'alt_text': profile.name, }, { 'type': 'mrkdwn', // 時刻は、Slack のクライアントのタイムゾーンに合わせるためSlack指定の形式を利用しています。 'text': `${profile.name} <!date^${parseInt(reaction.eventTs)}^Reacted {date_num} {time_secs}|Reacted 2014-02-18 6:39:42 AM>` } ] }, { 'type': 'divider' } ]); } } interface UsersInfoResponse { user: { id: string; name: string; profile: { image_24: string; } } } interface PostMessageRequest { token: string; channel: string; as_user: boolean; blocks: string; thread_ts: string; username: string; }
あとはこれを利用するようにユースケースとハンドラも修正します。
import { ApiClientSlack } from '../../infrastructures/slack/api-client-slack'; import * as Console from 'console'; import { DynamodbWhiteChannelTable } from '../../infrastructures/dynamo/dynamodb-white-channel-table'; export class ReactionAttendanceUseCase { /** * リアクションイベントからユーザー情報を取得し、リプライとして投稿します。 * @param sub リアクションイベント情報 */ public static async reaction(sub: ReactionContent): Promise<void> { // ユーザー情報収集 const userProfile = await ApiClientSlack.getUser(sub.user); Console.log(userProfile); // リプライを投稿 await ApiClientSlack.postReactionDetail(sub, userProfile); } public static async filterEvents(events: EventInfo[]): Promise<EventInfo[]> { // ホワイトリストに登録されていること const whiteChannelList: Channel[] = await DynamodbWhiteChannelTable.scan(); Console.log('whiteList', whiteChannelList); const whiteEvents = events.filter(e => whiteChannelList.map(c => c.channel).includes(e.channel)); // SlackBotに対するリアクションであること return whiteEvents.filter(e => e.itemUser === 'USLACKBOT'); } } export interface ReactionContent { type: string; user: string; reaction: string; itemUser: string; item: { type: string, channel: string, ts: string }; eventTs: string } export interface UserProfile { id: string; name: string; image24: string; } export interface EventInfo { itemUser: string; channel: string; } export interface Channel { channel: string; }
import 'source-map-support/register'; import * as Console from 'console'; import { SubscribeEvent } from '../slack-event'; import { EventInfo, ReactionAttendanceUseCase } from '../../domains/attendance/reaction-attendance-use-case'; export async function handler(event: KinesisRecords): Promise<void[]> { Console.log(event); const subscribeEvents: SubscribeEvent[] = event.Records.map(record => { Console.log('record.kinesis', record.kinesis); Console.log('record.kinesis.data', record.kinesis.data); const payloadString = Buffer.from(record.kinesis.data, 'base64').toString('utf-8'); Console.log('decoded', payloadString); return JSON.parse(payloadString) as SubscribeEvent; }); Console.log('origin', subscribeEvents); const filtered = await KinesisEventSubscribeController.filterEvents(subscribeEvents); Console.log('filtered', filtered); const promisedPost = filtered.map(KinesisEventSubscribeController.forwardEvent); return Promise.all(promisedPost); } class KinesisEventSubscribeController { public static async filterEvents(events: SubscribeEvent[]): Promise<SubscribeEvent[]> { try { const eventInfos: EventInfo[] = events.map(e => ({ itemUser: e.event.item_user, channel: e.event.item.channel })); const filteredInfos = await ReactionAttendanceUseCase.filterEvents(eventInfos); return events.filter(e => filteredInfos.map(i => i.channel).includes(e.event.item.channel)); } catch (e) { Console.log('filterEvents failed. Reason: ', e); return []; } } public static async forwardEvent(payload: SubscribeEvent): Promise<void> { Console.log(payload); try { await ReactionAttendanceUseCase.reaction({ eventTs: payload.event.event_ts, itemUser: payload.event.item_user, user: payload.event.user, reaction: payload.event.reaction, type: payload.event.type, item: payload.event.item }); } catch (e) { Console.log('forwardEvent failed. Reason: ', e); } } } interface KinesisRecords { Records: Record[]; } interface Record { kinesis: { data: string; } }
これで全体として、フィルタをすり抜けたリアクションに対し、ユーザープロファイルを取得後リプライするという流れになりました。完成です。デプロイして確認しましょう。
これで、リアクションするだけで時刻が記録されるようになりました。もちろん、ユーザー自身がリプライしたわけではないので、スレッドはフォローされません。課題が解消できました。
まとめ
APIを作るということはメンテナンスが必要になるのでこれですべて解決、というわけではありません。フィードバックをもらい、改善し続けること、その体制を整えておくことが大事です。また、Slack様 はユーザーの声を拾ってくれるので、同等の機能がSlack側に実装される可能性もあります(たとえばスレッドの自動フォローOFF機能など)。ただそれでも、今回のようなユースケースはサーバーレスアプリケーションがマッチしていて、作る価値があると感じました。
みなさんもぜひ、課題を感じたら「面倒だな」というところまで落とし込んで、プログラミングで解決していきましょう。そのとき、可能な限り手っ取り早く作りたい、といったシチュエーションであればサーバーレスの活用も検討してみてください。
参考
- Events API | Slack
- チュートリアル: API Gateway で Amazon Kinesis プロキシとして REST API を作成する - Amazon API Gateway