Microsoft Bot Framework에 카카오톡 붙이기

마이크로소프트 Bot Framework 는 현재 15개의 채널을 지원하고 있다 (2017-06-22 현재) 페이스북 메신저, Skype, Skype for business, Slack 등 다양한 메신저를 지원하고 동시에 Direct Line REST API를 제공하기 때문에 원한다면 내가 만든 앱에 채팅 UI를 붙여서 만들수도 있다. 하지만 한국의 사정은 조금 다르다. 현재 모바일 메신저 시장의 95%의 점유율을 가지고 있는 카카오톡이 지원되어야 챗봇 서비스가 완성된다고 해도 과언이 아니다. 하지만 카카오톡은 현재 마이크로소프트 Bot Framework의 공식 채널이 아니다. 이런 상황에서 카카오톡에 Microsoft Bot Framework를 붙여서 챗봇을 구현하는 방법을 알아보자.

카카오 플러스 친구

카카오는 최근에 봇 개발을 위한 프레임워크를 개발중이라고 밝히기도 했다. 그 전까지는 다른 방법을 써야 하는데 한가지 가능한 방법이 있다. 카카오 플러스 친구는 원래 쇼핑몰 운영자 등이 고객들과 카카오톡을 통해서 소통할 수 있는 방법을 제공한다. 고객과 운영자가 직접 메시지를 주고 받는 대신 “자동 응답 API” 도 제공을 하는데 이 API와 마이크로소프트 봇 프레임워크의 Direct Line REST API를 연결해주면 카카오 톡을 통해서 봇과 사용자가 메시지를 주고 받을 수 있다.

챗봇 애플리케이션과 별도의 웹 애플리케이션이 필요하고 “플러스 친구 자동응답 API”와 “Direct Line 3.0 REST API”를 연결해주는 기능만 해주면 된다. 즉, 카카오 톡에서 입력된 사용자의 메시지를 받고 이 메시지를 다시 Bot Framework 에서 알아 들을 수 있는 포멧으로 변경하여 Direct Line API로 전달하고 반대로 챗봇의 메시지를 다시 카카오 톡까지 전달하면 된다. 단순한 애플리케이션이라고 생각했지만 고려할 사항이 몇가지 있다.

여기에서는 ASP.NET MVC를 이용해서 웹앱을 만들어서 구현한 내용으로 설명을 하고 전체 코드는 GitHub에 공개되어 있다. ‘오일석 봇’이라는 샘플이다. 카카오톡으로 메시지를 입력하면 Bot 으로 전달되고 LUIS(Language Understanding Intelligent Service)를 통해 ‘의도(Intent)’를 알아내고 의도에 적합한 답변을 텍스트 또는 이미지로 응답하는 간단한 봇이다. 웹 앱 2개와 LUIS 앱, 대화의 세션을 저장하기 위한 Azure Cosmos DB로 구성되어 있다.

 

카카오 플러스 친구 가입 및 설정

카카오 플러스 친구를 가입하고 심사를 거쳐서 승인이된다. 관리자 센터로 가서 배경이미지, 아이콘 설명 등을 설정하고 “스마트 채팅 > API 형” 메뉴에 들어가서 “앱 URL”을 설정하고 API 테스트를 하고 저장하면 된다. 알림을 받을 전화번호를 입력해 놓으면 오류가 발생할 때 알림이 온다.

 

카카오 플러스 친구 API

상세한 API 스펙은 Github 카카오톡 플러스친구 API v. 2.0 개요 페이지에서 확인할 수 있다.  4가지 API(Keyboard, message, friend, chat_room)가 있는데 이중에 message API가 대화를 주고 받을 때 사용한다. 그리고 keyboard는 반드시 구현을 해줘야 한다. 나머지는 필요에 따라서 구현을 해주면 되는데 이 샘플에서는 message와 keyboard를 구현했다.

  • keyboard: 사용자가 접속하면 호출되어서 카카오톡 키보드 자리에 설정된 버튼을 표시해준다. 카카오 플러스 친구는 처음 시작을 버튼을 보여주면서 시작한다.
  • friend: 친구가 추가되거나 (POST) 차단(DELETE)될 때 호출된다.
  • chat_room: 사용자가 채팅방에서 나가면 호출된다.
  • message: 사용자가 메시지를 입력하면 호출 된다.

카카오톡에서 봇으로 메시지 전송

사용자가 메시지를 입력하면 message API가 호출되고 user_key, type, content 값이 POST로 전달된다. 여기서 type은 string으로 두가지 “text”, “photo” 이다. 이걸 받아서 Bot Framework가 알아 들을 수 있는 Activity 타입으로 전환을 해서 Direct Line REST API를 통해 보내면 된다.  C# 코드로 구현한다면 아래 코드를 참조하면 된다. 여기에서 보내는 채널에 대한 정보를 ChannelAccount 를 만들때 “kakao”라는 이름을 전달하는 코드가 들어 있는데 이는 봇에서 이 메시지가 카카오톡에서 왔음을 구별할 수 있는 방법이 된다.

[AcceptVerbs(HttpVerbs.Post)]
public async Task<ActionResult> Index(string user_key, string type, string content)
{
 try
 {
    // covert from Kakao talk message to Bot Builder Activity
    Activity activity = new Activity
    {
       // Bot 에서 메시지가 kakao로 부터 요청되었음을 알수 있도록 name에 kakao를 써준다. 
       From = new ChannelAccount(id:user_key,name:"kakao"),
       Type = ActivityTypes.Message
    };
    if (type == "text")
    {
       activity.Text = content;
    }
    else if (type == "photo")
    {
       activity.Attachments = new List<Attachment>();
       activity.Attachments.Add(new Attachment
       {
       ContentUrl = content
       });
    }
    var response = await conversationService.SendAndReceiveMessageAsync(user_key, activity);
    // 발견된 복수의 Activity를 넘겨서 처리
    var msg = MessageConvertor.DirectLineToKakao(response);
    return Json(msg);
    }
    catch (Exception ex)
    {
       throw new InvalidOperationException("Direct Line 연결오류", ex);
    }
}

봇에서 카카오톡으로 메시지 응답

반대로 챗봇이 사용자에게 보내는 메시지의 내용은 아래 Json 데이터처럼 메시지에 text, photo, message_button으로 메시지가 표현되고 키보드 영역에 표시가 되도록 button 도 전달 할 수 있다. C#에서는 이 메시지 JSON타입을 모델로 정의해서 사용하면 된다.

{
  "message": {
    "text": "안녕하세요",
    "photo": {
      "url": "https://photo.jpg",
      "width": 640,
      "height": 480
    },
    "message_button": {
      "label": "인사하기",
      "url": "https://coupon/url"
    }
  },
  "keyboard": {
    "type": "buttons",
    "buttons": [
      "처음으로",
      "다시 인사하기",
      "취소하기"
    ]
  }
}

메시지 전송과 대화의 유지

챗봇에 메시지를 전송하려면 DirectLine REST API를 이용해서 Activity 타입을 만들어서 전송한다. 처음 대화를 시작할 때 Authentication을 거치고 Conversation을 시작해서 ConversationID를 만드는 과정이 필요하다.  상세한 API 스펙은 Direct Line REST API 3.0 스펙에 나와있다. C#에서는 Microsoft.Bot.Connector.DirectLine 를 사용하면 쉽게 코드를 작성할 수 있다.

원래 채팅은 지금 대화를 나누다가도 내일 다시 대화를 이어나갈 수 있다. 즉, Conversation이라는 채널이 계속 유지되어야 한참 후에도 그 대화를 계속 이어나갈 수 있다. 따라서  생성된 ConversationID를 저장하고 있다가 다시 메시지가 오면 원래 ConversationID를 사용하여 메시지를 전송해야만 대화가 새로 시작되지 않고 이어진다. 마치 웹 애플리케이션에서 세션을 유지하듯이 별도의 저장공간에 CoversationID를 저장하고 있다가 꺼내써야 한다. 이를 위해서 샘플에서는 Azure Cosmos DB를 사용했고 여기에 카카오톡이 전달해준 user_key, Conversation 정도, 타임아웃을 체크하기 위한 시간, 최신 응답 메시지만 가져오기 위한 watermark 정보를 저장했다. 카카오 플러스 친구 API가 전송해준 user_key 는 대화방을 나가기 전까지 유효한 아이디 역할을 하고 Bot Framework 쪽에서는 ConversationID가 대화를 유지하는데 키가 되는 값이기 때문에 카카오 톡에서 대화를 종료하기 전까지 이 두가지를 같이 저장해야 한다. 또한 Bot Framework의 Conversation은 30분의 Timeout이 있기 때문에 30분이 지났다면 다시 연결을 해주는 로직도 필요하다.

// 메시지를 Direct Line API를 사용하여 Bot에 전송
public async Task SendMessageAsync(string userkey, Activity activity)
{
   DirectLineClient client;
   client = new DirectLineClient(ConfigurationManager.AppSettings["DirectLineSecret"]);
   client.SetUserAgent("kakao");

   await ConnectAsync(userkey);
   await client.Conversations.PostActivityAsync(conversation.ConversationId, activity);
}

// Direct Line API에 메시지를 보내기 전에 연결
// userkey를 기준으로 Database에 저장된 정보를 가져와서 Conversation을 새로 만들거나 기존 ID를 사용하여 연결
public async Task ConnectAsync(string userkey)
{
    if (conversation != null) return;

    // database 에서 ConversationInfo 가져옴 
    conversationinfo = await sessionService.GetInfoAsync(userkey);
    if (conversationinfo == null)
    {
        conversation = await client.Conversations.StartConversationAsync();
        await SaveConversationInfoAsync(conversation, userkey, "", DateTimeOffset.Now);
    }
    else
    {
        if (!conversationinfo.coversation.ExpiresIn.HasValue || !conversationinfo.timestamp.HasValue)
        {
            conversation = await client.Conversations.ReconnectToConversationAsync(conversationinfo.coversation.ConversationId);
            await SaveConversationInfoAsync(conversation, userkey, conversationinfo.watermark, DateTimeOffset.Now);
        }
        // timeout 체크. 30분
        var now = DateTimeOffset.Now;
        var timeoutdate = conversationinfo.timestamp.Value.AddSeconds(conversationinfo.coversation.ExpiresIn.Value - 300);
        var diff = timeoutdate - now;
        if (diff > TimeSpan.MinValue)
        {
            conversation = conversationinfo.coversation;
        }
        else
        {
            // 타임아웃이며 다시 커넥트를 해야 한다.
            conversation = await client.Conversations.ReconnectToConversationAsync(conversationinfo.coversation.ConversationId);
            await SaveConversationInfoAsync(conversation, userkey, conversationinfo.watermark, DateTimeOffset.Now);
        }
    }
}

챗봇에서 메시지 응답 받기

메시지를 응답받는 코드의 핵심은 GetActivityAsync 메서드로 ConversationId 와 watermark를 전달한다. watermark의 용도는 마지막으로 전달 받은 메시지만 가져오기 위함이다. 메시지 하나가 전송될 때마다 watermark 값이 단순 증가하는데 카카오톡에서 받은 메시지를 전송하고 받은 watermark를 저장하고 있다가 GetActivityAsync() 를 호출할때 사용하면 그 이후의 watermark 값을 가지고 있는 응답 메시지만 가져온다. 이 때 여러개의 Activity가 한꺼번에 응답될 수 있으므로 코드에서는 여러개의 activity를 처리할 수 있도록 코딩을 해 놓는게 좋겠다.

public async Task<IList<Activity>> ReceiveMessageAsync(string userkey)
{
    await ConnectAsync(userkey);
    
    // 응답 메시지를가져온다. 
    var activitySet = await client.Conversations.GetActivitiesAsync(conversationinfo.coversation.ConversationId, conversationinfo.watermark);
    conversationinfo.watermark = activitySet?.Watermark;
    // Conversation 저장
    await SaveConversationInfoAsync(conversation, userkey, conversationinfo.watermark, conversationinfo.timestamp.Value);

    // appSettings 에 설정한 BotId 는 bot을 등록할 때 사용한 Bot handler 와 같아야 한다. 
    var activities = from x in activitySet.Activities
                            where x.From.Id == botId
                            select x;

    return activities.ToList();
}

챗봇에서 전달 받은 메시지의 변환

챗봇이 카카오톡으로 메시지를 보낼때는 몇가지 문제가 있다. Bot Framework에서는 단순 메시지나 이미지 전달 말고도 다양한 포멧으로 메시지를 전달할 수 있도록 여러가지 Attachment를 지원한다. “Add rich card attachments to message” 문서를 보면 AdaptiveCard, HeroCard, ThumbnailCard 등 다양한 카드타입을 지원하는 걸 알 수 있다. Bot Framework의 공식 채널(메신저)들은 각자 표현은 조금씩 다르지만 이런 카드들을 사용할 수 있다. 하지만 카카오톡은 그렇지 못하다. 따라서 카카오톡으로만 챗봇 서비스를 하는게 아니라면 사용자에게 전달할 컨텐츠를 어떤 방식을 통해 전달할지에 대해서 기획을 하고 그 내용을 카카오톡에서는 어떻게 표현할지 생각해 봐야 한다.

또한 챗봇은 여러개의 메시지로 응답을 나눠 보낼 수도 있다. 예를들어 안내 텍스트를 하나의 Activity로 보내고 그 다음에 이미지를 한장 보내고 마지막으로 사용자가 선택할 수있는 버튼이 달린 카드를 보낼 수 있다. 즉 3번을 응답할 수 있고 채널에서는 각각의 메시지를 사용자에게 보여준다. 하지만 카카오톡의 API는 message API가 요청되면 그 응답으로 한번의 메시지만 전달 할 수 있다. Request 당 하나의 메시지만 전달 해야한다.

따라서 이런 특성을 이해하고 코드를 작성하는게 중요하다. 여러 고민을 해봤지만 제일 바람직한 방법은 챗봇 코드에서 카카오톡에서 온 요청을 인식하고, 하나의 Activity에 메시지와 이미지 하나를 만들어서 응답하는게 제일 좋은 방법으로 생각된다.

public static Models.MessageResponse DirectLineToKakao(IList<Activity> activities)
{
    if (activities == null || activities.Count <= 0) return null;

    var msg = new Models.MessageResponse();
    // 여러개의 Activity
    foreach (var activity in activities)
    {
        if (activity.Type != ActivityTypes.Message) continue;

        if (msg.message == null) msg.message = new Message();
        // 텍스트 메시지를 누적 시킴
        msg.message.text += "\n" + activity.Text;

        if (activity.Attachments != null && activity.Attachments.Count > 0)
        {
            foreach (Attachment attachment in activity.Attachments)
            {
                switch (attachment.ContentType)
                {
                    case "image/png":
                    case "image/jpeg":
                        // activity는 attachment가 배열로 여러개가 오지만 Kakao는 한개만 가능.
                        // 따라서 처음 하나만 보여지는 걸로 ... 
                        if (msg.message.photo == null)
                        {
                            msg.message.photo = new Photo
                            {
                                url = attachment.ContentUrl
                            };
                        }
                        break;
                }
            }
        }
    }
    return msg;
}

여러개의 Activity를 받아서 카카오톡이 표현할 수 있는 메시지 형태로 변환해주는 코드는 위와 같다. 텍스트를 붙여서 하나로 만든다거나 Attachment 중에 이미지 타입만 처리하고 그것도 한개만 처리하도록 했다. 데이터가 누락되는 것이다. 여기에서는 이렇게 방어코드를 넣어놓고 챗봇에서 카카오톡으로 보낼 때만 특별히 하나의 Activity 응답으로 만들어서 보내도록 분기를 해주는 방법이 적당할 것 같다. 그 방법은 카카오톡에서 전송된 Activity의 내용중에 From 을 살펴보고 분기하는 방법이다.

[LuisIntent("인사")]
public async Task Greeting(IDialogContext context, LuisResult result)
{
    if (context.Activity.From.Name == "kakao")
    {
        string message = $"안녕하세요. 저는 오일석 봇입니다. 저를 만든 오일석을 대신해서 제가 도움을 드릴 수 있으면 좋겠네요.  저는 이런걸 할 수 있어요.\n 제 소개를 해드릴 수 있어요.\n 제 인사를 할수도 있죠.";
        await context.PostAsync(message);
    }
    else
    {
        string message = $"안녕하세요. 저는 오일석 봇입니다. 저를 만든 오일석을 대신해서 제가 도움을 드릴 수 있으면 좋겠네요.  ";
        await context.PostAsync(message);

        string message2 = $"저는 이런걸 할 수 있어요.\n 제 소개를 해드릴 수 있어요.\n 제 인사를 할수도 있죠.";
        await context.PostAsync(message2);
        context.Wait(MessageReceived);
    }
}

 

해결하지 못한 두가지 문제점

  • 카카오톡으로 이미지를 전달할 때 width와 height를 같이 주도록 되어 있고 값을 넘기지 않으면 이미지를 표시하지 않는다. 하지만 전체 흐름에서 보면 width와 height를 알아내서 전달해주기 위해서는 이미지를 다운받아서 width/height를 직접 알아내는 방법 뿐이다. (굳이 왜 w / h를 받으려고 해쓰가…)
  • 타임아웃 문제 : 카카오톡 API는 5초안에 응답이 안오면 오류를 발생시킨다. 채팅인데 5초 타임아웃은 가혹하다.  봇 커넥터를 통해서 봇까지 가서 LUIS 서비스를 다녀오고 혹여나 백엔드 서비스까지 있다면 타임아웃이 걸릴 수도 있다. 실제로 관리자에게 오류 메시지가 오는데 타임아웃이 원인인 것 같다. 그리고 그 원인을 살펴볼 방법이 없다.

마무리

두개의 웹앱을 모두 테스트 해야하는데 두개를 모두 로컬 머신에서 테스트하는게 불가능했다. 모두 배포를 한 후에 테스트를 하거나 반쪽씩 테스트를 진행해야 했다. Postman과 fiddler를 모두 동원해서 오고가는 메시지를 확인하면서 테스트를 해야한다. 따라서 별도의 개발환경을 Azure에 구축하고 배포 후 테스트를 하는 방법이 가장 적당할 것이다.

여기까지 주요 코드를 살펴보면서 카카오톡과 Microsoft Bot Framework를 붙여서 챗봇을 서비스하는 방법을 살펴봤다. 카카오에서 좀 더 멋진 챗봇 프레임워크를 만들어서 공개할 것으로 믿고 그 방법이 Microsoft Bot Framework와 잘 연동이 되었으면 하는 바램이다.

추가 자료