ちょっと話題の記事

Slack上でなりきりチャットを実現する

たまには自分以外の何かになりたいと思いませんか? 犬だったり宇宙人だったり。 そんな世界をSlack上で実現すべくAppを作成していきます。
2021.07.22

なりきりとは

なりきりとはWikipediaによれば以下のようなものです。

なりきりとは、インターネットの掲示板やチャット、メールなどを使って、既存の漫画、アニメ、ゲームやまたは全くのオリジナルに設定された世界のキャラクターや、実在の有名人、動物などになりきって、レスや会話を楽しむことである。

今回は以下のようなファンシーなチャンネルを実現すべくSlackのチャンネルに代理で投稿してくれるBotを作成します。

仕様の整理

「なりきる」のに必要な機能として以下の3つを実装します。

  • 名前の設定
  • アイコンの設定
  • メッセージの投稿

Slackのスラッシュコマンドを利用してチャットができるようにします。 なので以下の3つのコマンドが動くように実装していきます。

  • /name 名前を設定します
    • 例:/name オウム
  • /icon Slack上の絵文字をアイコンとして設定します
    • 例:/icon :parrot:
  • /post メッセージを投稿します
    • 例:/post こんにちは

なぜオウムかというとオウムは物真似をするからです。 よってこのSlack AppはParrotとします

ちなにみにスラッシュコマンドはSlack上において/から始まる特定の文字列を入力して投稿するとメッセージではなく何らかのアクションが実行されるというものです。 大きく分けて最初から使用できるものとユーザーが独自に定義したものがあります。 例えば今回は/post こんにちはと入力するとBotが「こんにちは」と代理で投稿してくれるコマンドを実装します。

アーキテクチャ

構成図は以下のようになります。

スラッシュコマンドはHTTPでPOSTリクエストを送ることができるので、AWSのAPI Gateway+Lambdaを利用してバックエンドを実装します。 ユーザーのアイコンや名前の設定を保存しておく場所としてDynamoDBを利用します。

実装

Lambdaを使うので今回はSAMを用いて開発を行っていきます。 今回は簡略化のためLambda⇄他のAWSサービス間のエラーハンドリングは省略しています。

utils

最初に各Lambda関数で使いそうな便利系の関数を実装していきます。

ユーザーの設定

utils/user.py

import os
import boto3

USER_SETTING_TABLE = os.environ.get('USER_SETTING_TABLE')

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(USER_SETTING_TABLE)

def get_user_setting(user_id):
    res = table.get_item(
        Key={'userId': user_id}
    )

    if 'Item' not in res:
        return {
            'userId': user_id,
            'name': 'Parrot Bot',
            'iconEmoji': ':parrot:'
        }

    return res['Item']

def repr_setting_table(setting):
    return f'''現在の設定
名前     : {setting['name']}
アイコン  : {setting['iconEmoji']}
    '''

DynamoDBのユーザー設定周りです。

ポイントは以下の通りです。

  • Table操作用のインスタンスを作成
  • ユーザーの設定を取得する関数
    • ユーザーの設定がなければデフォルトの設定を返す

認証

utils/authorizer.py

from functools import wraps
import json

from slack_sdk.signature import SignatureVerifier
from utils.secret import SECRETS

verifier = SignatureVerifier(SECRETS['SIGNING_SECRET'])

def auth(func):
    @wraps(func)
    def wrapper(event, context):
        try:
            body = event['body']
            timestamp = event['headers']['X-Slack-Request-Timestamp']
            signature = event['headers']['X-Slack-Signature']
        except KeyError as e:
            return {
                "statusCode": 400,
                "headers": {
                    "Content-type": "text/json"
                },
                "body": json.dumps({
                    "message": "Bad request"
                }),
            }

        if not verifier.is_valid(body, timestamp, signature):
            return {
                "statusCode": 401,
                "headers": {
                    "Content-type": "text/json"
                },
                "body": json.dumps({
                    "message": "Unauthorized"
                }),
            }

        return func(event, context)

    return wrapper

特定のSlackAppからしかLambda関数を呼び出せないようにするためのデコレータです。 本来はLamda Authorizerを用いて実装したかったのですが、いろいろと問題がありデコレータとして実装しました。 ポイントは以下の通りです。

  • SlackのSignatureVerifirerを用いてリクエストを検証
  • デコレータを用いることで簡単に認証機能を追加できるようにする

シークレット

utils/secret.py

import os
import json
import boto3 

SECRET_REGION = os.environ.get('SECRET_REGION')
SECRET_NAME = os.environ.get('SECRET_NAME')

secrets = boto3.client('secretsmanager', region_name=SECRET_REGION)

SECRETS = json.loads(secrets.get_secret_value(
    SecretId=SECRET_NAME
)['SecretString'])

今回はSlackのトークンを使用するのでその保存先としてAWS SecretsManagerを利用します。 ここはシンプルで、SecretsManagerから値を読み込んでおくだけです。

API

こからは各APIを実装していきます。

name

import json
import urllib

from utils.user import table, get_user_setting, repr_setting_table
from utils.authorizer import auth

@auth
def lambda_handler(event, context):
    body = urllib.parse.parse_qs(event['body'])
    name = body['text'][0]
    user_id = body['user_id'][0]

    setting = get_user_setting(user_id)
    setting = {**setting, **{'name': name}}
    table.put_item(
        Item=setting
    )

    return {
        "statusCode": 200,
        "headers": {
            "Content-type": "application/json"
        },
        "body": json.dumps({
            "text": repr_setting_table(setting),
        }),
    }

ユーザの名前を設定するための関数です。 ポイントとしては以下の通りです。

  • @authデコレータで認証を実装
  • put_itemを用いることでアップサートを行う
    • 現在の設定の一部をアップデートして新しい設定をつくる
    • put_itemでDynamoDBに反映
  • レスポンスとして現在の設定を返す

ここで注意したいのはput_itemは該当するレコードがなければ作成、あれば全てのAttributeを置き換えるということです。 一部だけ更新ということができないので毎回全てのAttributeを指定して置き換えています。 本来はupdateの方を用いて実装するべきでしょうが、コードの簡略化のために今回はこうしました。 Attributeの数も少ないのでそこまで負荷はかからないと思います。

icon

set_icon.py

import json
import urllib
import re

from utils.user import table, get_user_setting, repr_setting_table
from utils.authorizer import auth

EMOJI = re.compile('^(\s| )*(:[a-zA-Z0-9\-\_]+:)(\s| )*$')


def parse_emoji(text):
    m = EMOJI.match(text)
    if m is None:
        return None

    return m.group(2)

@auth
def lambda_handler(event, context):
    body = urllib.parse.parse_qs(event['body'])
    icon_emoji = body['text'][0]
    user_id = body['user_id'][0]

    icon_emoji = parse_emoji(icon_emoji)

    if icon_emoji is None:
        return {
            "statusCode": 200,
            "headers": {
                "Content-type": "application/json"
            },
            "body": json.dumps({
                "text": "絵文字として不適切なフォーマットです",
            }),
        }

    setting = get_user_setting(user_id)
    setting = {**setting, **{'iconEmoji': icon_emoji}}
    table.put_item(
        Item=setting
    )

    return {
        "statusCode": 200,
        "headers": {
            "Content-type": "application/json"
        },
        "body": json.dumps({
            "text": repr_setting_table(setting),
        }),
    }

ほとんどset_name.pyと同じですが、こちらはアイコン用の絵文字の抽出を行っています。 正規表現を用いて、:parrot:のようなSlack上で使用する絵文字の文字列を切り出しています。 空白が前後にあっても大丈夫なように正規表現を設定しました。 アイコンに使用できる絵文字として適さない場合はSlackに返信するようにしています。 この時ステータスコードが200でないと弾かれてしまいます。

message

post_message.py

import json
import urllib
import slack_sdk

from utils.user import get_user_setting
from utils.secret import SECRETS
from utils.authorizer import auth

slack = slack_sdk.WebClient(token=SECRETS['API_TOKEN'])

@auth
def lambda_handler(event, context):
    body = urllib.parse.parse_qs(event['body'])
    message = body['text'][0]
    channel = body['channel_id'][0]
    user_id = body['user_id'][0]
    setting = get_user_setting(user_id)

    user_name = setting['name']
    icon_emoji = setting['iconEmoji']

    slack.chat_postMessage(
        channel=channel,
        text=message,
        icon_emoji=icon_emoji,
        username=user_name,
    )

    return {
        "statusCode": 200,
        "headers": {
            "Content-type": "application/json"
        },
        "body": json.dumps({
            "text": "投稿に成功しました",
        }),
    }

Slackのチャンネルにメッセージを投稿するAPIです。 ここでreturnしているのはスラッシュコマンドのレスポンスになります。 「なりきり」状態で投稿しているのはslack.chat_postMessageの部分になります。 ここではアイコンとしてSlack上の絵文字を設定し、名前もAppの名前とは別の名前で投稿することができます。

デプロイする

すべての関数の実装が終わったのでSAMを用いてデプロイします。

template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Slack Parrrot Bot

Globals:
  Function:
    Timeout: 3
    CodeUri: parrot/
    Runtime: python3.8
    Environment:
      Variables:
        SECRET_REGION: ap-northeast-1
        SECRET_NAME: dev/slackbot/parrot
        USER_SETTING_TABLE: !Select [1, !Split ['/', !GetAtt ParrotUserSettingTable.Arn]]

Resources:
  PostMessage:
    Type: AWS::Serverless::Function 
    Properties:
      Handler: post_message.lambda_handler
      Events:
        Parrot:
          Type: Api 
          Properties:
            Path: /message
            Method: post
      Policies:
        - AWSSecretsManagerGetSecretValuePolicy:
            SecretArn: "arn:aws:secretsmanager:ap-northeast-1:861215765426:secret:dev/slackbot/parrot-NnIQs8"
        - DynamoDBReadPolicy:
            TableName: !Select [1, !Split ['/', !GetAtt ParrotUserSettingTable.Arn]]

# 中略

  ParrotUserSettingTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: ParrotUserSetting
      AttributeDefinitions:
        - AttributeName: userId
          AttributeType: S
      KeySchema:
        - AttributeName: userId
          KeyType: HASH
      BillingMode: PAY_PER_REQUEST

Outputs:
  ParrotBotApi:
    Description: "API Gateway endpoint URL"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"

ここではLambda FunctionをAPI Gatewayに結びつけ、DynamoDBのテーブルを作成しています。 Lambda Functionの設定はほとんど同じなので割愛します。 ここでのポイントは以下の通りです。

  • 全体で共通する環境変数はGlobalに設定する
  • Secret ManagerとDynamoDBの権限をポリシーで管理

SlackのAppを作る

バックエンドの実装は終わったのでSlack Appを作成しワークスペースに導入します。 ここからAppを作成し設定していきます。

スラッシュコマンドを作る

以下のように3つのコマンドを作成します。 各コマンドは次のように設定します。 エンドポイントは先ほど作成したAPI Gatewayのエンドポイントを指定してください。 その際、各APIのパスも指定してください。

SetNameのエンドポイント

https://XXXXXXXX.execute-api.ap-northeast-1.amazonaws.com/Prod/name

OAuthの権限を設定

Appをワークスペースに入れる前に権限を設定しましょう。 今回は名前やアイコンを変更して投稿するので少し広い権限を付与しています。

トークンをSecrets Managerに登録

ここではSigning SecretとOAuthのトークンを取得します。 Singing SecretはリクエストがこのAppから送られてきたことを検証するのに使います。 OAuthのトークンはLambdaがSlackにメッセージを投稿するのに必要です。

以下の場所からトークンを入手します。

Singing Secret

OAuthトークン

Secrets Managerは以下のように設定します。

Appをワークスペースに入れる

最後にAppをワークスペースに追加して、チャンネルに参加させれば全て完了です。

OAuthの設定のページからワークスペースに追加します。

AppはSlackのチャンネルの詳細から以下のボタンを押せば追加できます。

実際に動かしてみる

アイコンと名前を設定します。

メッセージを投稿します。

感想

Slackのアプリ制作やサーバーレスについての理解が深まったと思います。 SlackのpostMessageAPIがアイコンや名前まで変更できるのは注意が必要だなと思いました。 一応、Slack上では横にAppの表示が出てきますが注意してないと偽物に騙されてしまうかもしれません。 こういったことを防ぐためにもシークレットの管理を徹底し、適切な権限を設定してAppを運用することが大切だということを再認識しました。