はじめに
皆さんリモートワークしていますか?弊社では2月中旬から全社的なリモートワークが始まり、この記事を書いている現在(2020/03/10)も引き続きリモートワークをしています。
本記事はリモートワーク時の電話対応をスムーズにストレスなくするべく、AWS Lambda・API Gatewayを使用してbotを開発しましたよというお話です。(先人としてGASを使ったbotを開発された方がいらっしゃいました。)
関連ツール&技術
今回紹介するbotの関連項目です。
- Slack
- AWS
- API Gateway
- Lambda
- S3
- Serverless Framework
- Python3.7
リモート時の電話対応について
リモートワークを実施する場合に、電話対応をどうしよう…オフィスに誰もいない?ということで弊社ではfondeskを導入しました。
fondeskとはオフィスへの電話対応を代行して、各種ツール(メールとかSlackとかとか)へ通知してくれるサービスです。
(導入方法・設定等は割愛します)
Slackへの通知
弊社では社内コミュニケーションツールとしてSlackを使用しているので、通知先としてSlackを選択しました。このように入電があると指定したチャンネルに通知が飛んできます。
Slack通知の問題点
便利なfondeskですが、チャンネルへの投稿はしてくれますが残念ながら個人宛のメンションは飛びません。公式を見に行くとこんな質問が…電話の宛先によって通知先を変えたり、メンションを付けたりすることはできますか?
宛先に応じた通知機能は多くのご要望をいただいていますが、期待される精度での通知が難しいだけでなく、ユーザーのみなさまにも多くのご負担がかかってしまうため、慎重に検討を進めています。
現時点ではメンションはつかないということで、メンション機能は自前で作ることとします。
先達としてGASを使ったbotを開発された方がいらっしゃいました。今回は自分が慣れている、Slack bot + API Gateway + Lambdaの構成で作成してみようと思います。
botの要件
- fondeskアプリの投稿に反応する
- 投稿内容から宛先を割り出しメンション付きで投稿する
- 宛先を特定できなかった場合
@here
で投稿する
フロー
- fondeskからSlackに通知(メンションなし)が飛ぶ
- チャンネルに招待されたSlackAppがAPI Gatewayにリクエストを飛ばす
- API GatewayがLambdaを起動し、宛先を特定する
- LambdaからSlackに投稿(メンション付き)をする
設定&実装
設定
Slack側
まずは、fondeskの投稿を検知し、APIGatewayにリクエストを飛ばすためのbotを作成します。
お好きな名前でAppsを作成し、以下のような権限を与えておきます。(commandsは別件でslash commandを使用するために付与しています。本記事の内容のみであれば不要です。)
後述するLambda+APIGatewayをserverlessでデプロイした後に、APIGatewayのエンドポイントを貼り付けておきます。
Subscribeするeventsはmessage.channelsです。
設定が終わった後、fondeskが通知するチャンネルにbotユーザーを招待しておきます。
AWS側
基本的にはServerlessでズドンとやります。
LambdaにはS3とファイルのやり取りをしてもらうので、Get/Put Object権限を与えておきます。またLambda起動のトリガーとしてAPI Gatewayを定義しておきます。(x-www-form-urlencodedをjsonにマッピングするテンプレートを置いていますが、本当に必要かは謎です。slash commandと混同している?)
service: fondesk-bot
provider:
name: aws
runtime: python3.7
stage: ${opt:stage, self:custom.defaultStage}
region: ap-northeast-1
memorySize: 512
logRetentionInDays: 30
iamRoleStatements:
- Effect: Allow
Action:
- "s3:GetObject"
- "s3:PutObject"
Resource:
- ${self:custom.S3Bucket.arn}
- ${self:custom.S3Bucket.arn_obj}
functions:
receiver:
handler: receiver.handler
name: fondesker-receiver-${self:provider.stage}
timeout: 10
events:
- http:
path: fondesker/receiver
method: post
integration: lambda
request:
passThrough: WHEN_NO_TEMPLATES
template:
application/x-www-form-urlencoded:
'{"body": $input.json(''$'')}'
environment:
FONDESK_BOT_ID: ${env:FONDESK_BOT_ID}
SLACK_TOKEN: ${self:custom.token.${self:provider.stage}}
S3BUCKET: ${self:custom.S3Bucket.name}
実装
API GatewayがLambdaを起動する。
起動したLambdaに受け渡されるeventの中身はこんな感じになっています。着目するポイントは次の4点です。
- event.type
- event.subtype
- event.bot_id
- event.attachments.fields
1.~3.でfondeskアプリからの投稿であることを特定し、4.の中身で宛先を特定するという流れです。
event.attachements.fields内に宛先項目があれば、良かったのですがありません…どうやら「内容」に入っているようです。
{
"body": {
"token": "<token>",
"team_id": "<team_id>",
"api_app_id": "<app_id>",
"event": {
"type": "message",
"subtype": "bot_message",
"text": "",
"ts": "1583124518.035200",
"bot_id": "<bot_id>",
"attachments": [{
"fallback": "test",
"id": 1,
"ts": 1583124518,
"color": "36a64f",
"fields": [
{
"title": "発信者",
"value": "test",
"short": false
},
{
"title": "折り返し先の電話番号",
"value": "xx-xxxx-xxxx",
"short": true
},
{
"title": "折り返しの連絡",
"value": "必要",
"short": true
},
{
"title": "内容",
"value": "◯◯様宛に入電。先程連絡頂きました件についてです。",
"short": false
}
]
}],
"channel": "<channel_id>",
"event_ts": "1583124518.035200",
"channel_type": "channel"
},
"type": "event_callback",
"event_id": "<event_id>",
"event_time": 1583124518,
"authed_users": ["<user_id>"]
},
...
}
宛先を特定する
「内容」欄から宛先らしい所を抽出することを試みます。正規表現沼や自然言語処理沼には入って行きたくないので、今回は単純に辞書をS3に持つことにして、Lambdaはその辞書を参照し宛先を特定します。
辞書の構造
{
"<SLACKUSER_ID>": ["テスト", "てすと", "test"]
}
内容欄の中から"宛"を検索し、存在した場合"宛"以前を、存在しない場合は内容欄全体を宛先検索に使用します。
if subtype == "bot_message" and slack_event["bot_id"] == FONDESK_BOT_ID:
value = [field["value"] for field in attachments[0]["fields"] if field["title"] == "内容"][0]
if "宛" in value:
value = value.split("宛")[0]
user_ids = lookup_user_id(value)
def lookup_user_id(value: str) -> List[Optional[str]]:
"""
(前提)"SlackユーザーID": [名前表記候補のリスト]という構成のJSONファイルを準備しておく。
params:
value: 宛名が入っている(と期待する)文字列
return:
user_ids: SlackユーザーIDのリスト。該当者がいない場合は空のリスト。
"""
BUCKET_NAME = os.environ["S3BUCKET"]
s3_bucket = S3Bucket("ap-northeast-1", BUCKET_NAME)
name_list = json.load(s3_bucket.get_object_body("synonyms.json"))
user_ids = []
for user_id, candidates in name_list.items():
if any([candidate in value for candidate in candidates]):
user_ids.append(user_id)
return user_ids
LambdaからSlackに投稿(メンション付き)をする
Slackではhere, channelなどは, でメンションできますが、個人にメンションしたいときは<@user_id>としなければいけません。
以前までは<@username>でもメンションできたようですが、変更されたようです。
message = ""
if user_ids:
for user_id in user_ids:
message += f"<@{user_id}> "
message += "さん、"
else:
message += "<!here> "
message += "入電ですよ!"
post_slack(channel, message)
def post_slack(channel: str, message: str) -> None:
"""
Slackにメッセージを投げます
"""
params = {"token": SLACK_TOKEN, "channel": channel, "text": message}
requests.post(SLACK_MESSAGE_URL,
headers={"Content-Type": "application/json"},
params=params)
↓のようにメンションが飛べばOKです。
メンション有り版
メンション無し版
運用してみて気付いたポイント
- 辞書の更新が結構手間
- Slackタイムアウト結構厳しい
辞書の更新に関しては、最初は手作業でやっていたのですが、結構手間なので最終的には辞書の更新機能を別途slash command化して、ユーザーに委ねる形にしました。slash commandに関しては、この記事内に書くと長くなるので別記事にまとめようと思います。(←slash commandの記事を書きました。)
S3にあるJSONファイルをダウンロードするようにした所、botからの投稿が3連打されるという事象が有りました。Slackはレスポンスを3000ms以内に受け取らないとタイムアウトし、3度ほどリトライされるようです。これについては、Lambdaへの割当メモリを増やすという対策でなんとかなっています。どうにもならなくなった場合は、LambdaからLambdaを非同期実行で呼び出すという対策が必要になりそうです。
まとめ
今回はfondeskからの投稿にメンションをつけるbotの紹介をしました。fondeskによってリモートワークしていても電話対応できるし、メンションがつくことで見落としへの対策が取れました。
あとは、普段一緒に仕事をしない別部署の同僚とマージリクエストのやり取りができて、全社的な取り組みっぽくなった点も個人的には良かったです。