Slack上でなりきりチャットを実現する
なりきりとは
なりきりとは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関数で使いそうな便利系の関数を実装していきます。
ユーザーの設定
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操作用のインスタンスを作成
- ユーザーの設定を取得する関数
- ユーザーの設定がなければデフォルトの設定を返す
認証
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を用いてリクエストを検証
- デコレータを用いることで簡単に認証機能を追加できるようにする
シークレット
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
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
Slackのチャンネルにメッセージを投稿するAPIです。
ここでreturn
しているのはスラッシュコマンドのレスポンスになります。
「なりきり」状態で投稿しているのはslack.chat_postMessage
の部分になります。
ここではアイコンとしてSlack上の絵文字を設定し、名前もAppの名前とは別の名前で投稿することができます。
デプロイする
すべての関数の実装が終わったのでSAMを用いてデプロイします。
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のパスも指定してください。
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のpostMessage
APIがアイコンや名前まで変更できるのは注意が必要だなと思いました。
一応、Slack上では横にApp
の表示が出てきますが注意してないと偽物に騙されてしまうかもしれません。
こういったことを防ぐためにもシークレットの管理を徹底し、適切な権限を設定してAppを運用することが大切だということを再認識しました。