こんにちは。katoです。
引き続きLINE Botを使ったシステムの構築を行っていきます。
今回はLINE BotとSlackを連携した有人チャットへの切り替えをご紹介したいと思います。
概要
最近では、チャットボットを導入したカスタマーサポートも増えており、一次受付をチャットボットとし、チャットボットにて対応できない領域を有人チャットにて対応するといった運用を行うケースを多く見かけます。
SalesforceのLive Agentなどを利用することで、LINE Bot等のチャットボットから有人チャットへのエスカレーションを行うことが可能ですが、今回はAWSサービスとSlackを利用して、LINE Botにおける有人チャットへの切り替えを試していきたいと思います。
今回構築するシステムは下記のような構成となります。
LINEでの顧客入力に応じてBot対応と有人対応を切り替えます。
有人対応時にはSlackをオペレータ用のチャットコンソールとして利用し、Incoming WebhooksとSlash Commandsを利用してメッセージの送受信を行います。 DynamoDBは有人対応と無人対応を切り替えるためのフラグ情報を格納するために利用します。
手順
SlackのIncoming WebhooksとSlash Commandsのセットアップに関しましては、他の記事にてご説明しておりますので、下記の記事をご参照ください。
Amazon Translate ~Slackを利用した異言語コミュニケーション~ xp-cloud.jp
Slack(Slash Commands) + Lambdaで工数管理 xp-cloud.jp
DynamoDBテーブルを作成します。
今回はプライマリパーティションキーを「userid」としてテーブルを作成します。
ソートキーは不要です。
次にLambda関数を設定していきます。
LINE BotのWebhookとして利用するBot用のLambda関数と、Slash Commandsにて呼び出すpush送信用のLambda関数の2種類が必要となります。
まずはLINE Botから呼び出されるLambda関数の設定を行います。
今回も前回までに作成したLINE Botに機能を追加する形となりますので、追加分以外のコードに関しての説明は省略させていただきます。
import json import boto3 import urllib import re import os rekognition = boto3.client('rekognition') translate = boto3.client('translate') dynamodb = boto3.client('dynamodb') def lambda_handler(event, context): print (event) url = "https://api.line.me/v2/bot/message/reply" method = "POST" headers = { 'Authorization': os.environ['CHANNEL_ACCESS_TOKEN'], 'Content-Type': 'application/json' } if (event['events'][0]['type'] == "postback"): if (event['events'][0]['postback']['data'] == "blog"): message = [ { "type": "text", "text": "ブログをお探しします!", "quickReply": { "items": [ { "type": "action", "action": { "type": "postback", "label": "おすすめ", "data": "Recommended", "displayText": "おすすめ" } }, { "type": "action", "action": { "type": "postback", "label": "キーワード検索", "data": "keyword", "displayText": "キーワード検索" } } ] } } ] elif (event['events'][0]['postback']['data'] == "Recommended"): message = [ { "type": "text", "text": "おすすめはこちら!" }, { "type": "text", "text": "https://xp-cloud.jp/blog/news/" } ] elif (event['events'][0]['postback']['data'] == "service"): message = [ { "type": "text", "text": "どのようなサービスをお探しでしょうか?", "quickReply": { "items": [ { "type": "action", "action": { "type": "postback", "label": "AWS導入", "data": "AWS", "displayText": "AWS" } }, { "type": "action", "action": { "type": "postback", "label": "Backup", "data": "Backup", "displayText": "Backup" } }, { "type": "action", "action": { "type": "postback", "label": "AWS移行", "data": "Migration", "displayText": "Migration" } }, { "type": "action", "action": { "type": "postback", "label": "アプリ開発", "data": "app", "displayText": "app" } }, { "type": "action", "action": { "type": "postback", "label": "監視・運用", "data": "operation", "displayText": "operation" } }, { "type": "action", "action": { "type": "postback", "label": "VPN・専用線", "data": "VPN", "displayText": "VPN" } }, { "type": "action", "action": { "type": "postback", "label": "鳴子", "data": "NARUKO", "displayText": "NARUKO" } } ] } } ] elif (event['events'][0]['postback']['data'] == "AWS"): message = [ { "type": "text", "text": "サービスサイトにご案内します!" }, { "type": "text", "text": "https://xp-cloud.jp/service/#aws_consul" } ] elif (event['events'][0]['postback']['data'] == "Backup"): message = [ { "type": "text", "text": "サービスサイトにご案内します!" }, { "type": "text", "text": "https://xp-cloud.jp/service/#cloud" } ] elif (event['events'][0]['postback']['data'] == "Migration"): message = [ { "type": "text", "text": "サービスサイトにご案内します!" }, { "type": "text", "text": "https://xp-cloud.jp/service/#cloudmigration" } ] elif (event['events'][0]['postback']['data'] == "app"): message = [ { "type": "text", "text": "サービスサイトにご案内します!" }, { "type": "text", "text": "https://xp-cloud.jp/service/#application" } ] elif (event['events'][0]['postback']['data'] == "operation"): message = [ { "type": "text", "text": "サービスサイトにご案内します!" }, { "type": "text", "text": "https://xp-cloud.jp/service/#network" } ] elif (event['events'][0]['postback']['data'] == "VPN"): message = [ { "type": "text", "text": "サービスサイトにご案内します!" }, { "type": "text", "text": "https://xp-cloud.jp/service/#monitoring" } ] elif (event['events'][0]['postback']['data'] == "NARUKO"): message = [ { "type": "text", "text": "サービスサイトにご案内します!" }, { "type": "text", "text": "https://xp-cloud.jp/naruko/" } ] elif (event['events'][0]['postback']['data'] == "keyword"): message = [ { "type": "text", "text": "「検索」の後にスペースを入れてキーワードを入力してください。\n\n例)検索 DynamoDB\n\nキーワードにスペースが含まれる場合にはAND検索となります。" } ] elif (event['events'][0]['postback']['data'] == "yes"): putitem = dynamodb.put_item( TableName="<DynamoDB_Table_Name>", Item={ "userid": { "S": event['events'][0]['source']['userId'] }, "status": { "S": "active" } } ) message = [ { "type": "text", "text": "担当者がご対応させていただきますので、お問い合わせの内容をご入力いただけますでしょうか?" } ] elif (event['events'][0]['postback']['data'] == "no"): message = [ { "type": "text", "text": "失礼致しました。\nお問い合わせの際には「質問」とご入力ください。" } ] elif (event['events'][0]['type'] == "message"): if (event['events'][0]['message']['type'] == "image"): geturl = "https://api.line.me/v2/bot/message/" + str(event['events'][0]['message']['id']) + "/content" getmethod = "GET" request = urllib.request.Request(geturl, method=getmethod, headers=headers) with urllib.request.urlopen(request) as res: body = res.read() detect = rekognition.detect_labels( Image={ "Bytes": body } ) labels = detect['Labels'] print (labels) names = [n.get('Name') for n in labels] if ("Human" in names or "Person" in names): response = rekognition.recognize_celebrities( Image={ "Bytes": body } ) print (response) if (len(response['CelebrityFaces']) == 0): message = [ { "type": "text", "text": "人物を特定できませんでした。\n画像を変えてお試しください。" } ] else: if (len(response['CelebrityFaces'][0]['Urls']) != 0): resurl = response['CelebrityFaces'][0]['Urls'][0] resname = response['CelebrityFaces'][0]['Name'] message = [ { "type": "text", "text": resname }, { "type": "text", "text": resurl } ] else: message = [ { "type": "text", "text": response['CelebrityFaces'][0]['Name'] } ] else: count = 0 message = [] for num in range(len(labels)): if (count < 5): response = translate.translate_text( Text=labels[num]['Name'], SourceLanguageCode='en', TargetLanguageCode='ja' ) messages = { "type": "text", "text": response['TranslatedText'] } message.append(messages) count += 1 else: getitem = dynamodb.get_item( TableName="<DynamoDB_Table_Name>", Key={ "userid": { "S": event['events'][0]['source']['userId'] } } ) print (getitem) if ("Item" in getitem): if (len(getitem['Item']) != 0): if (getitem['Item']['status']['S'] == "active"): question = getitem['Item']['userid']['S'] + " " + str(event['events'][0]['message']['text']) url = os.environ['Webhook_URL'] data = {"username": "LINE Bot", "text": question} print (data) req = urllib.request.Request(url, json.dumps(data).encode()) with urllib.request.urlopen(req) as res: body = res.read() return 0 if (event['events'][0]['message']['text'] == "案内"): message = [ { "type": "text", "text": "こんにちは!ご用件をお伺いできますでしょうか?", "quickReply": { "items": [ { "type": "action", "action": { "type": "postback", "label": "blog", "data": "blog", "displayText": "blog" } }, { "type": "action", "action": { "type": "postback", "label": "service", "data": "service", "displayText": "service" } } ] } } ] elif (re.match("検索", event['events'][0]['message']['text'])): keywords = event['events'][0]['message']['text'].split() if (len(keywords) > 1): keywords.remove("検索") keyword = "+".join(keywords) result = "https://xp-cloud.jp/blog/?s=" + str(keyword) message = [ { "type": "text", "text": "検索結果をご案内します。" }, { "type": "text", "text": result } ] else: message = [ { "type": "text", "text": "入力を正しく処理できませんでした。\n「検索」の後にスペースを入れてキーワードを入力してください。\n\n例)検索 DynamoDB" } ] elif (event['events'][0]['message']['text'] == "質問"): message = [ { "type": "text", "text": "お問い合わせでお間違いなかったでしょうか?", "quickReply": { "items": [ { "type": "action", "action": { "type": "postback", "label": "はい", "data": "yes", "displayText": "はい" } }, { "type": "action", "action": { "type": "postback", "label": "いいえ", "data": "no", "displayText": "いいえ" } } ] } } ] elif (event['events'][0]['message']['text'] == "こんにちは"): message = [ { "type": "text", "text": "こんにちは!" } ] else: message = [ { "type": "text", "text": "ご利用の際には「案内」と入力してください。" } ] else: message = [ { "type": "text", "text": "申し訳ございません。ご入力を正しく受け取ることができませんでした。お手数ですが再度ご入力いただけますでしょうか。" } ] params = { "replyToken": event['events'][0]['replyToken'], "messages": message } request = urllib.request.Request(url, json.dumps(params).encode("utf-8"), method=method, headers=headers) with urllib.request.urlopen(request) as res: body = res.read() return 0
追加したのは「質問」というメッセージ入力に対する処理(382~410行目)とpostbackの「yes/no」の処理(218~242行目)、有人チャットへの移行フラグ判定処理(310~329行目)となります。
「質問」という入力に対してquickReplyを返し、「yes」のpostbackが得られると、DynamoDBにユーザIDと有人対応用のフラグ(status: active)を設定しています。
image以外のメッセージタイプが入力されると、ユーザIDをもとにDynamoDBのアイテム検索が行われます。
アイテムが見つからない、または、statusが「active」でない場合には、通常のBot対応を行い、「active」のstatusが見つかると、入力メッセージをIncoming Webhooks経由で質問としてSlackに通知します。
次にSlash Commandsにて呼び出すpush送信用のLambda関数を設定します。
import boto3 import json import os import urllib import logging dynamodb = boto3.client('dynamodb') slash_command_token = os.environ['slash_command_token'] logger = logging.getLogger() logger.setLevel(logging.INFO) def respond(params, err, res=None): url = "https://api.line.me/v2/bot/message/push" method = "POST" headers = { 'Authorization': os.environ['CHANNEL_ACCESS_TOKEN'], 'Content-Type': 'application/json' } input = params['text'][0] inputs = input.split(None, 1) userid = inputs[0] if (inputs[1] == "end"): putitem = dynamodb.put_item( TableName="<DynamoDB_Table_Name>", Item={ "userid": { "S": str(inputs[0]) }, "status": { "S": "inactive" } } ) resmessage = "お問い合わせ有難う御座いました。\nこれにて本件クローズとさせていただきます。" message = [ { "type": "text", "text": resmessage } ] params = { "to": userid, "messages": message } print (params) else: inputs.pop(0) resmessage = "".join(inputs) message = [ { "type": "text", "text": resmessage } ] params = { "to": userid, "messages": message } print (params) request = urllib.request.Request(url, json.dumps(params).encode("utf-8"), method=method, headers=headers) with urllib.request.urlopen(request) as res: body = res.read() return { 'text': "[response]" + resmessage + "(to " + userid + ")", "response_type": "in_channel" } def lambda_handler(event, context): params = urllib.parse.parse_qs(event['body']) token = params['token'][0] if token != slash_command_token: logger.error("Request token (%s) does not match expected", token) return respond(Exception('Invalid request token')) return respond(params, None)
SlackのSlash Commandsにて送られた情報を取得し、LINE Botのpush APIを利用してメッセージを送信しています。
Botにて利用していたのはreplyTokenを指定した応答メッセージでしたが、有人チャットの際にはユーザIDを指定した送信メッセージを利用します。
応答メッセージと送信メッセージではURLと必要パラメータが少し異なりますので注意してください。
Slash Commandsは下記の形式で入力する形とし、送信先となるユーザIDとメッセージを指定しています。
/slash_commands <userID> <message>
基本的には入力値をLINE Botのpush API URLにPOSTしているだけですが、「end」の入力があった際に有人チャットを終了するためのフラグの無効化処理を入れています。
ここまで設定できたら、API GatewayにSlach Commands用のPOSTメソッドを追加し、Slash Commandsの「URL」にAPI Gatewayの呼び出しURLを設定すれば完了となります。
詳細は省略しますが、API GatewayのPOSTメソッド作成ステージにだけ注意してください。
以前のブログでも記載させていただきましたが、LINE Botに対してPOSTリクエストを実施する場合には、API Gatewayのステージを「/」以外で設定する必要があります。
動作確認
「質問」と入力してみます。
quickReplyで「はい」を選択します。
このタイミングでDynamoDBに「active」のフラグが設定され、以降の入力はSlackに通知されるように動作します。
上記のレスポンスはすべてSlackから送られたものとなります。
Slackに対しては以下のような形でメッセージが送られます。
顧客の送信メッセージ、オペレータの入力内容、顧客への返信メッセージが表示されます。
今回はSlash Commandsの返り値としてresponses_typeを「in_channel」と指定しているため、オペレータの入力内容と返信メッセージの両方が出力されています。
有人チャットを終了する場合には「end」を入力します
顧客に対してはLambda関数に設定したクローズメッセージが送信されます。
このタイミングでDynamoDBには「inactive」のステータスが設定され、以降の入力はBotにて処理されます。
まとめ
LINE BotとSlackを連携したチャットボットと有人チャットの切り替えに関してご紹介しました。
LINEとSlackという広く利用されているサービスを組み合わせることで、実用的でユーザビリティの高いカスタマーサポート用のチャットを、簡単に導入することが可能です。
別途サービスを導入する必要もなく、オペレータの教育コストも抑えることができるかと思います。 QAサポートのエスカレーションなど様々な用途に利用可能かと思いますので、興味のある方はぜひ試してみてください。