SlackのサーバーレスBOT – リモートワークの開始と終了宣言をSlackのリアクションで済ませたい

2019.09.06

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

今回は Slack で出勤、退勤を連絡するときにちょっと便利なリアクション投稿BOTをサーバーレスで作りました。背景にフレックスタイム制、リモートワーク可としているクラスメソッドの勤務体系があります。詳しくはこちら。

Slack に出勤・退勤を書き込むけど...ちょっと面倒

私が所属するCX事業本部は、開発をメイン業務に据えるメンバーも多く、またチームでの作業が基本です。拠点、労働時間のばらばらなメンバーが連携するのは思ったよりたいへんです。そこで一部のメンバーが、簡単に出退勤を知らせる目的で、出勤/退勤時、Slackへ投稿する試みを行いました。

images/kintai.png

しかしすぐに課題が出ます。チャンネルが、出勤・退勤の連絡で埋まってしまい、とても見通しが悪いのです。そこで、Slackに平日投稿のリマインダを設定し、そのスレッドにリプライする形で連絡するようにしました。

/remind #勤怠 to 開始 終了 はこのスレで! at 5:00am every weekday

images/kintai-thread.png

出退勤と特別な連絡がうまい具合に区別され、とてもよい感じです。ところがこれでもまだ課題がありました。スレッドにリプライすると、その会話に参加したとみなされ、自動でスレッドがフォローされるのです。そうすると、他の人の勤怠報告でスレッドが未読状態になるため、本当にチェックが必要なスレッドと混ざってしまいノイズになります。

images/thread-unread.png

結果、多少面倒ですが、出退勤のリプライを行った後に手動でスレッドのフォローをはずす運用としました。ここで面倒であるという課題が残りましたね。今回の話に限らず、あらゆる課題をまず「面倒くさい」というところまで落とし込めばプログラマーの出番です。

Slack Events API によるリアクションの自動リプライBOT

サーバーレスアプリケーションでの解決を試みます。もともと、「リマインダの投稿に出退勤のリアクションを行えばよいのではないか」という案がありました。リアクションするだけなら、スレッドが自動でフォローされることはありません。よい案です。ただリアクションは、誰が行ったものなのかはわかりますが、いつ行ったかまではわかりません。なのでどうにかしてリアクションを行った時間を記録したいと考えました。そこで、特定のスレッドにリアクションがあったら、その内容をBOTが自動でリプライするというアプリケーションを組んでみます。

images/slack_bot_arch.png

  1. Events api の受け口です。イベントが受け取れるかチェックし、特定のイベント(今回は reactions_added) をAPIに対して流すよう設定します。
  2. Data Streams に流れてきたSlackのイベントをホワイトリストでフィルタします。
    • 勤怠チャンネルかつ、USLACKBOT ユーザーへのリアクションを拾うようにしましす。
    • ホワイトリストとなるチャンネルのリストは DynamoDB に保存したものを使います。
  3. リアクションの情報から、ユーザー情報を取得します。ユーザーのプロファイル写真を取得するためです。
  4. chat.postMessage を使ってユーザーがリアクションしたスレッドにリプライします。

Slack Events API を受け取れるようにする

1で用意するAPIは、Slackから送られるテストリクエストをあらかじめ受け取っておく必要があります。Slack api のアプリケーション設定で、Event Subscriptions を開きます。URLを設定する項目があります。

images/slack-event.png

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を作ります。

src/lambda/handlers/api-gw/slack-event.ts(新規作成)

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 で新しいエンドポイント用のファイルを作るよう指定します。

.webpack.config.js(追記)

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 が追加でデプロイされるようにします。

.templates/lambda.yaml(追記)

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.jsentry に従います。 Handler は event ディレクトリに生成される index ファイルの handler 関数を指すよう指定します。
  • (2) Lambda Function に指定する IAM Role を作成します。
  • (3) API を定義します。/event パスに対するPOSTリクエスト Lambda Function へ渡し、 Lambda Function からの戻り値をレスポンスJSONとして返すシンプルなものです。

デプロイします。AWS環境へアクセスするこれらのコマンドの前に、スイッチロールを済ませておいてください。

$ 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 となれば完了です。

images/slack-event-filled.png

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 との接続を確認しましょう。

.src/lambda/handlers/kinesis/kinesis-event-subscribe.ts(新規作成)

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;
  }
}

.src/lambda/handlers/slack-event.ts

export interface SubscribeEvent {
  event: {
    type: string,
    user: string,
    reaction: string,
    item_user: string,
    item: {
      type: string,
      channel: string,
      ts: string
    },
    event_ts: string
  }
}

.templates/lambda.yaml(抜粋)

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にリアクションを追加すると: images/stream_slack_reaction_added.png

kinesis-event-subscribe.ts の CloudWatch Logs へ出力されるはず: image/stream_reaction_added_cw.png

ログの出力を確認できました。リアクション情報が流れています。

参考情報

リアクションイベントをフィルタする

Slack アプリケーションを有効にすると、チャンネル、スレッドを問わずにリアクション内容が Kinesis Data Streams を通して流れてきます。今回の場合は一部のリアクションのみ利用するので、フィルタ処理が必要です。

  • 勤怠チャンネルで発生したリアクションのみ対象とする
  • リマインダに対するリアクションのみ対象とする

こととします。

勤怠チャンネルで発生したリアクションのみ対象とする

チャンネルのホワイトリストを DynamoDB に保存して利用します。チャンネルのIDは、Web版のSlackでアクセスするとURLから割り出せます。

リマインダに対するリアクションのみ対象とする

リマインダとして投稿されたメッセージは投稿元の名前が USLACKBOT になります。これを利用して、リアクション先のメッセージ投稿主が USLACKBOT の場合だけリプライ対象とするようにします。

コード修正

コンソールに出力するだけの処理を修正しましょう。まずはハンドラのレイヤから。このレイヤからみると、リアクションのリストを渡したら、よしなにフィルタされたリアクションのリストが返ってくる という動きを期待します。

.src/lambda/handlers/kinesis/kinesis-event-subscribe.ts

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 からスキャンするコードは次のようになります。

.src/lambda/infrastructures/dynamo/dynamodb-white-channel-table.ts

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 の環境変数をセットします。

.templates/lambda.yaml(追加分)

  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 に期待するログが出るか確認します。

  • ホワイトリストに登録していないチャンネルでリアクションを行うと、フィルタリングの結果が空
  • ホワイトリストに登録しているチャンネルで、リマインダではないメッセージにリアクションを行うと、フィルタリングの結果が空
  • ホワイトリストに登録しているチャンネルで、リマインダにリアクションを行うと、フィルタリングの結果が空ではない

次の結果は最後の項目の確認(勤怠チャンネルでのリマインダに対するリアクション)です。

images/reaction_kintai_reminder.png

.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 を追加します。

images/slack_users_read.png

BOTとしてメッセージを投稿できるようにする

次に Bot User を追加します。名前は何でもよいです。

images/slack_add_bot.png

終わったら Install App で再度アプリケーションをインストールします。これでSlack側の準備は完了です。

トークンの登録

Slack にアプリケーションをインストールすると、OAuth Access TokenBot 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 テンプレートを修正し、環境変数を追加します。

.templates/lambda.yaml

  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. リアクションした時間
  3. ユーザー名
  4. ユーザープロファイル画像  

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"
  }
]

プレビューはこのようになります。

images/slack_block_kit_builder_user.png

このメッセージ形式を採用しましょう。

ユーザープロファイルの取得、リプライ投稿

ユーザープロファイルは Slack API の users.info を利用すれば取得できそうです。リクエストに必要なものはユーザーIDと OAuth Access Token で、前者は流れてきたリアクションのイベント情報から、後者は Lambda Function に設定した環境変数から取得できます。メッセージ投稿は chat.postMessage を利用します。

.src/lambda/infrastructures/slack/api-client-slack.ts

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;
}

あとはこれを利用するようにユースケースとハンドラも修正します。

.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 {

  /**
   * リアクションイベントからユーザー情報を取得し、リプライとして投稿します。
   * @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;
}

.src/lambda/handlers/kinesis/kinesis-event-subscribe.ts

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;
  }
}

これで全体として、フィルタをすり抜けたリアクションに対し、ユーザープロファイルを取得後リプライするという流れになりました。完成です。デプロイして確認しましょう。

image/slack_complete_reaction.png

これで、リアクションするだけで時刻が記録されるようになりました。もちろん、ユーザー自身がリプライしたわけではないので、スレッドはフォローされません。課題が解消できました。

まとめ

APIを作るということはメンテナンスが必要になるのでこれですべて解決、というわけではありません。フィードバックをもらい、改善し続けること、その体制を整えておくことが大事です。また、Slack様 はユーザーの声を拾ってくれるので、同等の機能がSlack側に実装される可能性もあります(たとえばスレッドの自動フォローOFF機能など)。ただそれでも、今回のようなユースケースはサーバーレスアプリケーションがマッチしていて、作る価値があると感じました。

みなさんもぜひ、課題を感じたら「面倒だな」というところまで落とし込んで、プログラミングで解決していきましょう。そのとき、可能な限り手っ取り早く作りたい、といったシチュエーションであればサーバーレスの活用も検討してみてください。

参考

ソースコード