Madoka Chomado
You Are Reading

Azure Functions でスマートスピーカー開発入門 + Office 365 連携 #decode19

9
登壇記録

Azure Functions でスマートスピーカー開発入門 + Office 365 連携 #decode19


のべ4000人以上が来場した日本マイクロソフト公式技術カンファレンス de:code 2019 で登壇しました。

コード

私の書いたデモアプリのコードはこちらです。

https://github.com/chomado/MeetingResponseServer

↑ GitHub のレポジトリ

Office 368 (Outlook) に入れた私の予定をスマートスピーカーから取得できるスキルを、3プラットフォーム向けに開発しました。

de:code 2019

質問があったのでここに書きます。
Google Assistant 向けの対話モデル作成に関しては、Google Dialogflow だけでなく Microsoft LUIS も使えるのですが、
共同登壇の方のセッションで田中さんがそちらをカバーしてくださっているので、私は Dialogflow でいっています。

LINE のエンドポイント

この辺の nuget パッケージ使ってます

using CEK.CSharp;
using CEK.CSharp.Models;
using Line.Messaging;

エンドポイント記述 (Azure Functions, C#)

[FunctionName("Line")]
public static async Task<IActionResult> Line(
    [HttpTrigger(AuthorizationLevel.Function, "post", Route = null)]HttpRequest req, ExecutionContext context, 
    ILogger log)
{
    var client = new ClovaClient();
    var cekRequest = await client.GetRequest(req.Headers["SignatureCEK"], req.Body);
    var cekResponse = new CEKResponse();
    switch (cekRequest.Request.Type)
    {
        // 起動時に飛んでくる intent
        case RequestType.LaunchRequest:
            cekResponse.AddText(IntroductionMessage[0]);
            cekResponse.ShouldEndSession = false;
            break;
        // ユーザ定義の intent
        case RequestType.IntentRequest:
            {
                // slot を抜き出す
                CEK.CSharp.Models.Slot when = null;
                cekRequest.Request.Intent.Slots?.TryGetValue(key: "when", value: out when);
                // intent ごとに処理を振り分け
                var texts = await HandleIntentAsync(cekRequest.Request.Intent.Name, when?.Value ?? "今日", Platforms.Clova);

                if (texts.Any())
                {
                    foreach (var text in texts)
                    {
                        cekResponse.AddText(text);
                    }
                }
                else
                {
                    cekResponse.AddText("予定はありません。");
                }

                // 予定があったら LINE にプッシュ通知する
                if (texts.Any())
                {
                    var config = Models.AuthenticationConfigModel.ReadFromJsonFile("appsettings.json");
                    var secret = config.LineMessagingApiSecret;

                    var textMessages = new List<ISendMessage>();

                    textMessages.Add(new TextMessage("ちょまどさんの予定はこちら!"));
                    foreach (var text in texts)
                    {
                        textMessages.Add(new TextMessage(text));
                    }

                    var messagingClient = new LineMessagingClient(secret);
                    await messagingClient.PushMessageAsync(
                        //to: cekRequest.Session.User.UserId,
                        to: config.MessagingUserId,
                        messages: textMessages
                    );
                }
            }
            break;
    }
    return new OkObjectResult(cekResponse);
}

Alexa

using Alexa.NET.Request;
using Alexa.NET.Request.Type;
using Alexa.NET.Response;
[FunctionName("Alexa")]
public static async Task<IActionResult> Alexa(
    [HttpTrigger(AuthorizationLevel.Function, "post", Route = null)]HttpRequest req, 
    ILogger log)
{
    var skillRequest = JsonConvert.DeserializeObject<SkillRequest>(await new StreamReader(req.Body).ReadToEndAsync());
    var skillResponse = new SkillResponse
    {
        Version = "1.0",
        Response = new ResponseBody(),
    };
    switch (skillRequest.Request)
    {
        case LaunchRequest lr:
            skillResponse.Response.OutputSpeech = new PlainTextOutputSpeech
            {
                Text = IntroductionMessage[0],
            };
            break;
        case IntentRequest ir:
            {
                
                var texts = await HandleIntentAsync(ir.Intent.Name, ir.Intent.Slots["when"].Value, Platforms.Alexa);
                
                if (texts.Any())
                {
                    var plainTextOutputSpeech = "ちょまどさんの予定をお知らせします。\n";
                    foreach (var text in texts)
                    {
                        plainTextOutputSpeech += $"{text}\n";
                    }
                    skillResponse.Response.OutputSpeech = new PlainTextOutputSpeech { Text = plainTextOutputSpeech };
                }
                else
                {
                    skillResponse.Response.OutputSpeech = new PlainTextOutputSpeech { Text = "予定はありません。" };
                }
            }
            break;
        default:
            skillResponse.Response.OutputSpeech = new PlainTextOutputSpeech
            {
                Text = "すいません。わかりません。",
            };
            break;
    }

    return new OkObjectResult(skillResponse);
}

Google Assistant

using Google.Protobuf;
using Google.Cloud.Dialogflow.V2;
 [FunctionName("GoogleHome")]
public static async Task<IActionResult> GoogleHome(
    [HttpTrigger(AuthorizationLevel.Function, "post", Route = null)]HttpRequest req, 
    ILogger log)
{
    var parser = new JsonParser(JsonParser.Settings.Default.WithIgnoreUnknownFields(true));
    var webhookRequest = parser.Parse<WebhookRequest>(await req.ReadAsStringAsync());
    var entities = webhookRequest.QueryResult.Parameters.Fields["when"].StringValue;
    var webhookResponse = new WebhookResponse();
    log.LogInformation(webhookRequest.QueryResult.Intent.DisplayName);
    switch (webhookRequest.QueryResult.Intent.DisplayName)
    {
        case "Default Welcome Intent":
            webhookResponse.FulfillmentText = IntroductionMessage[0];
            break;
        default:
            {
                var texts = await HandleIntentAsync(webhookRequest.QueryResult.Intent.DisplayName, entities, Platforms.GoogleAssistant);
                if (texts.Any())
                {
                    var fulfillmentText = "ちょまどさんの予定をお知らせします。\n";
                    foreach (var text in texts)
                    {
                        fulfillmentText += $"{text}\n";
                    }
                    webhookResponse.FulfillmentText = fulfillmentText;
                }
                else
                {
                    webhookResponse.FulfillmentText = "予定はありません。";
                }
            }
            break;
    }

    return new ProtcolBufJsonResult(webhookResponse, JsonFormatter.Default);
}

ビジネスロジック

3プラットフォーム間で共通化してる部分です

private static async Task<IEnumerable<string>> HandleIntentAsync(string intent, object meetingDay, Platforms platform)
{
    switch (intent)
    {
        case "HelloIntent":
            return HelloMessage;
        // 明日の予定を教えて
        case "AskScheduleIntent":
            {
                var start = ParseMeetingDay(meetingDay, platform);
                var response = await Services.MeetingInfoService.GetMeeting(startTime: start, endTime: start.AddDays(1));
                return ScheduleMessage(response.Value);
            }
        default:
            return ErrorMessage;
    }
}

private static DateTimeOffset ParseMeetingDay(object meetingDay, Platforms platform)
{
    if (platform == Platforms.Clova || platform == Platforms.Alexa)
    {
        return ConvertJname2Datetime((string)meetingDay);
    }
    else
    {
        // GoogleAssistant
        // 	2019-05-27T12:00:00+09:00
        var d = DateTimeOffset.Parse((string)meetingDay);
        return new DateTimeOffset(d.Date, d.Offset);
    }
}

private static IEnumerable<string> ScheduleMessage(Models.Value[] meeting)
{
    return meeting
        .Select(x => $"{x.Start.DateTime.ToJst().Hour}時{x.Start.DateTime.ToJst().Minute}分から{x.Subject}があります。場所は{x.Location.DisplayName}です。");
}

// "今日" -> DateTime
private static DateTimeOffset ConvertJname2Datetime(string when)
{
    var temp = DateTimeOffset.UtcNow.ToJst();
    var utc = new DateTimeOffset(temp.Date, temp.Offset);

    switch (when)
    {
        case "今日":
            break;
        case "明日":
            utc = utc.AddDays(1);
            break;
    }
    return utc;
}

// UTC -> JST
private static DateTimeOffset ToJst(this DateTimeOffset utc)
{
    var jstZoneInfo = TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time");
    return utc.ToOffset(jstZoneInfo.BaseUtcOffset);
}
private static DateTime ToJst(this DateTime utc)
{
    var jstZoneInfo = TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time");
    return TimeZoneInfo.ConvertTimeFromUtc(utc, jstZoneInfo);
}

Microsoft Graph API 叩いてる部分(私の予定を取ってくる)

using Microsoft.AspNetCore.Mvc;
using Microsoft.Identity.Client;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;

namespace MeetingResponseServer.Services
{
    public static class MeetingInfoService
    {
        public static async Task<Models.MeetingModel> GetMeeting(DateTimeOffset startTime, DateTimeOffset endTime)
        {
            var config = Models.AuthenticationConfigModel.ReadFromJsonFile("appsettings.json");

            IConfidentialClientApplication app;

            app = ConfidentialClientApplicationBuilder.Create(config.ClientId)
                .WithClientSecret(config.ClientSecret)
                .WithAuthority(new Uri(config.Authority))
                .Build();

            string[] scopes = new string[] { "https://graph.microsoft.com/.default" };

            AuthenticationResult result = null;
            result = await app.AcquireTokenForClient(scopes)
                .ExecuteAsync();

            var httpClient = new HttpClient();
            var apiCaller = new ProtectedApiCallHelper(httpClient);
            var requestUrl = $"https://graph.microsoft.com/v1.0/users/{config.MyUserId}/calendarview?startdatetime={startTime.ToUniversalTime().DateTime}&enddatetime={endTime.ToUniversalTime().DateTime}";
            var response = await apiCaller.CallWebApiAndProcessResultAsync<Models.MeetingModel>(requestUrl, result.AccessToken);
            return response;
        }
    }
}

デモアプリについて

今回のデモアプリでは「ちょまどさんの予定を教えて」と私の予定決め打ちでしたが

もし「ログインユーザーの予定を教えて」としたい場合は

ログイン周りの実装をすることになります。

参考:Azure Active Directory のドキュメント

スライド

湊川あいさんがグラフィックレコーディングしてくれました!

参考リンク


Madoka Chomado (ちょまど)

千代田まどかです。よくちょまどと呼ばれます。Microsoft 社員。文系出身プログラマ兼マンガ家です。私の書いた記事一覧がこちらです

コメントを残す

メールアドレスが公開されることはありません。