#やりたいこと
Google Homeに「OK Google、Slack読んで」と話しかけると、Slackに投稿された内容を読み上げてくれるものをつくります。
私の所属しているSlackグループ(ワークスペース)では、ニュース記事を共有するチャンネルがあります。
チャンネルはnews、 news_sub、 雑談の3つあります。
そこで、ユーザーがチャンネル名を指定すると、そのチャンネルの最新投稿の内容を読み上げてもらいます。
##ポイント
###「Slackに投稿されたとき」でなく、「ユーザーが話しかけたとき」にGoogle Homeが読みあげる
投稿されたときに読んでも近くに誰もいないことも多いので、ユーザーが話しかけたときに読んでもらうことにしました。
「なお、Slackに投稿されたとき」のやり方はこちらの記事にあります。Slackのwebhook設定など参考になりました:
SlackからGoogle Homeを喋らせてみた
###一問一答でなく、対話型UI
ユーザーの話しかけた内容に応じて、Google Homeが返答を変えたり、聞き返したりします。
##(A) Slackへの投稿内容をGoogle Sheetsに仮置き
いつ話しかけられても答えられるように、投稿内容を仮置きしておきます。
SlackのOutgoing Webhookの機能を使用して投稿内容を出力し、Google Apps Scriptでそれを受けて、Google Sheetsに記入します。
上書きしていくので、最新投稿のみが残ります。
##(B) Google Homeに話しかけるとSlack投稿内容を読み上げ
###1. Google Home, Google Assistant
Google Homeに話しかけて、まず自作のGoogle Assistantアプリ(アクション)を起動します。
Google Homeだけでなく、スマホなど、Google Assistantが動くところならどこでも動きます。
(ただし、今回は申請・公開したアプリではないので、自分のアカウントを設定した端末のみです)
###2. Dialogflow
Google Assistantに話しかけられた内容は、まずDialogflowで大まかなカテゴリに振り分けます。
キーワードが含まれているかを判断したり、文脈(それまでの会話内容)を考慮したり、何て言われたか分からないときに対処したりします。
今回は、簡単な返答ならDialogflowから返し(Lambda以降は通さない)、投稿内容をSlack投稿内容を含んだ返答が必要なときはLambda以降にwebhookを出力することにしています。
###3. AWS Lambda
Dialogflowからのwebhookを、Google Apps Scriptにスルーパスします。
###4. Google Apps Script, Google Sheets
受け取ったwebhookの内容に応じて、返答メッセージを返します。
返答メッセージには、Google Sheetsに仮置きされたSlack投稿内容を含めたりします。
###なぜLambdaを使う?
Lambdaなしで、DialogflowからGoogle Apps Scriptへ直接webhookを渡せたらよかったのですが、できないようでした。
そこで、スルーパスしてくれるものが必要です。
他のサービスとともにGoogleに統一するならCloud Functionを使えばよいと思いますが、この手のサービスは初めてだったので、使っている人が多そうなLambdaにしました。
このあたりは(というか他の部分も全体的に)こちらの記事を参考にさせてもらいました、ありがとうございます:
google homeで子供の宿題管理をする
#動作例
スマホで動かすときはこんな感じで表示されます。
一方、Google Homeで動かす場合には当然ながら画面がないのです。
今回のアプリケーションでは、画面がないとしんどいですよね、長文ですし。
#以下詳細----
#(A) Slackへの投稿内容をGoogle Sheetsに仮置き
##(A)-1. Slackの設定
カスタムインテグレーションの発信Webフック(Outgoing webhook)で、下のように設定します。
URL: 下で設定するGoogle Apps ScriptのURLにします。
トークン: ここで生成したものをGoogle Apps Scriptのスクリプト内で記入します。
残り2つのチャンネルについても同様に設定します。
##(A)-2. Google Apps Scriptの設定
Google Apps Scriptでは2つのwebhookを受け付けます:
・Slack投稿の発信Webフック
・DialogflowからのWebhook
これらを1つのスクリプトにします。
以下は、Slack投稿を受け取る部分のみ抜き出したものです。
Webhookの内容をパースして、ユーザー名、本文、リンク先のURL(ある場合)に分けて、
さらにURLからページタイトルを調べます。
そして、それらをGoogle Sheetsに書き込みます。
//ref: http://lomatov.hatenablog.com/entry/2017/12/15/132941
var TOKEN_SLACK_ZATSUDAN = "XXXXXXXXXXXXXXXXXXXXX";
var TOKEN_SLACK_NEWS = "XXXXXXXXXXXXXXXXXXXXX";
var TOKEN_SLACK_NEWS_SUB = "XXXXXXXXXXXXXXXXXXXXX";
var SHEET_ID = "XXXXXXXXXXXXXXXXXXXXX";
var sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName("シート1");
function URLtoTitle(url) { //ref: https://qiita.com/takoratta/items/75014ccd60240458fd0f
// HTMLの文字コードをしらべる
var response = UrlFetchApp.fetch(url);
var myRegexp_charset1 = /<meta charset="([\s\S]*?)"/i;
var myRegexp_charset2 = /<meta http-equiv="content-type" content="text\/html;charset=([\s\S]*?)"/i; //ex: <meta http-equiv="content-type" content="text/html;charset=shift_jis">
var match_charset1 = myRegexp_charset1.exec(response.getContentText('utf-8'));
if(match_charset1 != null){
var charset = match_charset1[1];
}else{
var match_charset2 = myRegexp_charset2.exec(response.getContentText('utf-8'));
var charset = match_charset2[1];
}
// HTMLのページタイトルをしらべる
var myRegexp_title = /<title>([\s\S]*?)<\/title>/i;
//var match_title = myRegexp_title.exec(response.getContentText('shift-jis'));
var match_title = myRegexp_title.exec(response.getContentText(charset));
var title = match_title[1];
title = title.replace(/(^\s+)|(\s+$)/g, "");
return(title);
}
function setValueSlack(e){ //ref: http://lomatov.hatenablog.com/entry/2017/12/15/132941
//JSONファイルの値からどのチャンネルかをしらべて,それによって入力する行をきめる
switch(e.parameter.channel_name){
case "雑談":
row = row_zatsudan;
break;
case "news":
row = row_news;
break;
case "news_sub":
row = row_news_sub;
break;
default:
row = row_default;
}
sheet.getRange(row, col_e).setValue(e);
//SpreadsheetにSlack投稿内容のユーザー名,テキスト,そこに含まれるURLのタイトルなどを書きこむ
var user_name = e.parameter.user_name; // 投稿者
var text = e.parameter.text.replace(/<(.+?)\|(.+?)>/g,"$2"); // 投稿内容の本文
// 投稿日時
var timestamp = e.parameter.timestamp;
var d = new Date( timestamp * 1000 );
var year = ('0000' + d.getFullYear()).slice(-4);
var month = ('00' + (d.getMonth() + 1)).slice(-2);
var day = ('00' + d.getDate()).slice(-2);
var hour = ('00' + d.getHours()).slice(-2);
var min = ('00' + d.getMinutes()).slice(-2);
var sec = ('00' + d.getSeconds()).slice(-2);
var dt = year + '-' + month + '-' + day + ' ' + hour + ':' + min + ':' + sec;
if(user_name){
sheet.getRange(row, col_user_name).setValue(user_name);
}
if(text){
sheet.getRange(row, col_text).setValue(text);
}
if(dt){
sheet.getRange(row, col_dt).setValue(dt);
}
var url = text.substring(text.indexOf("<")+1,text.indexOf(">"));
if(url){
var title = URLtoTitle(url);
var text_without_url = text.replace(url, "");
var text_without_url = text_without_url.replace("<", "");
var text_without_url = text_without_url.replace(">", "");
sheet.getRange(row, col_url).setValue(url);
sheet.getRange(row, col_url_title).setValue(title);
sheet.getRange(row, col_text_without_url).setValue(text_without_url);
}else{
sheet.getRange(row, col_url).setValue("");
sheet.getRange(row, col_url_title).setValue("");
sheet.getRange(row, col_text_without_url).setValue(text);
}
}
function doPost(e) {
//Slack投稿の場合
if (e.parameter.token == TOKEN_SLACK_ZATSUDAN || e.parameter.token == TOKEN_SLACK_NEWS || e.parameter.token == TOKEN_SLACK_NEWS_SUB) {
setValueSlack(e);
}
sheet.getRange(row_dialogflow, col_e).setValue(e);
var request = JSON.parse(e.postData.getDataAsString());
sheet.getRange(row_dialogflow, col_request).setValue(request);
}
メモ)
スクリプト変更時には、都度「公開」をし直すことを忘れずに
#(B) Google Homeに話しかけるとSlack投稿内容を読み上げ
##(B)-1. Google Home, Google Assistant, Actions on Googleの設定
Google Home自体には設定は不要です。
Actions on Googleで、「Actions」にDialogflowを設定したり、「Invocation」(何て言ったらアプリが起動するか)を設定したりします。
特別なことはしていないので、詳細は省略します。
それにしても今回は、Actions on GoogleやらDialogflowやら登場人物が多くいて、それぞれで使う専門用語が多くあり、慣れるまでは辛かったです。。。
##(B)-2. Dialogflow
Dialogflowには、Intent, Entity, Fullfilmentといった設定項目があります。
Intent
ここが対話型UIのキモです。
まず下図のように、会話の流れを想定します。
上の図で、四角2,3個の色付きのかたまりがあります。このうち左端の実線の四角がかたまり(Intent)の名前です。
それ以外(真ん中と右)の点線の四角は、それぞれInput Context, Output Contextを表しました。
Contextは文脈を考慮して会話するためのものです。
今回の場合、たとえばチャンネル名である「ニュース」という言葉をユーザーが言ったとすると、それが1つ目にユーザーが言ったチャンネル名なのか、2つ目なのかを区別したいのです。
そのために、どの質問をユーザーに対して言ったあとの発言なのかという情報が必要です。
このあたり(というかDialogflow全般)について、下記ページの解説がわかりやすいと思います。
Dialogflow入門
実際のDialogflowの画面は次のとおりです。
まずIntentの一覧の画面はこちら:
矢印が表示されているIntentとそうでないものがありますが、原因はよくわかりません。。。気にしなくて良いと思います。
上の四角の図では省略していたものもあり、Intentの数はけっこう多いです。
次に、Intentの詳細の画面です。一例として、「1 Channel Name 1」というIntentの場合はこちらの通りです(他は省略):
上の例ではResponsesは空ですが、簡単に固定の返答を返すだけのものは、Responsesの欄に記載して、Fulfillmentは無効にします。
Entity
聞き取り間違いなどを補正したり、似た言葉で同じ意味のものをまとめるために、次の通り設定します。
Fullfilment
Webhookの設定です。
Amazon API Gateway経由でLambdaに送信するので、Gatewayの情報を書きます。
メモ)
URLはこんな形です: https://XXXXXXXXXXXXX.execute-api.us-XXXX-X.amazonaws.com/stage/resource_name
↑最後のソース名を入れるのを忘れずに
##(B)-3. AWS Lambda
Dialogflowからのwebhookを、Google Apps Scriptにスルーパスします。
#ref: https://qiita.com/sho0211/items/6a88ffa473980a504956
import urllib.request, json
def lambda_handler(event, context):
url = "https://script.google.com/macros/s/XXXXXXXXXXXXXXXXXXXXXXXXXXXXexec" # GASのエンドポイント
headers = {"Content-Type": "application/json; charset=utf-8" }
body = json.loads(event["body"])
body["parameter"] = {"token": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"}
body = json.dumps(body).encode("utf-8")
request = urllib.request.Request(url, data=body, method="POST", headers=headers)
with urllib.request.urlopen(request) as response:
response_body = response.read().decode("utf-8")
result = {"statusCode": 200, "body": response_body, "headers": headers }
return result
AWS(API Gateway, Lambda)は初めて使ったのですが、こちらが非常に参考になりました。
ゼロから作りながら覚えるAPI Gateway環境構築
メモ)
API Gatewayの設定変更時は、都度デプロイすることを忘れずに
##(B)-4. Google Apps Script, Google Sheets
DialogflowからIntentやキーワードを受け取って、Google Assistantにしゃべらせる文章を返します。
//ref: http://lomatov.hatenablog.com/entry/2017/12/15/132941
var TOKEN_DIALOGFLOW = "XXXXXXXXXXXXXXXXXXXXXXX";
var SHEET_ID = "XXXXXXXXXXXXXXXXXXXXXXX";
var sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName("シート1");
function returnAsJSON(obj){
return ContentService.createTextOutput( JSON.stringify(obj)).setMimeType(ContentService.MimeType.JSON);
}
function make_slack_read_text(row, prefix, channel_name_speech, suffix){
var user_name = sheet.getRange(row, col_user_name).getValue();
var url_title = sheet.getRange(row, col_url_title).getValue();
var text_without_url = sheet.getRange(row, col_text_without_url).getValue();
var returnText = prefix+channel_name_speech+"チャンネルの最新投稿を読み上げます。"+user_name+"さんの投稿です:「"+text_without_url+"」。";
if(url_title != ""){
returnText = returnText + "リンクページのタイトルは、「"+url_title+"」です。";
}
returnText = returnText + suffix;
return returnText
}
function request2row(request){
var row = 0;
var channel_name_speech = "";
switch(request.queryResult.parameters.ChannelName){
case "news":
row = row_news;
channel_name_speech = "ニュース";
break;
case "news_sub":
row = row_news_sub;
channel_name_speech = "ニュースサブ";
break;
case "zatsudan":
row = row_zatsudan;
channel_name_speech = "雑談";
break;
}
return [row, channel_name_speech]
}
function search_rest_channel(channel_name_1, channel_name_2){
//残りのチャンネルをしらべる
var past_channel = [channel_name_1, channel_name_2];
//「ニュースサブ」には「ニュース」が含まれるので,順番が重要
if (past_channel.indexOf("ニュースサブ") < 0){ // 存在しない
rest_channel = "ニュースサブ";
}else if (row_past_channel.indexOf("ニュース") < 0){
rest_channel = "ニュース";
}else if (row_past_channel.indexOf("雑談") < 0){
rest_channel = "雑談";
}
return rest_channel
}
function channel_name_speech2row(channel_name_speech){
switch(channel_name_speech){
case "ニュース":
row = row_news;
break;
case "ニュースサブ":
row = row_news_sub;
break;
case "雑談":
row = row_zatsudan;
break;
}
return row
}
function doPost(e) {
sheet.getRange(row_dialogflow, col_e).setValue(e);
var request = JSON.parse(e.postData.getDataAsString());
sheet.getRange(row_dialogflow, col_request).setValue(request);
if (request.parameter.token == TOKEN_DIALOGFLOW) { //GoogleHomeへのボイスコマンドの場合
var prefix = "";
var suffix = "";
var returnText = "";
var row = 0;
var channel_name_speech = "";
var rest_channel = "";
var rest_channel_speech = "";
var previous_row = 0;
var channel_name_1 = sheet.getRange(row_dialogflow_row_channel, col_channel_name_1).getValue();
var channel_name_2 = sheet.getRange(row_dialogflow_row_channel, col_channel_name_2).getValue();
var channel_name_3 = sheet.getRange(row_dialogflow_row_channel, col_channel_name_3).getValue();
sheet.getRange(row_dialogflow, 4).setValue(request.queryResult);
sheet.getRange(row_dialogflow, 5).setValue(request.queryResult.action);
sheet.getRange(row_dialogflow, 6).setValue(request.queryResult.parameters);
sheet.getRange(row_dialogflow, 7).setValue(request.queryResult.parameters.ChannelName);
switch(request.queryResult.action){
case "channel_name_1":
[row, channel_name_speech] = request2row(request);
sheet.getRange(row_dialogflow_row_channel, col_channel_name_1).setValue(channel_name_speech);
sheet.getRange(row_dialogflow_row_channel, col_channel_name_2).setValue("");
sheet.getRange(row_dialogflow_row_channel, col_channel_name_3).setValue("");
suffix = "他のチャンネルも聞きますか?";
returnText = make_slack_read_text(row, prefix, channel_name_speech, suffix);
break;
case "channel_name_1_repeat":
prefix = "もう一度、";
channel_name_speech = channel_name_1;
suffix = "他のチャンネルも聞きますか?";
row = channel_name_speech2row(channel_name_speech);
returnText = make_slack_read_text(row, prefix, channel_name_speech, suffix);
break;
case "other_channel_yes_1_fallback":
prefix = "もう一度お願いします。";
switch(channel_name_1){
case "雑談":
rest_channel_speech = "ニュースかニュース・サブ";
break;
case "ニュース":
rest_channel_speech = "雑談かニュース・サブ";
break;
case "ニュースサブ":
rest_channel_speech = "ニュースか雑談";
break;
}
returnText = prefix + rest_channel_speech + "、どちらのチャンネルにしますか?";
break;
case "other_channel_yes_1":
switch(channel_name_1){
case "雑談":
rest_channel_speech = "ニュースかニュース・サブ";
break;
case "ニュース":
rest_channel_speech = "雑談かニュース・サブ";
break;
case "ニュースサブ":
rest_channel_speech = "ニュースか雑談";
break;
}
returnText = prefix + rest_channel_speech + "、どちらのチャンネルにしますか?";
break;
case "channel_name_2":
[row, channel_name_speech] = request2row(request);
sheet.getRange(row_dialogflow_row_channel, col_channel_name_2).setValue(channel_name_speech);
channel_name_2 = channel_name_speech;
rest_channel = search_rest_channel(channel_name_1, channel_name_2);
suffix = "残りのチャンネルは"+rest_channel+"ですが、こちらも聞きますか?";
returnText = make_slack_read_text(row, prefix, channel_name_speech, suffix);
break;
case "channel_name_2_fallback":
prefix = "もう一度お願いします。";
rest_channel = search_rest_channel(channel_name_1, channel_name_2);
returnText = prefix+"残りのチャンネルは"+rest_channel+"ですが、こちらも聞きますか?";
break;
case "channel_name_2_repeat":
prefix = "もう一度、";
channel_name_speech = channel_name_2;
//残りのチャンネルをしらべる
rest_channel = search_rest_channel(channel_name_1, channel_name_2);
suffix = "残りのチャンネルは"+rest_channel+"ですが、こちらも聞きますか?";
row = channel_name_speech2row(channel_name_speech);
returnText = make_slack_read_text(row, prefix, channel_name_speech, suffix);
break;
case "channel_name_2":
[row, channel_name_speech] = request2row(request);
sheet.getRange(row_dialogflow_row_channel, col_channel_name_2).setValue(channel_name_speech);
channel_name_2 = channel_name_speech;
rest_channel = search_rest_channel(channel_name_1, channel_name_2);
suffix = "残りのチャンネルは"+rest_channel+"ですが、こちらも聞きますか?";
returnText = make_slack_read_text(row, prefix, channel_name_speech, suffix);
break;
case "other_channel_yes_2":
channel_name_speech = channel_name_1;
suffix = "以上です。もう一度聞きますか。";
row = channel_name_speech2row(channel_name_speech);
returnText = make_slack_read_text(row, prefix, channel_name_speech, suffix);
break;
case "other_channel_yes_2_repeat":
prefix = "もう一度、";
channel_name_speech = channel_name_1;
suffix = "以上です。また呼んでくださいね。";
row = channel_name_speech2row(channel_name_speech);
returnText = make_slack_read_text(row, prefix, channel_name_speech, suffix);
break;
}
return returnAsJSON({"fulfillmentText":returnText});
}
}
#おわりに
対話型UIは、想像以上に作り込むのが大変でした。
ユーザーの言ったことを聞き取れなかったときの対処など、細かく考え出すとかなり大変です。
テストも割と時間がかかって面倒でした。Actions on GoogleかDialogflowのシミュレータを使って、音声入力かキーボード入力の好きな方を選べるので、環境はちゃんと用意されてはいるのですが。
今後は、AlexaやClovaの対話型UIも作ってみて、ノウハウを身に着けたいです。
全体としては、おおがかりなシステムになってしまいました。
今回はAWS Lambdaは脇役として使ったのですが、初めてだったので、慣れるのに時間がかかってしまいました。が、今後また使う機会があるだろうし、無駄ではなかったと信じます。
Dialogflow V2になって文字コードの扱いが変わったので、他の方のコードが流用できなかったり、
URLページタイトルを調べるところでも、文字コードを考慮して、処理を追加する必要があったりと、よい勉強の機会になりました。
1ヶ月に1件なにか作って記事を書こうと思っていたのに、4ヶ月くらいかかってしまった。。。