Microsoft Bot Framework FAQ - 輕輕鬆鬆透過 Direct Line 去接 LINE BOT

透過 Microsoft Bot Framework 來開發 BOT ,透過它的 Bot Connector service ,

可以讓我們寫一套程式,就可以處理不同的 IM Channel(例如 skype, slack ...)。

但是台灣常用的 LINE 卻不在它 Support 的 Channel 之中。

目前只能透過 Direct Line 的方式去接 LINE BOT。

所以 Kenichiro Nakamura 有幫我們封裝 LineClient (line-bot-sdk-csharp) 來處理跟 LINE BOT 溝通的部份。

以下我們一步步來介紹如何使用它,

 

1.啟用 Direct Line

MyBots 那,啟用 Direct Line ,勾選 3.0 ,並將 Secret Key Copy 下來(程式會用到它)。

2.申請 LINE BOT 

關於如何申請 LINE BOT 請參考「關於Linebot(2) - 新版Line@ Messaging API使用心得 (Line Bot v2)」說明。

LINE@MANAGER 建立帳號,並設定好 BOT 後(帳號設定 -> Bot設定),可以點「開啟 LINE Developers」,如下圖,

到了 LINE Developers 後,要記下來的就是 Channel Secret 及 Channel Access Token (程式會用到它)。

另外就是要設定 Webhook URL ,因為是在原先的 BOT 專案中新增一個 LineMessagesController ,所以 URL 類似(https://你的BOTURL/api/LineMessages) 。

3.加入 LineMessagesController

3.1. 從 Nuget 安裝 Microsoft.Bot.Connector.DirectLine 套件,如下,

3.2. 從 Nuget 安裝 LineMessagingAPI.Csharp 套件,如下,

3.3. 在 web.config 的 appSettings 區段中加入 Direct Line Secret 及 LINE 的 Channel Secret 及 Channel Access Token ,如下,

<appSettings>
    <!-- 其他設定 .... -->
    <add key="DirectLineSecret" value="Direct Line Secret Key" />
    <!-- LINE Bot Info-->
    <add key="LineChannelSecret" value="Line Channel Secret" />
    <add key="LineChannelAccessToken" value="Line Channel Access Token" />
    <!-- 其他設定 .... -->
</appSettings>

3.4. 在 Controllers 目錄,新增 LineMessagesController , 並貼上以下的 Code (或是從 LineWithBotFrameworkApplication Copy出來也可以)。

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using System.Web.Configuration;
using System.Web.Http;
using LineMessagingAPISDK;
using LineMessagingAPISDK.Models;
using Microsoft.Bot.Connector.DirectLine;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using dl = Microsoft.Bot.Connector.DirectLine;
using lm = LineMessagingAPISDK.Models;
//https://github.com/kenakamu/line-bot-sdk-csharp/tree/master/LineMessagingAPISDK
namespace <<這裡請改您的namespace>>.Controllers
{
public class LineMessagesController : ApiController
{
public async Task<HttpResponseMessage> Post(HttpRequestMessage request)
{
if (!await VaridateSignature(request))
return new HttpResponseMessage(HttpStatusCode.BadRequest);
var contentString = await request.Content.ReadAsStringAsync();
try
{
var activity = JsonConvert.DeserializeObject<lm.Activity>(contentString);
foreach (var lineEvent in activity.Events)
{
LineMessageHandler handler = new LineMessageHandler(lineEvent);
await handler.Initialize();
Profile profile = await handler.GetProfile(lineEvent.Source.UserId);
switch (lineEvent.Type)
{
case EventType.Beacon:
await handler.HandleBeaconEvent();
break;
case EventType.Follow:
await handler.HandleFollowEvent();
break;
case EventType.Join:
await handler.HandleJoinEvent();
break;
case EventType.Leave:
await handler.HandleLeaveEvent();
break;
case EventType.Message:
Message message = JsonConvert.DeserializeObject<Message>(lineEvent.Message.ToString());
switch (message.Type)
{
case MessageType.Text:
await handler.HandleTextMessage();
break;
case MessageType.Audio:
case MessageType.Image:
case MessageType.Video:
await handler.HandleMediaMessage();
break;
case MessageType.Sticker:
await handler.HandleStickerMessage();
break;
case MessageType.Location:
await handler.HandleLocationMessage();
break;
}
break;
case EventType.Postback:
await handler.HandlePostbackEvent();
break;
case EventType.Unfollow:
await handler.HandleUnfollowEvent();
break;
}
}
}
catch (Exception ex)
{
Debug.WriteLine(ex.ToString());
}
return new HttpResponseMessage(HttpStatusCode.OK);
}
private async Task<bool> VaridateSignature(HttpRequestMessage request)
{
var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(WebConfigurationManager.AppSettings["LineChannelSecret"]));
var computeHash = hmac.ComputeHash(Encoding.UTF8.GetBytes(await request.Content.ReadAsStringAsync()));
var contentHash = Convert.ToBase64String(computeHash);
var headerHash = Request.Headers.GetValues("X-Line-Signature").First();
return contentHash == headerHash;
}
}
public class LineMessageHandler
{
private lm.Event lineEvent;
private static string directLineSecret = WebConfigurationManager.AppSettings["DirectLineSecret"];
private LineClient lineClient = new LineClient(WebConfigurationManager.AppSettings["LineChannelAccessToken"]);
private dl.DirectLineClient dlClient = new dl.DirectLineClient(directLineSecret);
private string _userId;
private string conversationId; // DirectLine ConversationId
private string watermark; // Limit the messages to get from DirectLine
private Dictionary<string, object> userParams;
public LineMessageHandler(lm.Event lineEvent)
{
this.lineEvent = lineEvent;
}
public async Task Initialize()
{
var lineId = lineEvent.Source.UserId ?? lineEvent.Source.GroupId ?? lineEvent.Source.RoomId;
if (CacheService.caches.Keys.Contains(lineId))
{
// Get preserved ConversationId and Watermark from cache.
// If we scale out, then we have to use different method
userParams = CacheService.caches[lineId] as Dictionary<string, object>;
conversationId = userParams.Keys.Contains("ConversationId") ? userParams["ConversationId"].ToString() : "";
watermark = userParams.Keys.Contains("Watermark") ? userParams["Watermark"].ToString() : null;
}
else
{
// If no cache, then create new one.
userParams = new Dictionary<string, object>();
var conversation = await dlClient.Conversations.StartConversationAsync();
userParams["ConversationId"] = conversationId = conversation.ConversationId;
CacheService.caches[lineId] = userParams;
watermark = null;
}
}
public async Task HandleBeaconEvent()
{
}
public async Task HandleFollowEvent()
{
}
public async Task HandleJoinEvent()
{
}
public async Task HandleLeaveEvent()
{
}
public async Task HandlePostbackEvent()
{
dl.Activity sendMessage = new dl.Activity()
{
Type = "message",
Text = lineEvent.Postback.Data,
From = new dl.ChannelAccount(lineEvent.Source.UserId, lineEvent.Source.UserId)
};
// Send the message, then fetch and reply messages,
await dlClient.Conversations.PostActivityAsync(conversationId, sendMessage);
await GetAndReplyMessages();
}
public async Task HandleUnfollowEvent()
{
}
public async Task<Profile> GetProfile(string mid)
{
return await lineClient.GetProfile(mid);
}
public async Task HandleTextMessage()
{
var textMessage = JsonConvert.DeserializeObject<TextMessage>(lineEvent.Message.ToString());
dl.Activity sendMessage = new dl.Activity()
{
Type = "message",
Text = textMessage.Text,
From = new dl.ChannelAccount(lineEvent.Source.UserId, lineEvent.Source.UserId)
};
// Send the message, then fetch and reply messages,
try
{
await dlClient.Conversations.PostActivityAsync(conversationId, sendMessage);
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
try
{
await GetAndReplyMessages();
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
public async Task HandleMediaMessage()
{
Message message = JsonConvert.DeserializeObject<Message>(lineEvent.Message.ToString());
// Get media from Line server.
var media = await lineClient.GetContent(message.Id);
await dlClient.Conversations.UploadAsync(conversationId, media.Content, lineEvent.Source.UserId, media.ContentType);
await GetAndReplyMessages();
}
public async Task HandleStickerMessage()
{
//https://devdocs.line.me/files/sticker_list.pdf
var stickerMessage = JsonConvert.DeserializeObject<StickerMessage>(lineEvent.Message.ToString());
var message = new StickerMessage("1", "1");
await Reply(new List<Message>() { message });
}
public async Task HandleLocationMessage()
{
var locationMessage = JsonConvert.DeserializeObject<LocationMessage>(lineEvent.Message.ToString());
dl.Activity sendMessage = new dl.Activity()
{
Type = "message",
Text = locationMessage.Title,
From = new ChannelAccount(lineEvent.Source.UserId, lineEvent.Source.UserId),
//From = new ChannelAccount(lineEvent.Source.UserId, lineEvent.Source.UserId),
Entities = new List<Entity>()
{
new Entity()
{
Type = "Place",
Properties = JObject.FromObject(new Place(address:locationMessage.Address,
geo:new dl.GeoCoordinates(
latitude: locationMessage.Latitude,
longitude: locationMessage.Longitude,
name: locationMessage.Title),
name:locationMessage.Title))
}
}
};
// Send the message, then fetch and reply messages,
await dlClient.Conversations.PostActivityAsync(conversationId, sendMessage);
await GetAndReplyMessages();
}
private async Task Reply(List<Message> replyMessages)
{
int i = 0;
try
{
await lineClient.ReplyToActivityAsync(lineEvent.CreateReply(
messages: replyMessages.Take(5).ToList()));
if (replyMessages.Count > 5)
{
i = 1;
while (replyMessages.Count > i * 5)
{
await lineClient.PushAsync(lineEvent.CreatePush(
messages: replyMessages.Skip(i * 5).Take(5).ToList()));
i++;
}
}
}
catch
{
try
{
while (replyMessages.Count > i * 5)
{
await lineClient.PushAsync(lineEvent.CreatePush(
messages: replyMessages.Skip(i * 5).Take(5).ToList()));
i++;
}
}
catch (Exception ex)
{
#if DEBUG
await lineClient.PushAsync(lineEvent.CreatePush(ex.Message, message: null));
#endif
}
}
}
/// <summary>
/// Get all messages from DirectLine and reply back to Line
/// </summary>
private async Task GetAndReplyMessages()
{
dl.ActivitySet result = string.IsNullOrEmpty(watermark) ?
await dlClient.Conversations.GetActivitiesAsync(conversationId) :
await dlClient.Conversations.GetActivitiesAsync(conversationId, watermark);
userParams["Watermark"] = (Int64.Parse(result.Watermark)).ToString();
foreach (var activity in result.Activities)
{
if (activity.From.Id == lineEvent.Source.UserId)
continue;
List<Message> messages = new List<Message>();
if (activity.Attachments != null && activity.Attachments.Count != 0 && (activity.AttachmentLayout == null || activity.AttachmentLayout == "list"))
{
foreach (var attachment in activity.Attachments)
{
if (attachment.ContentType.Contains("card.animation"))
{
// https://docs.botframework.com/en-us/core-concepts/reference/#animationcard
// Use TextMessage for title and use Image message for image. Not really an animation though.
AnimationCard card = JsonConvert.DeserializeObject<AnimationCard>(attachment.Content.ToString());
messages.Add(new TextMessage($"{card.Title}\r\n{card.Subtitle}\r\n{card.Text}"));
foreach (var media in card.Media)
{
var originalContentUrl = media.Url?.Replace("http://", "https://");
var previewImageUrl = card.Image?.Url?.Replace("http://", "https://");
messages.Add(new ImageMessage(originalContentUrl, previewImageUrl));
}
}
else if (attachment.ContentType.Contains("card.audio"))
{
// https://docs.botframework.com/en-us/core-concepts/reference/#audiocard
// Use TextMessage for title and use Audio message for image.
AudioCard card = JsonConvert.DeserializeObject<AudioCard>(attachment.Content.ToString());
messages.Add(new TextMessage($"{card.Title}\r\n{card.Subtitle}\r\n{card.Text}"));
foreach (var media in card.Media)
{
var originalContentUrl = media.Url?.Replace("http://", "https://");
var durationInMilliseconds = 1;
messages.Add(new AudioMessage(originalContentUrl, durationInMilliseconds));
}
}
else if (attachment.ContentType.Contains("card.hero") || attachment.ContentType.Contains("card.thumbnail"))
{
// https://docs.botframework.com/en-us/core-concepts/reference/#herocard
// https://docs.botframework.com/en-us/core-concepts/reference/#thumbnailcard
HeroCard hcard = null;
if (attachment.ContentType.Contains("card.hero"))
hcard = JsonConvert.DeserializeObject<HeroCard>(attachment.Content.ToString());
else if (attachment.ContentType.Contains("card.thumbnail"))
{
ThumbnailCard tCard = JsonConvert.DeserializeObject<ThumbnailCard>(attachment.Content.ToString());
hcard = new HeroCard(tCard.Title, tCard.Subtitle, tCard.Text, tCard.Images, tCard.Buttons, null);
}
var imageUrl = string.Empty;
if (hcard.Images.Any())
{
imageUrl = hcard.Images?.First().Url.Replace("http://", "https://");
}
string text = string.IsNullOrEmpty(hcard.Subtitle) ? hcard.Text : hcard.Subtitle;
if (string.IsNullOrEmpty(text))
{
text = " ";
}
ButtonsTemplate buttonsTemplate = new ButtonsTemplate(
imageUrl,
hcard.Subtitle == null ? null : hcard.Title,
text);
if (hcard.Buttons != null)
{
foreach (var button in hcard.Buttons)
{
buttonsTemplate.Actions.Add(GetAction(button));
}
}
else
{
// Action is mandatory, so create from title/subtitle.
var actionLabel = hcard.Title?.Length < hcard.Subtitle?.Length ? hcard.Title : hcard.Subtitle;
buttonsTemplate.Actions.Add(new PostbackTemplateAction(actionLabel, actionLabel, actionLabel));
}
messages.Add(new TemplateMessage("Buttons template", buttonsTemplate));
}
else if (attachment.ContentType.Contains("receipt"))
{
// https://docs.botframework.com/en-us/core-concepts/reference/#receiptcard
// Use TextMessage and Buttons. As LINE doesn't support thumbnail type yet.
ReceiptCard card = JsonConvert.DeserializeObject<ReceiptCard>(attachment.Content.ToString());
var text = card.Title + "\r\n\r\n";
foreach (var fact in card.Facts)
{
text += $"{fact.Key}:{fact.Value}\r\n";
}
text += "\r\n";
foreach (var item in card.Items)
{
text += $"{item.Title}\r\nprice:{item.Price},quantity:{item.Quantity}";
}
messages.Add(new TextMessage(text));
ButtonsTemplate buttonsTemplate = new ButtonsTemplate(title: $"total:{card.Total}", text: $"tax:{card.Tax}");
foreach (var button in card.Buttons)
{
buttonsTemplate.Actions.Add(GetAction(button));
}
messages.Add(new TemplateMessage("Buttons template", buttonsTemplate));
}
else if (attachment.ContentType.Contains("card.signin"))
{
// https://docs.botframework.com/en-us/core-concepts/reference/#signincard
// Line doesn't support auth button yet, so simply represent link.
SigninCard card = JsonConvert.DeserializeObject<SigninCard>(attachment.Content.ToString());
ButtonsTemplate buttonsTemplate = new ButtonsTemplate(text: card.Text);
foreach (var button in card.Buttons)
{
buttonsTemplate.Actions.Add(GetAction(button));
}
messages.Add(new TemplateMessage("Buttons template", buttonsTemplate));
}
else if (attachment.ContentType.Contains("card.video"))
{
// https://docs.botframework.com/en-us/core-concepts/reference/#videocard
// Use Video message for video and buttons for action.
VideoCard card = JsonConvert.DeserializeObject<VideoCard>(attachment.Content.ToString());
foreach (var media in card.Media)
{
var originalContentUrl = media?.Url?.Replace("http://", "https://");
var previewImageUrl = card.Image?.Url?.Replace("http://", "https://");
messages.Add(new VideoMessage(originalContentUrl, previewImageUrl));
}
ButtonsTemplate buttonsTemplate = new ButtonsTemplate(title: card.Title, text: $"{card.Subtitle}\r\n{card.Text}");
foreach (var button in card.Buttons)
{
buttonsTemplate.Actions.Add(GetAction(button));
}
messages.Add(new TemplateMessage("Buttons template", buttonsTemplate));
}
else if (attachment.ContentType.Contains("image"))
{
var originalContentUrl = attachment.ContentUrl?.Replace("http://", "https://");
var previewImageUrl = string.IsNullOrEmpty(attachment.ThumbnailUrl) ? attachment.ContentUrl?.Replace("http://", "https://") : attachment.ThumbnailUrl?.Replace("http://", "https://");
messages.Add(new ImageMessage(originalContentUrl, previewImageUrl));
}
else if (attachment.ContentType.Contains("audio"))
{
var originalContentUrl = attachment.ContentUrl?.Replace("http://", "https://");
var durationInMilliseconds = 0;
messages.Add(new AudioMessage(originalContentUrl, durationInMilliseconds));
}
else if (attachment.ContentType.Contains("video"))
{
var originalContentUrl = attachment.ContentUrl?.Replace("http://", "https://");
var previewImageUrl = attachment.ThumbnailUrl?.Replace("http://", "https://");
messages.Add(new VideoMessage(originalContentUrl, previewImageUrl));
}
}
}
else if (activity.Attachments != null && activity.Attachments.Count != 0 && activity.AttachmentLayout != null)
{
CarouselTemplate carouselTemplate = new CarouselTemplate();
foreach (var attachment in activity.Attachments)
{
HeroCard hcard = null;
if (attachment.ContentType == "application/vnd.microsoft.card.hero")
hcard = JsonConvert.DeserializeObject<HeroCard>(attachment.Content.ToString());
else if (attachment.ContentType == "application/vnd.microsoft.card.thumbnail")
{
ThumbnailCard tCard = JsonConvert.DeserializeObject<ThumbnailCard>(attachment.Content.ToString());
hcard = new HeroCard(tCard.Title, tCard.Subtitle, tCard.Text, tCard.Images, tCard.Buttons, null);
}
else
continue;
TemplateColumn tColumn = new TemplateColumn(
hcard.Images.FirstOrDefault()?.Url?.Replace("http://", "https://"),
hcard.Subtitle == null ? null : hcard.Title,
string.IsNullOrEmpty(hcard.Subtitle) ? hcard.Title : hcard.Subtitle);
if (hcard.Buttons != null)
{
foreach (var button in hcard.Buttons)
{
tColumn.Actions.Add(GetAction(button));
}
}
else
{
// Action is mandatory, so create from title/subtitle.
var actionLabel = hcard.Title?.Length < hcard.Subtitle?.Length ? hcard.Title : hcard.Subtitle;
tColumn.Actions.Add(new PostbackTemplateAction(actionLabel, actionLabel, actionLabel));
}
carouselTemplate.Columns.Add(tColumn);
}
messages.Add(new TemplateMessage("Carousel template", carouselTemplate));
}
else if (activity.Entities != null && activity.Entities.Count != 0)
{
foreach (var entity in activity.Entities)
{
switch (entity.Type)
{
case "Place":
Place place = entity.Properties.ToObject<Place>();
GeoCoordinates geo = JsonConvert.DeserializeObject<GeoCoordinates>(place.Geo.ToString());
messages.Add(new LocationMessage(place.Name, place.Address.ToString(), geo.Latitude, geo.Longitude));
break;
case "GeoCoordinates":
GeoCoordinates geoCoordinates = entity.Properties.ToObject<GeoCoordinates>();
messages.Add(new LocationMessage(activity.Text, geoCoordinates.Name, geoCoordinates.Latitude, geoCoordinates.Longitude));
break;
}
}
}
else if (activity.ChannelData != null)
{
}
else if (!string.IsNullOrEmpty(activity.Text))
{
if (activity.Text.Contains("\n\n*"))
{
var lines = activity.Text.Split(new[] { "\n\n" }, StringSplitOptions.RemoveEmptyEntries);
ButtonsTemplate buttonsTemplate = new ButtonsTemplate(text: lines[0]);
foreach (var line in lines.Skip(1))
{
buttonsTemplate.Actions.Add(new PostbackTemplateAction(line, line.Replace("* ", ""), line.Replace("* ", "")));
}
messages.Add(new TemplateMessage("Buttons template", buttonsTemplate));
}
else
messages.Add(new TextMessage(activity.Text));
}
await Reply(messages);
}
}
/// <summary>
/// Create TemplateAction from CardAction.
/// </summary>
/// <param name="button">CardAction</param>
/// <returns>TemplateAction</returns>
private TemplateAction GetAction(CardAction button)
{
switch (button.Type)
{
case "openUrl":
case "playAudio":
case "playVideo":
case "showImage":
case "signin":
case "downloadFile":
return new UriTemplateAction(button.Title, button.Value.ToString());
case "imBack":
return new MessageTemplateAction(button.Title, button.Value.ToString());
case "postBack":
return new PostbackTemplateAction(button.Title, button.Value.ToString(), button.Value.ToString());
default:
return null;
}
}
}
public static class CacheService
{
public static Dictionary<string, object> caches;
static CacheService()
{
caches = new Dictionary<string, object>();
}
}
}

簡單的幾個步驟,然後在 LINE 中加入 BOT好友,您的 BOT 程式就可以在 LINE 上使用了哦! 如下,

註: 
1. 目前 Direct Line 不 Support Proactive Message 哦!
2. LineMessagesController 中會將 HeroCard 轉成 LINE 的 Buttons template 哦!
3. LINE BOT 的 Buttons template 只能在 智慧手機上使用哦! 在電腦版的 LINE 無法使用哦! 


4. 因為 Buttons template 中,subtitle, text, image, buttons , actions 都是必要的,所以要使用 HeroCard 請記得都要給哦!

 

參考資料

line-bot-sdk-csharp

An Introduction to the Microsoft Bot Framework

關於Linebot(2) - 新版Line@ Messaging API使用心得 (Line Bot v2)

Hi, 

亂馬客Blog已移到了 「亂馬客​ : Re:從零開始的軟體開發生活

請大家繼續支持 ^_^