話題の記事

Slack絵文字による川柳を効率化するためのスラッシュコマンドを開発しました

見せてやんよ本気の肥後橋南蛮亭
2019.07.28

カスタム絵文字で川柳を詠むチャンスを虎視眈々と伺っている私としては、新規登録された絵文字は確実に把握しておかなければなりません。

コーディング無し!Slackのカスタム絵文字を集計して定期的にチャンネルへ通知する

このように弊社にはSlack職人と呼ばれる人種が存在します。彼女たちはSlackのカスタム絵文字を利用して「川柳」を投稿したり、はたまた誰かの投稿に対するリアクションで「川柳」を詠むのが得意です。

一例です。

12時~13時頃の大阪オフィスでは、良くこういった会話が繰り広げられます。

※南蛮亭とは、大阪オフィス近くの居酒屋さんです(肥後橋南蛮亭 (ひごばしなんばんてい) - 肥後橋/焼鳥 食べログ)

先日のCX事業本部爆誕以後はこういった川柳が流行しているようです。

しかし、ある日冷静なツッコミが入りました

通常のリアクションと川柳によるリアクションを比較すると、川柳によるリアクションの完了には通常のリアクションの約3倍~10倍程度の時間が必要になります。 +:絵文字コード: を活用してもかなりのタイプ数になるので、こういった反応が出てくるのはもっともです。

Tip : 一番最後に受け取ったメッセージにリアクションするには、メッセージボックスで +:絵文字コード: と入力して送信します

Slack の使い方

Slack職人たちが暇か?と言われないように、またSlack職人たちの業務負荷軽減のために、効率よく川柳を詠むためのスラッシュコマンドを作ることを心に誓いました。

構成

今回作成する環境です。

各AWSリソースはざっくり以下のように利用します

スラッシュコマンドインストール時

  • ユーザーのブラウザがAPI Gatewayのエンドポイントにアクセス
  • API GatewayからLambdaを起動
  • Lambdaは環境変数からSlackのクライアントID,シークレットを取得(デプロイ時にSSMパラメータストアから取得してLambdaの環境変数にセットしておく)
  • LambdaからSlackの oauth.access APIを実行し、トークンを払い出す
  • 払い出されたトークンをKMSで暗号化し、DynamoDBに保存
  • ユーザーのブラウザに完了メッセージを返す

スラッシュコマンド利用時

  • スラッシュコマンド実行時にSlackがAPI Gatewayエンドポイントにリクエストを発行
  • API GatewayからLambdaを起動
  • LambdaがDynamoDBから対象ユーザーのトークンを取得し、KMSで復号
  • LambdaがDynamoDBに登録された川柳を読み込み、先ほど取得したトークンを使ってreactions.addもしくはchat.postMessageのAPIを実行し、ユーザーの代わりにリアクションもしくは投稿を実施

コマンド体系

スラッシュコマンドの体系は以下のように設計しました

  • /SLASH_COMMAND ヘルプを表示 ヘルプにはマスタ登録済みの川柳一覧を含める
  • /SLASH_COMMAND SUB_COMMAND SUB_COMMANDで指定された川柳を投稿
  • /SLASH_COMMAND SUB_COMMAND N 直近N個目の投稿に対してSUB_COMMANDで指定されたリアクションを付与します。

例)/SLASH_COMMAND nanbanteiを実行した場合 「肥後橋南蛮亭」と投稿されます

例)/SLASH_COMMAND nanbantei 1を実行した場合 直近の投稿に「肥」、「後」、「橋」、「南」、「蛮」、「亭」のリアクションが付与されます

/SLASH_COMMAND nanbantei 1,/SLASH_COMMAND nanbantei 2,/SLASH_COMMAND nanbantei 3,,,と連続実行することで南蛮亭のリアクションを蔓延させることができます。

ソースコード

ディレクトリ構成は以下のような形にしています。

├── layer
│   └── python                        # ライブラリをLambda Layersにパッケージするためのディレクトリ
├── parameter_store.yml               # SSMパラメータストアを作成するためのテンプレート
├── sam.yml                           # 各種AWSリソースを作成するためのテンプレート
└── src
    └── handlers
        ├── auth.pyi                  # スラッシュコマンドインストール時に起動するLambda
        └── slash.py                  # スラッシュコマンド実行時に起動するLambda

まずSlackのクライアントID,クライアントシークレットを保存するためにSSMのパラメータストアを作成します

parameter_store.yml

AWSTemplateFormatVersion: 2010-09-09
Description: Setup Parameter store for slack dev
Resources:
  SSMParamSlackClientID:
    Type: "AWS::SSM::Parameter"
    Properties:
      Name: SLACK_CLIENT_ID
      Type: String
      Value: hogehoge #テンプレートでは入れ物だけ作って、実際の値は別途入れてもらう
  SSMParamSlackClientSecret:
    Type: "AWS::SSM::Parameter"
    Properties:
      Name: SLACK_CLIENT_SECRET
      Type: String
      Value: hogehoge #テンプレートでは入れ物だけ作って、実際の値は別途入れてもらう

本来はType:SecureStringで作成したいのですが、CloudFormationが未対応なのでType:Stringで作成しています。 後ほどSlackのクライアントIDとクライアントシークレットを保存します。

次にSAMテンプレートです。 作成するリソースの種類も少ないので、全て1つのテンプレートに詰め込みました。 本当はKMS周りのリソースもテンプレートに詰め込みたいのですが、循環参照の問題が解決できないので、別途作成してパラメータで渡すようにしています。

sam.yml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Globals:
  Function:
    Runtime: python3.7
    Timeout: 15
    MemorySize: 256
    Layers:
      - !Ref LambdaLayer
    Environment:
      Variables:
        SLACK_CLIENT_ID: !Sub '${SlackClientId}'
        SLACK_CLIENT_SECRET: !Sub '${SlackClientSecret}'
        USER_TOKENS_TABLE: !Ref SlackUserTokens
        COMMANDS_TABLE: !Ref SlackCommands
        KMS_KEY_ID: !Ref KmsKeyId
Parameters:
  SlackClientId:
    NoEcho: true
    Type: AWS::SSM::Parameter::Value<String>
    Default: SLACK_CLIENT_ID
  SlackClientSecret:
    NoEcho: true
    Type: AWS::SSM::Parameter::Value<String>
    Default: SLACK_CLIENT_SECRET
  KmsKeyId:
    Type: String
    Description: KMSキーのID
Resources:
  SlashCommand:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src
      Handler: handlers/slash.handler
      Role: !GetAtt LambdaExecuteRole.Arn
      Events:
        SlashCmdAPI:
          Type: Api
          Properties:
            Path: /
            Method: post
  GetAuth:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src
      Handler: handlers/auth.handler
      Role: !GetAtt LambdaExecuteRole.Arn
      Events:
        GetAuthAPI:
          Type: Api
          Properties:
            Path: /auth
            Method: get
  LambdaLayer:
    Type: AWS::Serverless::LayerVersion
    Properties:
      Description: python modules Layer
      ContentUri: layer
  SlackUserTokens:
    Type: AWS::Serverless::SimpleTable
    Properties:
      PrimaryKey:
        Name: user_id
        Type: String
  SlackCommands:
    Type: AWS::Serverless::SimpleTable
    Properties:
      PrimaryKey:
        Name: command_name
        Type: String
  LambdaExecuteRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - 'lambda.amazonaws.com'
            Action: sts:AssumeRole
      Policies:
        -
          PolicyName: 'allow_dynamo_access'
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              -
                Effect: 'Allow'
                Action:
                  - 'dynamodb:*'
                Resource:
                  - !GetAtt SlackUserTokens.Arn
                  - !GetAtt SlackCommands.Arn
        -
          PolicyName: 'allow_cloud_watch_logs'
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              -
                Effect: 'Allow'
                Action:
                  - 'logs:CreateLogGroup'
                  - 'logs:CreateLogStream'
                  - 'logs:PutLogEvents'
                Resource: '*'
        -
          PolicyName: 'allow_kms'
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              -
                Effect: 'Allow'
                Action:
                  - 'kms:Decrypt'
                  - 'kms:Encrypt'
                Resource: !Sub "arn:aws:kms:${AWS::Region}:${AWS::AccountId}:key/${KmsKeyId}"
  GetAuthLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub /aws/lambda/${GetAuth}
      RetentionInDays: 14
  SlashCommandLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub /aws/lambda/${SlashCommand}
      RetentionInDays: 14
Outputs:
  ApiGwURL:
    Description: "API Gateway endpoint URL"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/"
  LambdaExecuteRoleArn:
    Description: "Lambda Execute Role Arn"
    Value: !GetAtt "LambdaExecuteRole.Arn"

スラッシュコマンドインストール用のLambdaです Slack側から送信されてきた認可コードを使用してOAuthのトークンを取得、取得したトークンはKMSで暗号化してDynamoDBに保存します。 Python3.7で実装しました。

※説明を簡略化するために諸々のチェック処理等を省略しています

src/handlers/auth.py

import base64
import boto3
import os
from slack.web.client import WebClient
from http import HTTPStatus
import json
import logging
 
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['USER_TOKENS_TABLE'])
kms_client = boto3.client('kms')
logger = logging.getLogger()
 
 
def handler(event, context):
 
    client = WebClient(token='')
    client_id = os.environ['SLACK_CLIENT_ID']
    client_secret = os.environ['SLACK_CLIENT_SECRET']
    code = event['queryStringParameters']['code']
 
    response = client.oauth_access(
        client_id=client_id,
        client_secret=client_secret,
        code=code
    )
    # 本当はレスポンスのチェックが必要
 
    token = response.data['access_token']
    kms_res = kms_client.encrypt(
        KeyId=os.environ['KMS_KEY_ID'],
        Plaintext=token.encode()
    )
 
    encrypted_token = base64.b64encode(kms_res['CiphertextBlob'] ).decode('utf-8')
    user_id = response.data['user_id']
    table.put_item(Item={
        'token': encrypted_token,
        'user_id': user_id
    })
 
    return {
            'statusCode': HTTPStatus.OK,
            'body': json.dumps({'text':'success'}),
            'headers': {
                'Content-Type': 'application/json'
            }
    }
 

次にスラッシュコマンド実行用のLambdaです

※説明を簡略化するために諸々のチェック処理等は省略しています

src/handlers/slash.py

import base64
import boto3
from http import HTTPStatus
import json
import logging
import os
import re
from slack.web.client import WebClient
from slack.errors import SlackApiError
import time
from urllib.parse import parse_qs

 
MY_SLASH_CMD_NAME = 'xxxxxx'
 
dynamodb = boto3.resource('dynamodb')
kms_client = boto3.client('kms')
token_table = dynamodb.Table(os.environ['USER_TOKENS_TABLE'])
cmd_table = dynamodb.Table(os.environ['COMMANDS_TABLE'])
logger = logging.getLogger()
 
 
def handler(event, context):
 
    # 本当は色々バリデーションが必要     
    body = event['body']
    params = parse_qs(body)
    channel_id = params['channel_id'][0]
    user_id = params['user_id'][0]
 
    if 'text' not in params:
        return cmd_help()
 
    options = params['text'][0].split()
 
    res = token_table.get_item(Key={'user_id': user_id})
    if 'Item' not in res:
        return please_install()
    encrypt_token = res['Item']['token']
 
    sub_cmd = options[0]
    res = cmd_table.get_item(Key={'command_name': sub_cmd})
 
    if 'Item' not in res:
        return cmd_help('指定されたサブコマンドが見つかりませんでした')
    emoji_list = res['Item']['emoji']
 
    blob_token = base64.b64decode(encrypt_token)
    kms_res = kms_client.decrypt(CiphertextBlob=blob_token)
    decrypted_token = kms_res['Plaintext'].decode('utf-8')
    client = WebClient(token=decrypted_token)
 
    if len(options) == 1:
        return post(client, channel_id, emoji_list)
 
    back_cnt = int(options[1])
    res = client.conversations_history(
        channel=channel_id,
        count=10,
        inclusive='false'
    )
 
    msgs = res.data['messages']
    ts = msgs[back_cnt - 1]['ts']
 
    return react(client, channel_id, ts, emoji_list)
 
def react(client, channel_id, ts, emoji_list):
 
    for emoji in emoji_list:
        add_react(client, channel_id, emoji.replace(':', '').replace(' ', ''), ts)
 
    return {
            'statusCode': HTTPStatus.OK,
            'body': json.dumps({'text':'success'}),
            'headers': {
                'Content-Type': 'application/json'
            }
    }
 
 
def post(client: WebClient, channel_id, emoji_list):
 
    client.chat_postMessage(channel=channel_id,
                            text=''.join(emoji_list))
    return {
            "statusCode": HTTPStatus.OK,
            'body': json.dumps({'text':'success'}),
            'headers': {
                'Content-Type': 'application/json'
            }
    }
 
 
def add_react(client, channel_id, emoji_name, ts):
 
    try:
        res = client.reactions_add(
            channel=channel_id,
            name=emoji_name,
            timestamp=ts
        )
        # 絵文字の並び順が担保できないのでsleepを挟む
        # Clientをasync=Falseで作成してるのに並び順が担保されていないのでSlack側の問題かも?
        time.sleep(0.1)
        return res
    except SlackApiError as e:
        if e.response.get('error') == 'already_reacted':
            return
        raise
 
 
def please_install():
 
    return {
            'statusCode': HTTPStatus.OK,
            'body': json.dumps({
              'text': 'インストールお願いします!',
              'response_type': 'ephemeral',
              'attachments': [{
                'text': '誰かにURL聞いてインストールして下さい!'
                }]
            }),
          'headers': {
                    'Content-Type': 'application/json'
            }               
        }
 
def cmd_help(title='ヘルプ'):
 
    res = cmd_table.scan()
    cmds = res['Items']
    cmds.sort(key=lambda x: x['command_name'])
    cmds = [i['command_name'] + '     ' + ''.join(i['emoji']) for i in res['Items']]
 
    text = f"""
コマンドの使い方...(略)
利用可能なサブコマンド一覧です
"""
    text += '\n'.join(cmds)
     
    return {
        'statusCode': HTTPStatus.OK,
        'body': json.dumps({
          'text': title,
          'response_type': 'ephemeral',
          'attachments': [{
              'color': '#36a64f',
              'pretext': 'コマンドの利用方法です↓',
              'text': text
            }]
          }),
      'headers': {
                'Content-Type': 'application/json'
            }
      }

スラッシュコマンド利用準備

実際にスラッシュコマンドを利用するための準備を行います

スラッシュコマンドの作成

[小ネタ]Slack絵文字をスタンプにして最高の体験をしよう!

まずはこちらを参考にスラッシュコマンドを作成します。 Request URLはデプロイ後に入力するので、一旦ダミーでOKです。

必要なスコープには

  • channels:history
  • chat:write:user
  • groups:history
  • im:history
  • mpim:history
  • reactions:write

を設定して下さい。

デプロイ

次にAWSリソースの作成とLambdaのデプロイを行います まずはパラメータストアを作成します

aws cloudformation create-stack --stack-name <適当なスタック名> --template-body  file://parameter_store.yml

作成したパラメータストアに先ほど作成したSlackアプリのクライアントIDとシークレットを保存します

aws ssm put-parameter --name SLACK_CLIENT_ID --value <SlackのクライアントID> --type String --overwrite
aws ssm put-parameter --name SLACK_CLIENT_SECRET --value <Slackのクライアントシークレット> --type String --overwrite

KMSのキーを作成します。 本来はCloudFormationでやりたいのですが

  • KMSのキーポリシーでLambda実行ロールにEncrypt,Decrypt等の許可を設定したい
  • Lambda実行ロールにKMSキーの利用許可を設定したい

と循環参照の問題が解決できないので、CloudFormationは使わずにAWS Cliと手作業で作成していきます。

aws kms create-key

後ほど使用するので、作成されたキーIDを控えておいて下さい。

続いてLayerの準備をします。

pip install -t layer/python slackclient

これでSAMのデプロイ時にlayerの作成と紐付けまでセットで行えます。

パッケージ処理です

sam package --template-file sam.yml --output-template-file output.yml --s3-bucket <デプロイに使える適当なS3バケット>

続いてデプロイです。

sam deploy --stack-name slack-blog --template-file output.yml --capabilities CAPABILITY_NAMED_IAM --parameter-overrides KmsKeyId=<先ほど作成したKMSのキーID>

デプロイ出来たらKMSのキーポリシーに作成されたLambda実行ロールの利用権限を追加しておきましょう。以下のポリシーを追加します。

{
  "Sid": "Allow use of the key",
  "Effect": "Allow",
  "Principal": {
      "AWS": <SAMで作成されたLambda実行ロールのARN>
  },
  "Action": [
      "kms:Encrypt",
      "kms:Decrypt",
      "kms:ReEncrypt*",
      "kms:GenerateDataKey*",
      "kms:DescribeKey"
  ],
  "Resource": "*"
}

スラッシュコマンドの設定

[小ネタ]Slack絵文字をスタンプにして最高の体験をしよう!

先ほどと同様にこちらを参考にURL等を設定します。

  • アプリの「Redirect URLs」に<作成されたAPI GWのエンドポイント/auth>を設定
  • スラッシュコマンドの「Request URL」に<作成されたAPI GWのエンドポイント>を設定

川柳マスタ登録

最後にDynamoDBに川柳マスタを登録します。 マスタメンテナンスの機能は特に設けていないので、心を込めて1件ずつ手入力します。 マスタデータは以下のようなデータ構造です。

{
  "command_name": "go_nanban",
  "emoji": [
    ":nanbantei_nan:",
    ":nanbantei_ban:",
    ":nanbantei_tei:",
    ":iku:"
  ]
}

command_nameがパーティションキーで、emojiという属性に絵文字コードのリストを登録しています。

使ってみる

これで準備完了です。 インストール用のURLからスラッシュコマンドをインストールします。

使ってみます。 /SLASH_COMMAND nanbanntei

投稿できました!!

作ってみて

α版として社内公開したところ、大変好評のようです。

Slack職人からの喜びの声
健康面にもメリットが
リアクションの速度に恐れおののく大阪オフィス民
リアクションの速度に恐れおののくCX事業本部

作ってみた感想と今後の展望

Slack職人たちの生産性向上に寄与できたようで、良かったなーという感想です。が、同時にいくつか課題も見えてきました。

3秒以内にレスポンスを返せない

Slackの仕様でスラッシュコマンドのリクエストに対して3秒以内にレスポンスを返す必要があります。しかし、リアクションのAPIを直列に実行すると多くの場合は3秒以内に処理が完了しません。そのため、川柳としては問題ないのにSlackからは operation_timeout のレスポンスが返ってきます。

こちらに関しては、

  • スラッシュコマンドの要求を受け付けるLambda
  • 川柳を投稿するLambda

を分割して、スラッシュコマンドの要求を受け付けるLambdaから実際に川柳を投稿するLambdaを非同期実行。スラッシュコマンドの要求を受け付けるLambdaはさっさとレスポンスを返すようにすれば解消できそうだったのですが、実装が面倒だったのでまずはα版をリリースして使ってもらうことを優先したかったのと、エラーの通知は自分にしか見えないことから、エラー通知を無視してもらうということで諦めました。

リアクションの順番がずれる

リアクションは絵文字の1つ1つが独立した投稿であり、並び順が非常に重要となります。 「南蛮亭」とリアクションしたいのに「蛮亭南」と並んでたら意味が分かりませんよね?しかし、残念ながら実際にはリアクションの順番はズレてしまいます。

  • PCで見たら意図通り並んでいるのにスマホから見たら並び順がおかしい
  • 意図通り並んでたはずなのに、時間を開けてから再度見返すと並び順が変わっている

といったことが起こってしまうのです。 SlackのAPI実行処理は同期実行にして、間にSleepを挟んだりもしましたが、最終的に問題が解消できませんでした。 こちらの問題については、恐らくSlack側の仕様だろうということで諦めました。Slack川柳に限らず、PCから見た場合とスマホから見た場合で見え方が異なることはしばしば起こっているので。。

投稿とリアクションでサブコマンドを分けるべきか?!

今回作成したスラッシュコマンドは、オプションの数値有無によって投稿とリアクションを切り分けつつ、川柳マスタは同じものを利用しました。しかし、本来投稿とリアクションには以下のような違いがあります。

  • 投稿は同じ絵文字を何度も繰り返し利用できる
  • 投稿には絵文字以外のテキストも含むことができる

具体例をあげると、以下のような川柳?は投稿ならではの特権で、リアクションでは実現できません。

投稿は/SLASH_COMMAND post SUB_COMMAND 、リアクションは/SLASH_COMMAND react SUB_COMMAND Nというコマンド体系にして、ぞれぞれの川柳を別のマスタで管理するべきかもしれません。しかし、サブコマンドが増えるとスラッシュコマンドの入力コストが高くなり、本来実現したかった運用負荷軽減の効果が弱まってしまいます。 スラッシュコマンド自体を2つに分けることを検討した方が良いかもしれません。

まとめ

思わず「暇か?」とツッコミたくなるSlack職人を抱える企業においては、Slack川柳をスラッシュコマンド化することで生産性向上が見込めそうです。同じような境遇の方々の参考になれば幸いです。