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

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

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

![図1.png](https://qiita-image-store.s3.amazonaws.com/0/239549/a2ccff49-319b-71d7-1937-f6cc1abac986.png)
![図2.png](https://qiita-image-store.s3.amazonaws.com/0/239549/b6b6cfb8-ad97-8b82-84f5-008b9f5f62c9.png)

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

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


#システム概要
![図6.png](https://qiita-image-store.s3.amazonaws.com/0/239549/2f0b11b8-0c73-7fe1-43a9-f8210a220c6c.png)

##(A) Slackへの投稿内容をGoogle Sheetsに仮置き
いつ話しかけられても答えられるように、投稿内容を仮置きしておきます。
SlackのOutgoing Webhookの機能を使用して投稿内容を出力し、Google Apps Scriptでそれを受けて、Google Sheetsに記入します。
<img width="1189" alt="スクリーンショット 2018-09-04 0.08.02.png" src="https://qiita-image-store.s3.amazonaws.com/0/239549/ab870ef5-d487-3ce7-5bc8-26e1d250a919.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で子供の宿題管理をする](https://qiita.com/sho0211/items/6a88ffa473980a504956)

#動作例

<img width="448" alt="スクリーンショット 2018-09-08 0.56.05.png" src="https://qiita-image-store.s3.amazonaws.com/0/239549/3f505e86-7a6b-1596-9f11-9bb536478eeb.png">
<img width="440" alt="スクリーンショット 2018-09-08 0.56.20.png" src="https://qiita-image-store.s3.amazonaws.com/0/239549/d74c0292-4397-0687-4d32-a26131100184.png">
<img width="445" alt="スクリーンショット 2018-09-08 0.56.33.png" src="https://qiita-image-store.s3.amazonaws.com/0/239549/8ccf96df-c753-67f6-f7c9-83b1200337a7.png">

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


#以下詳細----

#(A) Slackへの投稿内容をGoogle Sheetsに仮置き
##(A)-1.  Slackの設定
カスタムインテグレーションの発信Webフック（Outgoing webhook）で、下のように設定します。
![図4.png](https://qiita-image-store.s3.amazonaws.com/0/239549/7bbdc953-1751-2235-9ae8-3c8510f5b525.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に書き込みます。


```javascript:コード.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](https://qiita-image-store.s3.amazonaws.com/0/239549/b75e2fbe-bd65-6736-b6e3-3b999ddf0e0b.jpeg)

上の図で、四角２，３個の色付きのかたまりがあります。このうち左端の実線の四角がかたまり（Intent）の名前です。
それ以外（真ん中と右）の点線の四角は、それぞれInput Context, Output Contextを表しました。
Contextは文脈を考慮して会話するためのものです。
今回の場合、たとえばチャンネル名である「ニュース」という言葉をユーザーが言ったとすると、それが1つ目にユーザーが言ったチャンネル名なのか、2つ目なのかを区別したいのです。
そのために、どの質問をユーザーに対して言ったあとの発言なのかという情報が必要です。
このあたり（というかDialogflow全般）について、下記ページの解説がわかりやすいと思います。
[Dialogflow入門](https://qiita.com/kenz_firespeed/items/0979ceb05e4e3299f313#%E5%AF%BE%E8%A9%B1%E7%9A%84intent)


実際のDialogflowの画面は次のとおりです。
まずIntentの一覧の画面はこちら：
<img width="968" alt="スクリーンショット 2018-09-04 0.21.41.png" src="https://qiita-image-store.s3.amazonaws.com/0/239549/2c2f6b65-a678-ee3e-0b97-458df8c182e0.png">
矢印が表示されているIntentとそうでないものがありますが、原因はよくわかりません。。。気にしなくて良いと思います。
上の四角の図では省略していたものもあり、Intentの数はけっこう多いです。



次に、Intentの詳細の画面です。一例として、「1 Channel Name 1」というIntentの場合はこちらの通りです（他は省略）：
<img width="973" alt="スクリーンショット 2018-09-05 0.51.18.png" src="https://qiita-image-store.s3.amazonaws.com/0/239549/2cb1a108-f7bf-d1fb-7865-5f24780d390b.png">
<img width="970" alt="スクリーンショット 2018-09-05 0.51.34.png" src="https://qiita-image-store.s3.amazonaws.com/0/239549/dd691059-13f0-c208-d257-db38a991d8a6.png">
上の例ではResponsesは空ですが、簡単に固定の返答を返すだけのものは、Responsesの欄に記載して、Fulfillmentは無効にします。

### Entity
聞き取り間違いなどを補正したり、似た言葉で同じ意味のものをまとめるために、次の通り設定します。
<img width="963" alt="スクリーンショット 2018-09-04 0.21.55.png" src="https://qiita-image-store.s3.amazonaws.com/0/239549/14413bb9-7348-93c7-31ff-13efbc3b91fd.png">


### Fullfilment
Webhookの設定です。
Amazon API Gateway経由でLambdaに送信するので、Gatewayの情報を書きます。
![図10.png](https://qiita-image-store.s3.amazonaws.com/0/239549/68435fc3-d0ae-cf44-4287-51fc6d6d89e4.png)
メモ）
URLはこんな形です：　https://XXXXXXXXXXXXX.execute-api.us-XXXX-X.amazonaws.com/stage/resource_name
↑最後のソース名を入れるのを忘れずに

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

```python: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環境構築](https://dev.classmethod.jp/cloud/aws/getting-start-api-gateway/)
メモ）
API Gatewayの設定変更時は、都度デプロイすることを忘れずに

##(B)-4. Google Apps Script, Google Sheets
DialogflowからIntentやキーワードを受け取って、Google Assistantにしゃべらせる文章を返します。

```javascript:コード.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ヶ月くらいかかってしまった。。。