選單
GSS 技術部落格
在這個園地裡我們將從技術、專案管理、客戶對談面和大家分享我們多年的經驗,希望大家不管是喜歡或是有意見,都可以回饋給我們,讓我們有機會和大家對話並一起成長!
若有任何問題請來信:gss_crm@gss.com.tw
10 分鐘閱讀時間 (2072 個字)

Microsoft Botframework + Adaptive Cards 快速打造 Chatbot

front-view-of-dell-display-mockup-on-white-table

前言

今年的 Chatbot 很火紅,不知大家都用什麼來開發 Chatbot 呢?
筆者使用的是 Microsoft Botframework 來開發,它提供了很多語言的 SDK,讓我們可以快速的開發出 Chatbot。
最近開發 Vitals ESP (KM) Chatbot,一開始規劃好畫面及流程後,很快就開發完成了。
接下來就跟大家分享開發的過程 :)

需求

Vitals ESP 是 KM 系統,希望 KM Chatbot 可以方便讓人查詢,在手機上畫面不大,所以需要分頁。如果有人 Mention 到你的話,也可以發通知到 Chatbot 上,讓你可以快速地回覆。
所以需求主要有 2 個,

1.使用者在輸入框輸入文字,立即依關鍵字查詢,並顯示出查詢結果(分頁),使用者可以按上一頁、下一頁去瀏覽,並可按文章串到系統去。

下圖為使用者在輸入框輸入「關鍵字」去查詢,並顯示出查詢結果
[輸入關鍵字查詢]
下圖為使用者按「下一頁」,系統切到第 2 頁
[顯示第2頁]

2.當其他人在文章中有 MENTION 到使用者時,使用者可以立馬收到別人在 CUE 你的內容。

下圖為當 KM 系統收到有人在 Cue 使用者時,除了 Mail 通知外,現在會再通知 IM ,讓使用者可以立馬可以知道
[及時通知]

下圖為使用者收到被 Cue 的內容後,可以針對該內容進行回覆
[立即回覆文章]

實作

1.定義 ACTIONS

從需求來看,可以將目前行為規劃為 2 個 Action ,一個是 Keyword Search ,另一個是 Mention 回覆。
當使用者從輸入框輸入文字的查詢,它的查詢頁為第 1 頁,卡片中的上、下頁,則依 Acton 中的頁碼來決定。
Mention 回覆則需要記錄要要回覆的文章相關資訊及回覆的內容。
所以以下就建立這 2 個 Action 的類別,如下,

public enum KMActionType
{
none = 0,
SearchKeyword,
ReplyDoc
}
[JsonConverter(typeof(KMActionConverter))]
public class KMAction
{
public KMActionType Action { get; set; }
}
//查詢 Keyword 的 Action
public class KMSearchAction : KMAction
{
public KMSearchAction()
{
Action = KMActionType.SearchKeyword;
}
public string Keyword { get; set; }
public int PageIndex { get; set; }
}
//回覆 Mention 的 Action
public class KMReplyMentionAction : KMAction
{
public KMReplyMentionAction()
{
Action = KMActionType.ReplyDoc;
}
//這個是讓 User 輸入的內容,會對應到 TextInput 的 Id
public string ReplyContent { get; set; }
//這個是要回覆所需要的資訊
public KMPost PostInfo { get; set; }
}
//回覆所需要的資訊物件
public class KMPost
{
public string ParentId { get; set; } //這裡指的是 Document Id
// .....
}
////https://blog.mbwarez.dk/deserializing-different-types-based-on-properties-with-newtonsoft-json/
public class KMActionConverter : JsonConverter
{
/// <summary>
/// 依 KMActionType 來決定要轉回什麼物件
/// </summary>
/// <param name="reader"></param>
/// <param name="objectType"></param>
/// <param name="existingValue"></param>
/// <param name="serializer"></param>
/// <returns></returns>
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
var jObject = JToken.ReadFrom(reader);
KMActionType type = jObject["Action"].ToObject<KMActionType>();
KMAction result = null;
switch (type)
{
case KMActionType.SearchKeyword:
result = new KMSearchAction();
break;
case KMActionType.ReplyDoc:
result = new KMReplyMentionAction();
break;
default:
throw new ArgumentOutOfRangeException();
}
serializer.Populate(jObject.CreateReader(), result);
return result;
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
throw new NotImplementedException();
}
public override bool CanConvert(Type objectType)
{
// Not needed, as we register our converter directly on Vehicle
throw new NotImplementedException();
}
//只做Read,不做Write
public override bool CanWrite => false;
}

從上面可以發現我們定義了 2 個 Action ,分別為 KMSearchAction 及 KMReplyMentionAction ,它們都繼承自 KMAction 。
而設定 KMActionConverter 可以讓我們依 KMActionType 來分別 Deserialize 到對應的物件 ,如下的程式,
//會依 KMActionType 來分別轉成對應的物件 (KMSearchAction or KMReplyMentionAction )
messageAction = JsonConvert.DeserializeObject<KMAction>(dataValue);

當使用者按 上、下頁時,透過 JsonConvert.DeserializeObject 時,實際上會轉換成 KMSearchAction 類別,
[查詢的 KMSearchAction]

當使用者在回覆卡片上按下送出時,透過 JsonConvert.DeserializeObject 時,實際上會轉換成 KMReplyMentionAction 類別,
[回覆文章的 KMReplyMentionAction]

2.建立查詢結果及 MENTION 回覆的 ADAPTIVE CARDS

使用 Adaptive Cards 時,需要從 Nuget 中安裝 AdaptiveCards 套件,如下,
[AdaptiveCards 套件]

AdaptiveSubmitAction 物件有一個 DataJson 的屬性,是可以讓我們放入物件的 JSON 字串 。
所以在建立畫面這些 Button 時,就可以建立 Action 物件後,將它們的 JSON 放到 DataJson 屬性中 。
當使用者按下 Button 時,它的值就會在 MessageActivity 的 Value 屬性之中 。
所以在 RootDialog 中,我們就可以用這個屬性值來區分是按下 Button 進來的,還是使用者從輸入框輸入字串進來的,如下,
public class RootDialog : IDialog<object>
{
public async Task StartAsync(IDialogContext context)
{
context.Wait(MessageReceivedAsync);
}
private async Task MessageReceivedAsync(IDialogContext context, IAwaitable<IMessageActivity> result)
{
var message = await result;
var searchKeyword = message.Text;
KMAction messageAction = null;
var dataValue = message.Value?.ToString();
//如果 value 有值,就是按 action 進來的
if (!string.IsNullOrWhiteSpace(dataValue))
{
//button 進來的
//會透過 KMActionConverter 來自動轉換
messageAction = JsonConvert.DeserializeObject<KMAction>(dataValue);
}
else
{
//從輸入框進來的 Keyword,所以是在第一頁(base 0)
var pageIndex = 0;
messageAction = new KMSearchAction
{
Action = KMActionType.SearchKeyword,
Keyword = searchKeyword,
PageIndex = pageIndex
};
}
var kmUser = GetKMUser(message);
//依不同的 Action 來產生對應的處理 Class
var actionStrategy = ActionStrategyResolver.ResolveActionStrategy(messageAction.Action);
await actionStrategy.DoAction(context, kmUser, messageAction);
context.Done("");
}
/// <summary>
/// 取得使用者的 UserName
/// </summary>
/// <param name="userToBotMessage"></param>
/// <returns></returns>
private static KMUser GetKMUser(IMessageActivity userToBotMessage)
{
var loginId = new KMUser(userToBotMessage.From.Id);
return loginId;
}
}

 

筆者建立執行 Action 的 IActionStrategy interface,然後將 Keywrod Search 與 回覆 Mention 分別放到不同的類別之中,並實作 IActionStrategy 。
然後再透過 KMActionType 來決定要生成那個類別,最後執行 DoAction 就可以了 。

/// <summary>
/// 定義 Action 共通的 interface
/// </summary>

public interface IActionStrategy
{
Task DoAction(IDialogContext context, KMUser user, KMAction action);
}
public class ActionStrategyResolver
{
/// <summary>
/// 依不同的 Type 決定要用那個 Class
/// </summary>
/// <param name="type"></param>
/// <returns></returns>
public static IActionStrategy ResolveActionStrategy(KMActionType type)
{
IActionStrategy result = null;
switch (type)
{
case KMActionType.SearchKeyword:
result = new SearchKeywordActionStrategy();
break;
case KMActionType.ReplyDoc:
result = new ReplyMentionActionStrategy();
break;
default:
throw new ArgumentOutOfRangeException();
}
return result;
}
}


在 SearchKeywordActionStrategy Class 中,依 KMSearchAction 的內容,來建立查詢結果的 Adaptive Card

/// <summary>
/// 專門處理 KM Keyword Search
/// </summary>

public class SearchKeywordActionStrategy : IActionStrategy
{
/// <summary>
/// 處理Search Keyword動作
/// </summary>
/// <param name="context"></param>
/// <param name="userName"></param>
/// <param name="messageAction"></param>
/// <returns></returns>
public async Task DoAction(IDialogContext context, KMUser kmUser, KMAction messageAction)
{
var action = messageAction as KMSearchAction;
//先做 Action 前的驗證
//1.必需要2個字(以上)
const int lowestLength = 2;
if (string.IsNullOrWhiteSpace(action.Keyword) || action.Keyword.Length < lowestLength)
{
throw new BotException($"查詢文字請至少{lowestLength}個字元,謝謝您!");
}
var attachment = await BuildSearchResultCard(kmUser.LoginId, action);
if (attachment == null)
{
//查不到
throw new BotException($"查不到任到資料,請重新查詢...");
}
var replyMessage = context.MakeMessage();
replyMessage.Attachments.Add(attachment);
await context.PostAsync(replyMessage);
}
/// <summary>
/// 依使用者及Action 建立 Search結果的卡片
/// </summary>
/// <param name="userId"></param>
/// <param name="action"></param>
/// <returns></returns>
private static async Task<Attachment> BuildSearchResultCard(string userId, KMSearchAction action)
{
var pageIndex = action.PageIndex;
var searchKeyword = action.Keyword;
//同時取回 文章及總筆數
var searchResult = SearchKM(searchKeyword, pageIndex);
var searchCount = searchResult.Item2;
//沒任何資料
if (searchCount == 0)
return null;
var card = new AdaptiveCard();
card.Body.Add(new AdaptiveTextBlock()
{
Text = $"查詢「{searchKeyword}」共 {searchCount} 筆,",
Weight = AdaptiveTextWeight.Bolder
});
card.Body.Add(new AdaptiveTextBlock()
{
Text = VitalsESPHelper.GetRangeString(pageIndex, searchCount),
Weight = AdaptiveTextWeight.Bolder
});
//內容
foreach (var doc in searchResult.Item1)
{
card.Body.Add(new AdaptiveTextBlock()
{
//markdown link
Text = $"[{doc.Title}]({doc.Url})",
Weight = AdaptiveTextWeight.Bolder
});
card.Body.Add(new AdaptiveTextBlock()
{
Text = $"...子資訊...",
IsSubtle = true
});
}
//產生上、下一頁
var pageSize = 5;
var totalPage = (searchCount + pageSize - 1) / pageSize;
if (pageIndex > 0)
{
//上一頁
var actionPageIndex = pageIndex - 1;
var preAction = new KMSearchAction
{
Action = KMActionType.SearchKeyword,
Keyword = searchKeyword,
PageIndex = actionPageIndex
};
card.Actions.Add(new AdaptiveSubmitAction()
{
Title = "上一頁",
Data = $"",
DataJson = JsonConvert.SerializeObject(preAction)
});
}
if ((pageIndex + 1) < totalPage)
{
//下一頁
var actionPageIndex = pageIndex + 1;
var nextAction = new KMSearchAction
{
Action = KMActionType.SearchKeyword,
Keyword = searchKeyword,
PageIndex = actionPageIndex
};
card.Actions.Add(new AdaptiveSubmitAction()
{
Title = "下一頁",
Data = $"",
DataJson = JsonConvert.SerializeObject(nextAction)
});
}
var attachment = new Attachment()
{
ContentType = AdaptiveCard.ContentType,
Content = card
};
return attachment;
}
//依 keyword 去Search,取回查詢結果
public static async Task<Tuple<List<KMDocument>, int>> SearchKM(string keyword, int pageIndex)
{
var searchCount = search 總筆數;
var kmDocs = search 總文件;
return new Tuple<List<KMDocument>, int>(kmDocs, searchCount);
}
}

上面建立 Adaptive Cards ,我是透過 Card Elements 一個一個來加入 。
您也可以到 Adaptive Cards Designer 設計好之後,將 json 存檔後,透過 AdaptiveCard.FromJson將它們匯進來哦!

建立 Mention 回覆的 Adaptive Cards ,是在另一個 Controller 在收到通知後,就建立它。
主要部份是建立 KMReplyMentionAction Class 一樣給 AdaptiveSubmitAction 的 DataJson 屬性,而 AdaptiveTextInput 的 Id 值要跟 KMReplyMentionAction Class 中的屬性值相同,只是 KMReplyMentionAction 是 PascalCase ,AdaptiveTextInput 的 Id 值是 CamelCase ,如下,
public static async Task<Attachment> BuildMentionCard()
{
//顯示 mention reply card
var card = new AdaptiveCard();
// Body content 
card.Body.Add(new AdaptiveTextBlock()
{
Text = $"誰誰誰在一篇文件(那篇文章)中提到你",
Weight = AdaptiveTextWeight.Bolder,
Wrap = true
});
//mention 內容
card.Body.Add(new AdaptiveTextBlock()
{
Text = $"...文章內容...",
IsSubtle = true,
Wrap = true,
Separator = true
});
//reply
card.Body.Add(new AdaptiveTextInput()
{
//id要跟 KMReplyMentionAction 中的 ReplyContent 屬性一樣
Id = "replyContent",
IsMultiline = true,
Placeholder = $"訊息請回覆於此",
// IsRequired = true (未來才會實作)
});
var post = 從km取得要回覆的相關資訊;
var replyAction = new KMReplyMentionAction
{
Action = KMActionType.ReplyDoc,
PostInfo = post
};
card.Actions.Add(new AdaptiveSubmitAction()
{
Title = "送出",
DataJson = JsonConvert.SerializeObject(replyAction)
});
var attachment = new Attachment()
{
ContentType = AdaptiveCard.ContentType,
Content = card
};
return attachment;
}



處理使用者回覆的 ReplyMentionActionStrategy Class ,只要呼叫 KM API 檢查狀態 OK ,就可以了,如下,

public class ReplyMentionActionStrategy : IActionStrategy
{
/// <summary>
/// 處理快速回覆的Action
/// </summary>
/// <param name="context"></param>
/// <param name="kmUser"></param>
/// <param name="messageAction"></param>
/// <returns></returns>
public async Task DoAction(IDialogContext context, KMUser kmUser, KMAction messageAction)
{
var action = messageAction as KMReplyMentionAction;
var replyMessage = context.MakeMessage();
if (!string.IsNullOrWhiteSpace(action.ReplyContent))
{
//將內容reply進去
var postResult = await 呼叫KMAPI;
replyMessage.Text = $"回覆完成!";
}
else
{
throw new BotException($"您回覆的內容為「空白」,無法回覆!");
}
await context.PostAsync(replyMessage);
}
}

 


而在上面有些檢查筆者是 throw BotException,然後透過 Bot Framework Custom Error Messages and Exception Handling 來將訊息顯示給使用者,如下,

public class BotException : Exception
{
public BotException()
{}
public BotException(string message) : base(message)
{}
public BotException(string message, Exception inner) : base(message, inner)
{}
}
public class PostUnhandledExceptionToUserOverrideTask : IPostToBot
{
private readonly ResourceManager resources;
private readonly IPostToBot inner;
private readonly IBotToUser botToUser;
private readonly TraceListener trace;
public PostUnhandledExceptionToUserOverrideTask(IPostToBot inner, IBotToUser botToUser, ResourceManager resources, TraceListener trace)
{
SetField.NotNull(out this.inner, nameof(inner), inner);
SetField.NotNull(out this.botToUser, nameof(botToUser), botToUser);
SetField.NotNull(out this.resources, nameof(resources), resources);
SetField.NotNull(out this.trace, nameof(trace), trace);
}
public async Task PostAsync(IActivity activity, CancellationToken token)
{
try
{
await inner.PostAsync(activity, token);
}
catch (Exception ex)
{
try
{
//顯示 Message 給使用者看
await botToUser.PostAsync(ex.Message, cancellationToken: token);
}
catch (Exception inner)
{
trace.WriteLine(inner);
}
throw;
}
}
}

下圖是系統收到 Exception 後,透過客製的錯誤處理將訊息傳送給使用者,
[BotException]

 

 

  • 註:在 Application_Start 那記得要跟 Autofac 註冊 PostUnhandledExceptionToUserOverrideTask 哦!

結論

從上面的分享中,可以透過自定的 JsonConverter (KMActionConverter),在 JsonConvert.DeserializeObject 時,取回正確的物件,再交給對應的 Strategy 類別來處理。
所以只要規劃好流程腳本,再透過 Microsoft Bot Framework,就可以讓我們快速開發出 Chatbot, 而 Adaptive Cards 則讓我們可以在 Chatbot 中建構出完整的 UI ,透過 Action.Submit 將 UI 轉化成需要的 Model 大大簡化開發的複雜度。
未來如果再加一個 Action 的話,只要擴充 KMActionType 及對應的 Action 及 ActionStrategy 就可以了哦。
  • 註 1: 目前 Adaptive Cards 的 Action 只能放在最下面,未來版本應該可以放在 Card 的中間,可以到 Adaptive Cards Designer 設計看看哦~
  • 註 2: 上述範例中,因為有使用 Makedown 的 link ,所以如果用 webchat 測試的話,請加入以下的 script 哦!

<script src="https://cdnjs.cloudflare.com/ajax/libs/markdown-it/8.4.2/markdown-it.js" />


  • 註 3:有時想要讓使用者知道 Chatbot 有收到它的輸入,可以在 MessagesController 的 Post Method 中一收到訊息時,就先回個 Typing 的訊息給使用者,如下,
//先發送 typing 的 message
var connector = new GssConnectorClient(new Uri(activity.ServiceUrl));
var isTypingReply = activity.CreateReply();
isTypingReply.Type = ActivityTypes.Typing;
await connector.Conversations.ReplyToActivityAsync(isTypingReply); 

參考資料

Schema ExplorerAdaptive Cards DesignerDeserializing different types based on properties, with Newtonsoft.JsonBot Framework Custom Error Messages and Exception HandlingBotFramework-WebChat CustomizationBot Framework Typing Activity – Let users know your bot is responding (and know when they are too)Customize Web Chat for your websites - 亂馬客
WINDOWS UPDATE 導致Excel匯入功能異常
網站效能問題查看,同一網站,台北的開啟速度正常,其他的外地分行開啟速度異常的慢

相關文章

 

評論

尚無評論
已經注冊了? 這裡登入
Guest
2024/04/29, 週一

Captcha 圖像