Google Homeに「午前休する」って言ったら電話・メールで会社に連絡できるようにする

Google Home Mini が安くなっていたので買いました。

スタートレックが好きな僕としては起動ワードを「コンピューター」にできる Amazon Echo が本命なのですが、全く招待されないので仕方がありません。

radiko ヘビーユーザーなので radiko の再生・停止をボイスコマンドでできるというのも楽しみにしていましたが、radiko プレミアムが使えないので FM802 をかけられないのであまり意味がありません。また、音楽についても Google Music を契約すれば自分の曲もアップロードできるようになるそうなのですが、月 980 円追加するのは Netflix*1 もあるしちょっと…。あとボイスでやりたいことというと家電の操作ですが、Nature Remo を買ってまだ発送されないのでしばらくお預けです。

さて、会社でお勤めの方ですと朝調子悪くてお休みするといったときは連絡されると思います。例えばこういう連絡が必要なシナリオだとします。…あくまでシナリオです。

  • 上司に電話連絡して承認を得る
  • 問題なければメールで全体連絡する

メールの送信だけであれば IFTTT にもありますが、本文に改行入れても改行されません。様式を重んじる日本の企業としてはなかなか厳しいです。 いっそこのフロー全体を作っておき、Google Homeに「午前休する」と言ったら上司に電話連絡して承認を取り、OKもらったらメールしてもらうような仕組みを作ってみました。

フローの設計

このようなフローを組み立てることにしました。

f:id:iseebi:20171210002015p:plain

  • 起動トリガーは、IFTTT からボイスコマンドで受けて Webhook を叩くことにします。
  • Webhook を受けた先では Twilio API を使って上司に電話をかけます
  • 連絡を受ける上司も朝の時間帯は通勤などで忙しいと思います。なので単純に電話を切れば承認、何かキーを押せば要件があるので折り返してもらうというようにします。
  • 承認されたらメールを送ります
  • 結果は自分の LINE に飛ばします。

HTTP で受け取ることになりますが、今回は Azure Functions を使ってみました。Twilio では会話の内容を TwiML という形式の XML で書きますが、一番最初の電話をかけるところでの情報取得のために BLOB ストレージ、上司の返答を保存するためにテーブルストレージも使いました。

Azure Functions について

Azure Functions についてはこの記事からのリンク先にある内容がまとまってて便利でした。

tech.guitarrapc.com

要旨をまとめると。

  • 重要な情報はアプリケーション設定に書き、ConfigurationManager.AppSettings["xxx"] から取得する。
    • 標準な感じのアセンブリはファイルの先頭に #r "System.Configuration"等を書くと参照できる。
  • NuGet パッケージは project.json を作ると読み込める。このときは #r 扶養
  • 実体は 1 関数ごとにフォルダが作られている。App Service エディターを開くとそのフォルダの上の階層に csx ファイルを作ることができ、そのファイルは相対パスでファイルの先頭に #load "..\hoge.csx" でロードでき、共通処理を書ける。

Twilio について

Twilio のポイントをざっと。

  • まず電話番号を取得する。108円/月の維持費がかかる。
  • 通話料金は携帯電話にかけると 16.2 円/分
  • TwiML という XML で発話内容を記述する。最初のコールのときに読み込み先の URL を指定したり、番号プッシュの応答で POST したときに結果返答するときにこの形式で返す。
  • 使った HTTP リクエストのログが全部「プログラマブルVoice」の「ログ」に残るのでデバッグに便利。
  • トライアル中は確認済みの番号にしかかけられない。その確認の電話もAPI課金される
  • アカウントをアクティベートするとトライアルの残高は召し上げになる

実装

では実装を見ていきましょう。

共通処理の準備

まず、Azure Functions は、JSON であれば直接処理できるらしいのですが、Twilio は HTTP フォーム形式でリクエストを送ってきますので、それをパースする仕組みを用意します。

Azure Functions の設定ページから「App Service エディター」を開きます。このあたりにあります。

f:id:iseebi:20171210005631p:plain

WWWROOT 直下にファイルを作成します。

f:id:iseebi:20171210005749p:plain

using System;
using System.Collections.Generic;
using System.Threading.Tasks;

public static async Task<Dictionary<string, string>> ParseHttpForm(this HttpRequestMessage req)
{
    var keyValues = new Dictionary<string, string>();
    var reqString = await req.Content.ReadAsStringAsync();
    foreach (var item in reqString.Split('&'))
    {
        var nameValueItem = item.Split('=');
        var valueStr = Uri.UnescapeDataString(nameValueItem[1].Replace("+", " "));
        keyValues.Add(nameValueItem[0], valueStr);
    }
    return keyValues;
}

また、この先 ConfigurationManager.AppSettings にある定数値は「アプリケーション設定」に書いておきます。このあたりにあります。

f:id:iseebi:20171210010147p:plain

今回は最終的にこのような値を定義しました。

f:id:iseebi:20171210010418p:plain

電話をかける - CallBossByTwilio

では、Function を作っていきます。「関数」のところをポイントすると現れる「+」ボタンをクリックして「HTTP Trigger」を選び、C# の CallBossByTwilio を作ります。

f:id:iseebi:20171210010713p:plain

Twilio の SDK を使いたいので、右ペインの「ファイルの表示」を開いて「project.json」を追加します。初期状態ではそもそも存在しないファイルなので、新規作成する必要があります。project.json を作って内容を反映すると、自動的に nuget restore が動き、project.json.lock が作成されます。

f:id:iseebi:20171210010929p:plain

{
 "frameworks": {
   "net46":{
     "dependencies": {
       "Twilio": "5.9.1"
     }
   }
 }
}

run.csx の内容はこのようにします。

#r "System.Configuration"

using System;
using System.Configuration;
using System.Net;
using Twilio;
using Twilio.Rest.Api.V2010.Account;
using Twilio.Types;
using System.Collections.Generic;

public static async Task<HttpResponseMessage> Run(HttpRequestMessage req, TraceWriter log)
{
    var accountSid = ConfigurationManager.AppSettings["TWILIO_ACCOUNT_SID"];
    var authToken = ConfigurationManager.AppSettings["TWILIO_AUTH_TOKEN"];
    var telNumber = ConfigurationManager.AppSettings["TWILIO_TEL_NUMBER"];
    var bossTelNumber = ConfigurationManager.AppSettings["BOSS_TEL_NUMBER"];
    var twimlFileUri = new Uri(ConfigurationManager.AppSettings["URL_BOSS_CALL_TWIML"]);
    var fallbackUri = new Uri(ConfigurationManager.AppSettings["URL_TWILIO_FALLBACK"]);
    var callbackUri = new Uri(ConfigurationManager.AppSettings["URL_TWILIO_CALLBACK"]);
    
    TwilioClient.Init(accountSid, authToken);
    
    var res = await CallResource.CreateAsync(
        to: new PhoneNumber(bossTelNumber),
        from: new PhoneNumber(telNumber),
        url: twimlFileUri,
        method: "get",
        fallbackUrl: fallbackUri,
        fallbackMethod: "post",
        statusCallback: callbackUri,
        statusCallbackMethod: "post",
        statusCallbackEvent: new List<string> { "completed" }
    );

    return req.CreateResponse(HttpStatusCode.OK, res.Sid);
}

このとき、url は電話に出たときに流される TwiML の取得先、fallbackUri はエラー等の時に呼ばれる URL、statusCallback は各種イベントの時に呼ばれる URL です。今回「イベント」は通話処理完了の completed のみをコールバックするようにしました。

TwiML は以下のような内容にしました。上司に休むことを音声で伝え、承認でよければ電話を切り、何かあるのであればどれかボタンを 1 つ押してもらうので、それを受け付けるようにします。

<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="alice" language="ja-JP">おはようございます。伊勢です。体調不良のため午前きゅうします。よろしければそのまま電話をお切りください。不具合のある場合はいずれかの番号を押してください。</Say>
<Gather input="dtmf" numDigits="1" action="[TwilioBossUnauthorizedのURL]" method="POST" />
</Response>

機械音声だと微妙だなという感じでしたら、mp3 ファイルに自分の声を録音し、<Play>で再生させると良いでしょう。詳細は TwiML の Play のリファレンスで。

この TwiML をどこから取得させるかというと、もちろん Functions で返しても良いのですが、内容が変わらないので BLOB ストレージに置いてしまうことにしました。ストレージアカウントを作り、BLOB サービスにアップロードしておきます。

f:id:iseebi:20171210012018p:plain

作成した関数の URL はファイルの右上リンクから取得できます。

f:id:iseebi:20171210014934p:plain

NG応答を保存する - TwilioBossUnauthorized

さて、上司が NG で応答した場合は、先ほどの TwiML の Gather に指定された URL にコールバックが送られます。NG 応答のあった通話についてはその情報をどこかに記録して次の処理に渡す必要があります。

f:id:iseebi:20171210020308p:plain

押された番号も Digits に入っていますが、今回は使いません。

Twilioの通話には通話ごとにユニークな CallSid が付与されますので、NG 応答のあった通話の一覧をどこかに記録しておくことにします。先ほど TwiML を置いたストレージアカウントのテーブルサービスをを使うことにしました。

テーブルサービスへの接続は接続文字列が必要になりますが、このあたりにありますのでコピーしてアプリケーション設定に書き込んでおきます。

f:id:iseebi:20171210013054p:plain

次に、テーブルストレージに保存するデータのモデルクラスを作成します。App Service エディターを開いて、ParseHttpForm.csx と同じ所に CallEntity.csx を作成します。

using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Table;

public class CallEntity : TableEntity
{
    public string CallSid { get; set; }

    public CallEntity(string callSid)
    {
        this.PartitionKey = nameof(CallEntity);
        this.RowKey = callSid;

        CallSid = callSid;
    }

    public CallEntity() { }
}

では関数を作りましょう。まず project.json から。

{
 "frameworks": {
   "net46":{
     "dependencies": {
       "WindowsAzure.Storage": "8.6.0"
     }
   }
 }
}

run.csx はこちら。

#r "System.Configuration"
#load "..\ParseHttpForm.csx"
#load "..\CallEntity.csx"

using System.Collections.Generic;
using System.Configuration;
using System.Net;
using System.Net.Http;
using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Table;

public static async Task<HttpResponseMessage> Run(HttpRequestMessage req, TraceWriter log)
{
    var storageAccountString = ConfigurationManager.AppSettings["STORAGE_CONNECT_STRING"];

    var keyValues = await req.ParseHttpForm();
    log.Info($"answer: {keyValues["Digits"]}");
    
    var storageAccount = CloudStorageAccount.Parse(storageAccountString);
    var tableClient = storageAccount.CreateCloudTableClient();
    var tableReference = tableClient.GetTableReference("TwilioGozenkyuBossUnauthorizes");
    await tableReference.CreateIfNotExistsAsync();
    try 
    {
        await tableReference.ExecuteAsync(TableOperation.Insert(new CallEntity(keyValues["CallSid"])));
    }
    catch (Exception)
    {
        // 409 Conflict
    }

    var res = new HttpResponseMessage(System.Net.HttpStatusCode.OK);
    res.Content = new StringContent(
        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
        + "<Response>"
        + "<Say voice =\"alice\" language=\"ja-JP\">承知しました。改めてお電話いたします。</Say>"
        + "<Hangup />"
        + "</Response>",
        System.Text.Encoding.UTF8,
        "application/xml");
        
    return res;
}

テーブルに CallSid を書き込んで、レスポンスの戻り値として TwiML を返しています。Twilio ではハイパーリンクをさせるような形で TwiML を繋いでいくことができます。

また、この TwiML では Hangup を使って電話を切断し、通話を終了させています。

通話の結果を処理してメールを送る - TwilioCallCompleted

{
 "frameworks": {
   "net46":{
     "dependencies": {
       "WindowsAzure.Storage": "8.6.0",
       "MailKit": "1.22.0"
     }
   }
 }
}
#r "System.Configuration"
#load "..\ParseHttpForm.csx"
#load "..\CallEntity.csx"

using System.Collections.Generic;
using System.Configuration;
using System.Net;
using System.Net.Http;
using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Table;
using MailKit.Net.Smtp;
using MailKit;
using MimeKit;

public static async Task<HttpResponseMessage> Run(HttpRequestMessage req, TraceWriter log)
{
    var storageAccountString = ConfigurationManager.AppSettings["STORAGE_CONNECT_STRING"];
    var lineKey = ConfigurationManager.AppSettings["LINE_KEY"];
    var emailAddress = ConfigurationManager.AppSettings["MAIL_FROM"];
    var toAddresses = ConfigurationManager.AppSettings["MAIL_TO"].Split(';');
    var mailServer = ConfigurationManager.AppSettings["SMTP_SERVER"];
    var mailServerUser = ConfigurationManager.AppSettings["SMTP_USER"];
    var mailServerPassword = ConfigurationManager.AppSettings["SMTP_PASSWORD"];
    var bossTelNumber = ConfigurationManager.AppSettings["BOSS_TEL_NUMBER"];

    var keyValues = await req.ParseHttpForm();

    // 上司が NG を出したか確認する
    var storageAccount = CloudStorageAccount.Parse(storageAccountString);
    var tableClient = storageAccount.CreateCloudTableClient();
    var tableReference = tableClient.GetTableReference("TwilioGozenkyuBossUnauthorizes");
    await tableReference.CreateIfNotExistsAsync();
    var result = await tableReference.ExecuteAsync(TableOperation.Retrieve(nameof(CallEntity), keyValues["CallSid"]));
    var approved = (result.Result == null);
    
    if (!approved) {
        // NG だったら、LINE Notify でその旨を通知する
        var httpClient = new HttpClient();
        var lineReq = new HttpRequestMessage(HttpMethod.Post, "https://notify-api.line.me/api/notify");
        lineReq.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", lineKey);
        lineReq.Content = new FormUrlEncodedContent(new Dictionary<string, string>
        {
            { "message", "午前休について、上司がNGを出しました。電話して確認してください\n" + bossTelNumber },
        });
        await httpClient.SendAsync(lineReq);
        return req.CreateResponse(HttpStatusCode.OK, "");
    }
    
    // OK だったらメールを送信する
    var message = new MimeMessage();
    message.From.Add(new MailboxAddress("Shin Ise", emailAddress));
    foreach (var toAddress in toAddresses) 
    {
        message.To.Add(new MailboxAddress(toAddress));
    }
    message.Subject = $"[勤怠連絡] 伊勢 {DateTimeOffset.Now.ToString("MM/dd")} 午前休";

    message.Body = new TextPart("plain")
    {
        Text = @"各位

お疲れさまです。
伊勢です。

体調不良のため、午前中様子を見てから出社します。
ご迷惑をおかけしますが、よろしくお願いいたします。
"
    };

    using (var client = new SmtpClient())
    {
        client.ServerCertificateValidationCallback = (s, c, h, e) => true;
        client.Connect(mailServer, 587, false);
        client.AuthenticationMechanisms.Remove("XOAUTH2");
        client.Authenticate(mailServerUser, mailServerPassword);
        client.Send(message);
        client.Disconnect(true);
    }
    
    // 結果を LINE Notify で通知する
    {
        var httpClient = new HttpClient();
        var lineReq = new HttpRequestMessage(HttpMethod.Post, "https://notify-api.line.me/api/notify");
        lineReq.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", lineKey);
        lineReq.Content = new FormUrlEncodedContent(new Dictionary<string, string>
        {
            { "message", "午前休メールを送りました。" },
        });
        await httpClient.SendAsync(lineReq);
    }
    
    return req.CreateResponse(HttpStatusCode.OK, "");
}

ここでは 4 つの処理をしています。

  • テーブルストレージに現在の CallSid のデータがあるか確認する。あれば上司が NG を出したと言うことになる。
  • NG だったら、その旨を LINE Notify で通知する。
  • OK だったら、ML にメールを送る。送り先の ML は複数かもしれないので、; で区切れるようにしている。
    • メールの送信には MailKit を使いました。
  • さらに、結果を LINE Notify で通知する。

LINE Notify についてはパーソナルアクセストークンを発行してアプリケーション設定に書き込んでいます。LINE Notify の API はシンプルで、簡単にトークンも取れて便利ですね。

処理失敗を処理する - TwilioFallback

さて、Twilioの処理中に失敗した場合は FallbackUrl にアクセスが発生します。今回は LINE Notify でその内容を通知することにしました。

#r "System.Configuration"
#load "..\ParseHttpForm.csx"

using System;
using System.Configuration;
using System.Collections.Generic;
using System.Net;

public static async Task<HttpResponseMessage> Run(HttpRequestMessage req, TraceWriter log)
{
    var keyValues = await req.ParseHttpForm();

    var t = "";
    foreach (var kv in keyValues) 
    {
        t += $"{kv.Key}: {kv.Value}\n";
    }

    var client = new HttpClient();
    var lineReq = new HttpRequestMessage(HttpMethod.Post, "https://notify-api.line.me/api/notify");
    lineReq.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", lineKey);
    lineReq.Content = new FormUrlEncodedContent(new Dictionary<string, string>
    {
        { "message", "午前休連絡の電話でエラーが発生しました\n" + t },
    });
    await client.SendAsync(lineReq);
}

Google Home からの接続を定義する

あとは Google Home から呼び出せるように IFTTT を設定するだけです。

f:id:iseebi:20171210015351p:plain

Trigger を Google Assistant、Say simple phaseにして、「午前休 する」に対して「午前休連絡をします」という返しにします。

f:id:iseebi:20171210015450p:plain

定義したのにうまく反応してくれないときは Google Home アプリのアカウントメニューから「マイアクティビティ」を確認すると、実際にどういう受け答えになったのかを確認できます。僕はこの「午前休」と「する」の間に半角スペースが入るのに気づかなくてかなりハマりました。

f:id:iseebi:20171210015731p:plain

Webhook は、CallBossByTwilio の URL を入れておきます。

完成!

これで完成です!

テストするときは自分の携帯電話やメールアドレスで確認してみましょう。

最後に

この記事はあくまでシナリオを示したものであり、実際にやるときは会社の制度や上司の理解が得られるかなどはご自身で判断ください。

また、以下の処理は考慮外になっていますので、必要に応じて拡張してみてください。

  • 上司が電話に出なかった (answered のイベントを受けて、その状態を保持しておく必要があるでしょう)
  • 電話中だった (CallStatus が busy になる)

*1:スタートレック好きとしては最新シリーズのディスカバリーを見るために必須