9
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

「OK Google、Slack読んで」 DialogflowとGASで対話型UI

Last updated at Posted at 2018-09-07

#やりたいこと
Google Homeに「OK Google、Slack読んで」と話しかけると、Slackに投稿された内容を読み上げてくれるものをつくります。

私の所属しているSlackグループ(ワークスペース)では、ニュース記事を共有するチャンネルがあります。
チャンネルはnews、 news_sub、 雑談の3つあります。
そこで、ユーザーがチャンネル名を指定すると、そのチャンネルの最新投稿の内容を読み上げてもらいます。

図1.png
図2.png

##ポイント
###「Slackに投稿されたとき」でなく、「ユーザーが話しかけたとき」にGoogle Homeが読みあげる
投稿されたときに読んでも近くに誰もいないことも多いので、ユーザーが話しかけたときに読んでもらうことにしました。
「なお、Slackに投稿されたとき」のやり方はこちらの記事にあります。Slackのwebhook設定など参考になりました:
SlackからGoogle Homeを喋らせてみた

###一問一答でなく、対話型UI
ユーザーの話しかけた内容に応じて、Google Homeが返答を変えたり、聞き返したりします。

#システム概要
図6.png

##(A) Slackへの投稿内容をGoogle Sheetsに仮置き
いつ話しかけられても答えられるように、投稿内容を仮置きしておきます。
SlackのOutgoing Webhookの機能を使用して投稿内容を出力し、Google Apps Scriptでそれを受けて、Google Sheetsに記入します。
スクリーンショット 2018-09-04 0.08.02.png
上書きしていくので、最新投稿のみが残ります。

##(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で子供の宿題管理をする

#動作例

スクリーンショット 2018-09-08 0.56.05.png スクリーンショット 2018-09-08 0.56.20.png スクリーンショット 2018-09-08 0.56.33.png

スマホで動かすときはこんな感じで表示されます。
一方、Google Homeで動かす場合には当然ながら画面がないのです。
今回のアプリケーションでは、画面がないとしんどいですよね、長文ですし。

#以下詳細----

#(A) Slackへの投稿内容をGoogle Sheetsに仮置き
##(A)-1. Slackの設定
カスタムインテグレーションの発信Webフック(Outgoing webhook)で、下のように設定します。
図4.png
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に書き込みます。

コード.gs <Slack投稿を受け取る部分>
//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のキモです。
まず下図のように、会話の流れを想定します。
図9.jpg

上の図で、四角2,3個の色付きのかたまりがあります。このうち左端の実線の四角がかたまり(Intent)の名前です。
それ以外(真ん中と右)の点線の四角は、それぞれInput Context, Output Contextを表しました。
Contextは文脈を考慮して会話するためのものです。
今回の場合、たとえばチャンネル名である「ニュース」という言葉をユーザーが言ったとすると、それが1つ目にユーザーが言ったチャンネル名なのか、2つ目なのかを区別したいのです。
そのために、どの質問をユーザーに対して言ったあとの発言なのかという情報が必要です。
このあたり(というかDialogflow全般)について、下記ページの解説がわかりやすいと思います。
Dialogflow入門

実際のDialogflowの画面は次のとおりです。
まずIntentの一覧の画面はこちら:
スクリーンショット 2018-09-04 0.21.41.png
矢印が表示されているIntentとそうでないものがありますが、原因はよくわかりません。。。気にしなくて良いと思います。
上の四角の図では省略していたものもあり、Intentの数はけっこう多いです。

次に、Intentの詳細の画面です。一例として、「1 Channel Name 1」というIntentの場合はこちらの通りです(他は省略):
スクリーンショット 2018-09-05 0.51.18.png
スクリーンショット 2018-09-05 0.51.34.png
上の例ではResponsesは空ですが、簡単に固定の返答を返すだけのものは、Responsesの欄に記載して、Fulfillmentは無効にします。

Entity

聞き取り間違いなどを補正したり、似た言葉で同じ意味のものをまとめるために、次の通り設定します。
スクリーンショット 2018-09-04 0.21.55.png

Fullfilment

Webhookの設定です。
Amazon API Gateway経由でLambdaに送信するので、Gatewayの情報を書きます。
図10.png
メモ)
URLはこんな形です: https://XXXXXXXXXXXXX.execute-api.us-XXXX-X.amazonaws.com/stage/resource_name
↑最後のソース名を入れるのを忘れずに

##(B)-3. AWS Lambda
Dialogflowからのwebhookを、Google Apps Scriptにスルーパスします。

lambda_function.py
#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にしゃべらせる文章を返します。

コード.gs <Dialogflowの部分>
//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ヶ月くらいかかってしまった。。。

9
17
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?