Microsoft Botframework + Adaptive Cards 快速打造 Chatbot

前言

今年的 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 Explorer
Adaptive Cards Designer
Deserializing different types based on properties, with Newtonsoft.Json
Bot Framework Custom Error Messages and Exception Handling
BotFramework-WebChat Customization
Bot Framework Typing Activity – Let users know your bot is responding (and know when they are too)
Customize Web Chat for your websites – 亂馬客

作者: 亂馬客

亂馬客 @叡揚資訊 rainmaker_ho@gss.com.tw https://rainmakerho.github.io/ https://www.slideshare.net/rainmakerho

發表迴響

你的電子郵件位址並不會被公開。