상쾌한 일요일 저녁입니다. 몇 일전에 우연히 발견한 봇 관련 포스트가 있어서 그 내용을 보면서 따라하기를 해 보려고 합니다. 벌써부터 기분이 좋아질려고 하는군요.. 이 포스트를 읽으시는 분들도 기분이 좋으시죠? 으흐흐;;;

올해는 봇이랑 뭔가 좋은 일을 만들어야 하는데 말입니다. 좋은 기운을 받아서 출발해 보죠

 

이 예제를 이용하기 위해서는 마이크로소프트 밴드가 필요하겠네요. 밴드가 아니라 다른 것을 가지고 있으면 그걸 이용하셔도 좋습니당. 물론 개발자 SDK는 직접 구하셔야겠네요.

 

 

1. 참고 포스트

Developing a Microsoft Health Bot based on Data captured from the Microsoft Band

https://blogs.msdn.microsoft.com/uk_faculty_connection/2016/06/17/developing-a-microsoft-health-bot-based-on-data-captured-from-the-microsoft-band/

 

 

2. 밴드의 어떤 데이터를 이용할까요?

 

. 운동 모드(달리기와 연습) : 매 초마다 심박을 기록합니다.

. 수면 추적 : 2분 동안 동작하고, 8분은 꺼져있습니다.

. 그 외 시간 : 1분 동안 동작하고, 9분은 꺼져있습니다.

. 수동 : 강제 확인 기능으로 Me Tile을 클릭하면 볼 수 있습니다.

 

 

3. 어디에 저장되고, 어떻게 사용할 수 있나요?

 

각종 정보는 MS Health로 전송됩니다. 그리고 API를 이용해서 조회해서 볼 수 있으며, 폰에 어플리케이션이 필요하지는 않습니다.

 

Integrate Microsoft Health data into your application or service.

http://developer.microsoftband.com/cloudAPI

 

 

4. 시작하기..

 

비주얼 스튜디오와 기타 설정하는 방법에 대해서는 아래 포스트를 참고하시기 바랍니다.

 

Bot Framework 시작하기 Part1

http://kaki104.tistory.com/496

 

 

5. Microsoft Health API

 

밴드는 자체 플래쉬 메모리에 운동한 데이터를 기록해 놓았다가, 모바일 장치에 Microsoft Helth 앱과 동기화를 하면 해당 데이터를 Microsoft cloud에 올리게 됩니다. 해당 데이터를 조회하는 방법은 Web dashboard를 이용하거나 MS Health API를 이용하는 방법이 있습니다.

 

Web dashboard를 통해 간단하게 운동량을 알 수 있습니다.~

 

 

 

API를 이용해서 데이터를 가지고 오기 위해서는 OAuth 2 인증 방법을 이용해서 인증 후 사용이 가능합니다. (브라우저에서 인증을 하는 방법)

 

그런데, bot은 브라우저가 아니기 때문에 인증하는 방법이 어렵습니다. 그래서~ 아래 이미지와 같은 방식을 통해서 인증 처리를 합니다. (세부 사항은 영문을 참고하세용)

 

 

이렇게 하기 위해서는 봇을 OAuth 2.0 client로 등록을 해야합니다.

 

1) 내 App 등록하는 화면에서 Add an app 버튼을 클릭해서 앱을 등록 합니다. 앱 이름은 MSHealthBot으로 합니다.  https://apps.dev.microsoft.com/?mkt=en-us#/appList

 

 

 

2) Application Id를 복사해서 메모장에 붙여 넣으세용(ClientId가 됩니다.)

 

3) Generate New Password를 클릭해서 새로운 패스워드 생성(이 때 생성된 비밀번호는 한번만 보여주니 복사해서 메모장에 저장 -> ClientSecret이 됩니다.)

 

4) Add Platform을 클릭해서 플랫폼 추가, Web으로

이 때 Redirect URI는 local에서 bot이 실행된 주소에 /api/auth/receivetoken 이라는 경로를 추가해서 등록

예를 들면 http://localhost:3978/api/auth/receivetoken 이렇게..

 

5) Live SDK support 클릭

 

6) Save

 

7) 아래의 코드를 Global.asax.cs 파일 하단에 아래 내용을 추가합니다.

 


    /// 인증키 저장용
    /// </summary>
    public class CredentialStore : ICredentialStore
    {
        private readonly Dictionary<string, string> _idMap = new Dictionary<string,
            string>();

        public void AddToken(string id, string token)
        {
            _idMap[id] = token;
        }

        public string GetToken(string id)
        {
            string val = null;
            if (_idMap.TryGetValue(id, out val))
            {
                return val;
            }
            return null;
        }
    }
    /// <summary>
    /// 인증키
    /// </summary>
    public interface ICredentialStore
    {
        string GetToken(string id);
        void AddToken(string id, string token);
    }
    public class MyDependencies
    {
        public static ICredentialStore _store = new CredentialStore();
    }

 

 

소스에 대한 이해..

간단하게 구현된 인증 토큰을 서버 메모리에 저장하기 위한 코드 입니다.

 

 

8) 솔루션 탐색기 창에서 Controllers에서 마우스 오른쪽 클릭 -> Add -> Class, AuthController란 이름으로 클래스를 추가하고, 아래 소스를 복사해서 붙여 넣습니다.

 

using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using System.Web.Http;
using Newtonsoft.Json;

namespace MSHealthBot
{
    public class AuthController : ApiController
    {
        /// <summary>
        ///     서버에 올릴 경우 서버 주소로 변경합니다.
        /// </summary>
        private static readonly string RedirectUri =
            @"http://localhost:3978/api/auth/receivetoken";

        /// <summary>
        ///     조회 범위
        /// </summary>
        private static readonly string Scopes =
            "mshealth.ReadDevices mshealth.ReadActivityHistory mshealth.ReadActivityLocation mshealth.ReadDevices mshealth.ReadProfile offline_access";

        private readonly ICredentialStore _creds;

        private readonly string ClientId;
        private readonly string ClientSecret;

        /// <summary>
        ///     생성자
        /// </summary>
        public AuthController()
        {
            ClientId = Environment.GetEnvironmentVariable("MSHEALTHBOT_HEALTHAPI_CLIENTID");
            ClientSecret = Environment.GetEnvironmentVariable("MSHEALTHBOT_HEALTHAPI_CLIENTSECRET");
            _creds = MyDependencies._store;
        }

        [Route("api/auth/home")]
        [HttpGet]
        public HttpResponseMessage Home(string UserId)
        {
            var resp = Request.CreateResponse(HttpStatusCode.Found);
            resp.Headers.Location = CreateOAuthCodeRequestUri(UserId);
            return resp;
        }

        private Uri CreateOAuthCodeRequestUri(string UserId)
        {
            var uri = new
                UriBuilder("https://login.live.com/oauth20_authorize.srf");
            var query = new StringBuilder();
            query.AppendFormat("redirect_uri={0}", Uri.EscapeUriString(RedirectUri));
            query.AppendFormat("&client_id={0}", Uri.EscapeUriString(ClientId));
            query.AppendFormat("&client_secret={0}",
                Uri.EscapeUriString(ClientSecret));
            query.AppendFormat("&scope={0}", Uri.EscapeUriString(Scopes));
            query.Append("&response_type=code");
            if (!string.IsNullOrEmpty(UserId))
                query.Append($"&state={UserId}");
            uri.Query = query.ToString();
            return uri.Uri;
        }

        private Uri CreateOAuthTokenRequestUri(string code, string refreshToken = "")
        {
            var uri = new UriBuilder("https://login.live.com/oauth20_token.srf");
            var query = new StringBuilder();
            query.AppendFormat("redirect_uri={0}", Uri.EscapeUriString(RedirectUri));
            query.AppendFormat("&client_id={0}", Uri.EscapeUriString(ClientId));
            query.AppendFormat("&client_secret={0}",
                Uri.EscapeUriString(ClientSecret));
            var grant = "authorization_code";
            if (!string.IsNullOrEmpty(refreshToken))
            {
                grant = "refresh_token";
                query.AppendFormat("&refresh_token={0}",
                    Uri.EscapeUriString(refreshToken));
            }
            else
            {
                query.AppendFormat("&code={0}", Uri.EscapeUriString(code));
            }
            query.Append(string.Format("&grant_type={0}", grant));
            uri.Query = query.ToString();
            return uri.Uri;
        }

        [Route("api/auth/receivetoken")]
        [HttpGet]
        public async Task<string> ReceiveToken(string code = null, string state
            = null)
        {
            if (!string.IsNullOrEmpty(code) && !string.IsNullOrEmpty(state))
            {
                var tokenUri = CreateOAuthTokenRequestUri(code);
                string result;
                using (var http = new HttpClient())
                {
                    var c = tokenUri.Query.Remove(0, 1);
                    var content = new StringContent(c);
                    content.Headers.ContentType = new
                        MediaTypeHeaderValue("application/x-www-form-urlencoded");
                    var resp = await http.PostAsync(new
                        Uri("https://login.live.com/oauth20_token.srf"), content);
                    result = await resp.Content.ReadAsStringAsync();
                }
                dynamic obj = JsonConvert.DeserializeObject(result);
                _creds.AddToken(state, obj.access_token.ToString());
                return "Done, thanks!";
            }
            return "Something went wrong - please try again!";
        }
    }
}

 

소스의 이해...

OAuth 2.0 인증을 위한 코드와 Access Token으로 변경하는 코드입니다. 코드가 실행되기 위해서는 2개의 환경 변수에 값이 설정되어야 합니다.

MSHEALTHBOT_HEALTHAPI_CLIENTID, MSHEALTHBOT_HEALTHAPI_CLIENTSECRET는 소스에 api key를 포함하지 않고, 환경 변수에 입력 후 사용하는 방법입니다. 이 방법을 사용하기 위해서는 powershell을 관리자 모드로 실행하시고, 해당 소스가 위치한 폴더로 이동하신 후에

 

setx -m 키 벨류

 

형태로 입력을 하시면 됩니다. 이렇게 입력하는 api key가 2종류 있으니 사용해 보시기 바랍니다.

 

 

9) Model.cs class를 추가 후 다음

https://gist.githubusercontent.com/peted70/9e5db9148137f5b3f95e68517eda8b0e/raw/ee1cb88a4fd6e9276664e71ee576ca4d58d7027d/bot-mshealth-model

해당 페이지의 내용을 복사해서 붙여 넣습니다.

이 내용은 Microsoft Health에서 제공하는 데이터 모델 들입니다.

 

 

6. LUIS

 

드디어 LUIS를 이용해서 뭔가 하도록 하겠습니다. 세부적인 내용은 우선 패스~ 무조건 따라서 하나 만들어 봅니다.

 

1) JSON 파일을 다운로드해서 저장 합니다. 저장할 때 확장자는 반드시 json으로 합니다.

https://github.com/peted70/ms-health-bot/blob/2bbdbd7ed311db0362f1f6d9c95ee489dee8544a/assets/MSHealthBot.json

 

 

2) 루이스 사이트로 이동합니다. https://www.luis.ai

가입은 Microsoft Account만 있으면 가능하며, 추가로 몇가지 정보를 더 입력합니다.

 

 

3) Add New을 선택한 후 Import Existing Application을 선택합니다.

 

4) 아까 저장했던 json 파일을 선택한 후 Import 버튼을 클릭합니다.

5) Import과정이 완료 된 후 왼쪽 하단에 Train 버튼을 클릭합니다. 그러면 한참 혼자 연습을 합니다.

 

6) Train이 완료된 후 왼쪽 메뉴 중 상단에 Publish 버튼을 눌러서 서비스를 등록 합니다.

 

7) Publish가 완료 된 후 URL에 포함되어 있는 API Key 2개를 보관하셔야 합니다.

 

이제는 자연어를 입력하면, LUIS를 이용해서 단어를 분석해서 반환 합니다.

물론 한글로 입력하는 것은 아니되옵니다.

 

 

7. 나머지 Bot 완성하기

 

1) MessageController class에 작업을 해야하는데, 그 전에 Nuget package 중에 NadaTime을 설치해야 합니다. 이 패키지는 날자 형식을 파싱할 때 사용되는데, 검색을 하기전에 Include prerelease를 체크해야 검색이 가능합니다.

 

2) MessageController 전체 소스입니다.

using System;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using System.Web.Http;
using Microsoft.Bot.Connector;
using Microsoft.Bot.Connector.Utilities;
using Newtonsoft.Json;
using NodaTime;
using NodaTime.Text;

namespace MSHealthBot
{

    [BotAuthentication]
    public class MessagesController : ApiController
    {
        private const string ApiVersion = "v1";

        private readonly ICredentialStore _creds;

        /// <summary>
        ///     기본 생성자
        /// </summary>
        public MessagesController()
        {
            _creds = MyDependencies._store;
        }

        private async Task<MSHealthUserText> ParseUserInput(string input)
        {
            var escaped = Uri.EscapeDataString(input);

            using (var http = new HttpClient())
            {
                var key = Environment.GetEnvironmentVariable("MSHEALTHBOT_LUIS_API_KEY");
                var id = Environment.GetEnvironmentVariable("MSHEALTHBOT_LUIS_APP_ID");

                string uri =
                    $"https://api.projectoxford.ai/luis/v1/application?id={id}&subscription-key={key}&q={escaped}";
                var resp = await http.GetAsync(uri);
                resp.EnsureSuccessStatusCode();

                var strRes = await resp.Content.ReadAsStringAsync();
                var data = JsonConvert.DeserializeObject<MSHealthUserText>(strRes);
                return data;
            }
        }

        /// <summary>
        ///     POST: api/Messages
        ///     Receive a message from a user and reply to it
        /// </summary>
        public async Task<Message> Post([FromBody] Message message)
        {
            if (message.Type == "Message")
            {
                var userid = message?.From?.Id;
                // Lookup the user id to see if we have a token already..
                var token = _creds.GetToken(userid);
                var prompt = "";

                //토큰 확인
                if (string.IsNullOrEmpty(token))
                {
                    var loginUri = new
                        Uri($"http://localhost:3978/api/auth/home?UserId={userid}");
                    prompt =
                        $"Please pay a visit to {loginUri} to associate your user identity with your Microsoft Health identity.";
                }
                else
                {
                    var data = await ParseUserInput(message.Text);

                    if (data.intents.Length <= 0 || data.entities.Length <= 0)
                    {
                        return
                            message.CreateReplyMessage(
                                "I don't have enough information to understand the question - please try again...");
                    }

                    var words = string.Join(", ", data.intents.Select(p => p.intent));
                    Debug.WriteLine(words);

                    var topIntent = data.intents[0].intent;

                    switch (topIntent)
                    {
                        case "SummariseActivity":
                            var firstOrDefault = data.entities.FirstOrDefault(e => e.type == "ActivityType");
                            if (firstOrDefault != null)
                            {
                                var entityStr = firstOrDefault.entity;

                                // This could be either date, time or duration..
                                var entityTime = data.entities.FirstOrDefault(e =>
                                    e.type == "builtin.datetime.time" ||
                                    e.type == "builtin.datetime.duration" ||
                                    e.type == "builtin.datetime.date");

                                if (entityTime.type == "builtin.datetime.duration")
                                {
                                    var res = PeriodPattern.NormalizingIsoPattern.Parse(entityTime.resolution.duration);

                                    // Now call the relevant Microsoft Health API and respond to the user...
                                    var st = SystemClock.Instance.GetCurrentInstant().InUtc().LocalDateTime - res.Value;

                                    var start = st.ToDateTimeUnspecified();
                                    var end = DateTime.Now;
                                    var res2 = await GetActivity(token, entityStr, start, end);
                                    var sleep = JsonConvert.DeserializeObject<Sleep>(res2);

                                    // create a textual summary of sleep in that period...
                                    var num = sleep.itemCount;
                                    if (num <= 0)
                                    {
                                        prompt = "You didn't track any sleep";
                                        break;
                                    }
                                    var total = sleep.sleepActivities.Sum(a =>
                                    {
                                        if (a.sleepDuration != null)
                                        {
                                            var dur = PeriodPattern.NormalizingIsoPattern.Parse(a.sleepDuration);
                                            return dur.Value.ToDuration().Ticks;
                                        }
                                        return 0;
                                    });

                                    var av = total/num;
                                    var sleepSpan = TimeSpan.FromTicks(av);
                                    var totalSpan = TimeSpan.FromTicks(total);

                                    var avSleepStr = $"{sleepSpan.ToString(@"%h")} hrs {sleepSpan.ToString(@"%m")} mins";
                                    var totalSleepStr =
                                        $"{totalSpan.ToString(@"%d")} days {totalSpan.ToString(@"%h")} hrs {totalSpan.ToString(@"%m")} mins";

                                    prompt =
                                        $"You have tracked {num} sleeps - average sleep per night {avSleepStr} for a total of {totalSleepStr}";
                                }
                            }
                            break;
                        default:
                            Debug.WriteLine($"topIntent:{topIntent}");
                            break;
                    }
                }

                //// calculate something for us to return
                //var length = (message.Text ?? string.Empty).Length;

                // return our reply to the user
                //return message.CreateReplyMessage($"You sent {length} characters");
                // return our reply to the user
                return message.CreateReplyMessage(prompt);
            }
            return HandleSystemMessage(message);
        }

        private Message HandleSystemMessage(Message message)
        {
            if (message.Type == "Ping")
            {
                var reply = message.CreateReplyMessage();
                reply.Type = "Ping";
                return reply;
            }
            if (message.Type == "DeleteUserData")
            {
                // Implement user deletion here
                // If we handle user deletion, return a real message
            }
            else if (message.Type == "BotAddedToConversation")
            {
            }
            else if (message.Type == "BotRemovedFromConversation")
            {
            }
            else if (message.Type == "UserAddedToConversation")
            {
            }
            else if (message.Type == "UserRemovedFromConversation")
            {
            }
            else if (message.Type == "EndOfConversation")
            {
            }

            return null;
        }

        private async Task<string> MakeRequestAsync(string token, string path,
            string query = "")
        {
            var http = new HttpClient();
            http.DefaultRequestHeaders.Authorization = new
                AuthenticationHeaderValue("Bearer", token);
            var ub = new UriBuilder("https://api.microsofthealth.net")
            {
                Path = ApiVersion + "/" + path,
                Query = query
            };
            var resStr = string.Empty;
            var resp = await http.GetAsync(ub.Uri);
            if (resp.StatusCode == HttpStatusCode.Unauthorized)
            {
                // If we are unauthorized here assume that our token may have expired and use the
                // refresh token to get a new one and then try the request again..
                // TODO: handle this - we can cache the refresh token in the same flow as the access token
                // just haven't done it.
                return "";
                // Re-issue the same request (will use new auth token now)
                //return await MakeRequestAsync(path, query);
            }
            if (resp.IsSuccessStatusCode)
            {
                resStr = await resp.Content.ReadAsStringAsync();
            }
            return resStr;
        }

        private async Task<string> GetActivity(string token, string activity,
            DateTime Start, DateTime end)
        {
            var res = string.Empty;
            try
            {
                res = await MakeRequestAsync(token, "me/Activities/",
                    string.Format("startTime={0}&endTime={1}&activityTypes={2}&ActivityIncludes=Details",
                        Start.ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'"),
                        end.ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'"),
                        activity));
            }
            catch (Exception ex)
            {
                return $"API Request Error - {ex.Message}";
            }
            await Task.Run(() =>
            {
                // Format the JSON string
                var obj = JsonConvert.DeserializeObject(res);
                res = JsonConvert.SerializeObject(obj, Formatting.Indented);
            });
            return res;
        }
    }
}

 

 

8. 서비스를 사용하기

 

서비스를 실행 후 Bot Emulator을 실행해서 테스트 할 수 있습니다.

 

일단 시작하고 아무 글이나 입력하면, Live에 로그인을 하는 링크가 출력됩니다. 링크를 클릭 합니다.

 

 

how much sleep have I had in last 2 weeks?

라고 입력하면, 2주 동안 발생한 sleep 데이터를 분석해서 반환해 줍니다.

 

 

 

이 셈플 예제는 Health API의 기능을 모두 조회할 수는 없습니다. (sleep만 조회가 가능하더군요..) 기본적인 사용법에 대한 설명이 있는 것이니, 개발자 스스로 성능을 개선해 나가면 더 좋은 채팅 봇이 될 것 같습니다.

 

포스트에 이상한 점이나 질문은 리플로 남겨주시면 처리하겠습니다.

 

블로그 이미지

MVP kaki104

* Microsoft MVP - Windows Development 2014 ~ 2019 5ring * LINE : kaki104 * facebook : https://www.facebook.com/kaki104 https://www.facebook.com/groups/w10app/

티스토리 툴바