この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
なりきりとは
なりきりとは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のpostMessage
APIがアイコンや名前まで変更できるのは注意が必要だなと思いました。
一応、Slack上では横にApp
の表示が出てきますが注意してないと偽物に騙されてしまうかもしれません。
こういったことを防ぐためにもシークレットの管理を徹底し、適切な権限を設定してAppを運用することが大切だということを再認識しました。