2017年1月13日 星期五

一步一步用 .NET Web API 撰寫 LINE Webhook (LINEBot)

由於在工作上會接觸到 LINE 或 Facebook 等 Messaging API,我將過程濃縮成最精華的部分,整理在下面的介紹中,希望對需要方向的開發者有幫助。廢話不多說,直接進入主題。


建立 LINE 商用帳號

這篇文章主要的重點會擺在使用 .NET Web API 建立 LINE 的 webhook 功能,建立帳號的部分這邊僅會簡單帶過。首先先確認你已經在 LINE Business Center 中已經將機器人帳號建立完成,請注意 Messaging API 要已經是 PUBLISHED 才算是 OK。



點選 LINE Developer 可以進入主控台。可以設定 Webhook URL 或取得 Channel Access TokenChannel Secret 等必要資訊。




建立 .NET Web API Project

.NET Web API 是微軟提供的一套建立 HTTP 服務的 Framework,它可以幫助我們快速建立擁有 RESTful 風格的 Web App。




選擇 ASP.NET Web Application 專案,再選擇使用 Web API Template,就可以建立完成。


建立 LINE Webhook Controller

為了讓 LINE 將使用者訊息傳給我們處理,根據 LINE API Reference 中 Webhook API 的說明,首先必須建立一個 HTTP POST 的 API 供 LINE 呼叫。在 Controllers 目錄中加入一個 Web API 2 Controller - Empty 命名為「LineController」。



接下來,加入 Webhook 入口並設定基本的 Router。

[RoutePrefix("line")]
public class LineController : ApiController
{
    [HttpPost]
    [Route]
    public IHttpActionResult webhook()
    {   
        return Ok("OK");
    }
}

其中[RoutePrefix("line")]表示 URL 如果為 http://{hostname}/line/... 就會進入這個 Controller。
webhook()方法所套用的 Attribute:
  • [HttpPost]:規定只接受 HTTP POST 方法
  • [Route]:因為沒有給任何參數,表示 URL 為 http://{hostname}/line 時,就會執行這個方法
  • [Route("haha")]:以此類推,如果設定成這樣,就會在 URL 為 http://{hostname}/line/haha 才會執行。

完成後,就可以執行並使用 HTTP POST http://{hostname}/line 看到一個顯示 OK 的基本畫面。


綁定 Webhook URL

剛才建立好的 https://{hostname}/line 就可以設定到您帳戶主控台中的 Webhook URL,並點選「VERIFY」進行驗證,如果 URL 是有效的就會顯示 Success.


Webhook URL 是要告訴 LINE 如何將使用者的訊息傳送給你,而且必須要 SSL 加密 (https),SSL 必須是擁有第三方認證的,不能是 self signed certificate。這一點是我覺得比較麻煩的,在這邊假設您已經都準備好了。

建立 LINE Webhook Data Model

為了取得存放在 Request 中的 Body 資料,這邊採用一個比較嚴謹的作法,建立 LINE 的 Webhook data Model。這樣做主要原因有二,一是維護方便,資料格式與屬性已經都定義在 Model 中,不用每次都回到官網查文件,另外也可以借用 .NET Web API 強大的 mapping 機制,它會自動將資料對應到所指定的 Model 中,再交給我們存取。

根據官方文件的說明,從 LINE 傳遞過來的資料大概會長這樣:

{
  "events": [
    {
      "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
      "type": "message",
      "timestamp": 1462629479859,
      "source": {
        "type": "user",
        "userId": "U206d25c2ea6bd87c17655609a1c37cb8"
      },
      "message": {
        "id": "325708",
        "type": "text",
        "text": "Hello, world"
      }
    },
    {
      "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
      "type": "follow",
      "timestamp": 1462629479859,
      "source": {
        "type": "user",
        "userId": "U206d25c2ea6bd87c17655609a1c37cb8"
      }
    }
  ]
}


觀察官方文件之後,大約可以訂出一個初步的 LINE Webhook data Model。首先,建立一個全新的 Model,命名為「LineWebhookModels.cs」


Event Object
根據官方說明,傳進來的資料由數個 event 組成,所以資料表示成 {events:[ ... ]} ,其中又分為 message, follow, unfollow, join, leave, postback, beacon 7種 Event。
namespace LINE_Webhook.Models
{
    public enum EventType { message, follow, unfollow, join, leave, postback, beacon }
    
    public class LineWebhookModels
    {
        public List<Event> events { get; set; }
    }

    public class Event
    {
        public EventType type { get; set; }
        public string timestamp { get; set; }
    }
}

其中 LineWebhookModels Model 唯一整包 LINE 傳入的資料,我們使用List<Event>來表示陣列型態的 events,且使用enum來表示不同種的Type,以利後續比對或判斷。


Source Object
在每個 event 中一定會包含一個 Source Object,來表示該 event 是由誰發出,其中又分成 user, group, room 3種 Source 來源,將 Event Model 改成這樣。
public enum SourceType { user, group, room }

public class Event
{
    public EventType type { get; set; }
    public string timestamp { get; set; }
    public Source source { get; set; }
}

public class Source
{
    public SourceType type { get; set; }
    public string userId { get; set; }
    public string groupId { get; set; }
    public string roomId { get; set; }
}


Message Object
接下來就進入重頭戲,取得訊息內容。當 event 為 message type 時,就會多出 message 與 replyToken 兩個成員,其中 message 又分成 text, image, video, audio, location, sticker 6個種類。最後將 Event Model 改成這樣。
public enum MessageType { text, image, video, audio, location, sticker }

public class Event
{
    public EventType type { get; set; }
    public string timestamp { get; set; }
    public Source source { get; set; }
    public string replyToken { get; set; }
    public Message message { get; set; }
}

public class Message
{
    public string id { get; set; }
    public MessageType type { get; set; }
    public string text { get; set; }
    public string title { get; set; }
    public string address { get; set; }
    public decimal latitude { get; set; }
    public decimal longitude { get; set; }
    public string packageId { get; set; }
    public string stickerId { get; set; }
}

大功告成!這樣一來,無論是哪一種類型的訊息,透過LineWebhookModels都有辦法 mapping 進去。

建立 Model 並非唯一的方式,您也可以使用 dynamic 的型態來接收從 LINE 傳遞過來的資料。


將用戶的訊息印出來

有了LineWebhookModels後,就可以取出用戶傳過來的訊息。回到 LineController 將 webhook() 改成這樣:

public IHttpActionResult webhook([FromBody] LineWebhookModels data)
{
    if (data == null) return BadRequest();
    if (data.events == null) return BadRequest();

    foreach (Event e in data.events)
    {
        if (e.type == EventType.message) {
            string senderID  = "";
            switch (e.source.type) {
                case SourceType.user:
                    senderID = e.source.userId;
                    break;
                case SourceType.room:
                    senderID = e.source.roomId;
                    break;
                case SourceType.group:
                    senderID = e.source.groupId;
                    break;
            }
            Trace.WriteLine("傳遞者ID " + senderID);
            Trace.WriteLine("內容 " + e.message.text);
        }
    }
    return Ok();
}

除了文字訊息之外,其他擁有照片、影片或檔案等媒體類型的 message 若要取得原始資源,則要透過 LINE Get content API 額外取得,細節可以參考官方文件。


回覆用戶

能夠接收用戶訊息後,最重要的就是回覆用戶訊息了!為了介紹,這邊會以「回音」的功能進行說明 (用戶說什麼就回什麼)。首先,您必須從帳戶主控台中取得Channel Access Token。將這個資料暫時記在Web.config檔案中,以利後續取用。

<appSettings>
 <add key="AccessToken" value="Jk0lRN...."/>
</appSettings>

根據官方文件的 Reply message API 中所述,透過 HTTP POST 方法,並設定相對應 Header 與 Body 資訊後,就可以對用戶發送的訊息進行回覆。這裡採用 .NET 內建的 WebRequest 就可以輕鬆辦到:

WebRequest req = WebRequest.Create("https://api.line.me/v2/bot/message/reply");
req.Method = "POST";
req.ContentType = "application/json";
req.Headers["Authorization"] = "Bearer " + WebConfigurationManager.AppSettings["AccessToken"];

WebResponse response = req.GetResponse();
using (var streamReader = new StreamReader(response.GetResponseStream()))
{
    string result = streamReader.ReadToEnd();
    Trace.WriteLine(result);
}


先等等,還沒加上要回覆的資料,依照官方說明,送出 Reply data大概長這樣,其中 messages 最多5則,而且跟剛才介紹的 Message Object 是一樣的,可以是不同類型的回覆:

{
    "replyToken":"nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
    "messages":[
        {
            "type":"text",
            "text":"Hello, user"
        }
    ]
}


我們可以透過 Model 的機制來規範這個 Reply data,而且剛才規範的Message Model 也可以重複使用,但Message model 中的 message 成員 Type 必須為 string,不符合需求,因此需要用一個小技巧來解決。


分為 ReceiveMessage 與 SendMessage

透過繼承的方式,抽離 Message 相同的部分,留下 type 提供繼承時才實作類別,這樣就可以保持原先的enum型態,又可以擴充出string型態。

public enum MessageType { text, image, video, audio, location, sticker }
public abstract class Message<T>
{
    public string id { get; set; }
    public T type { get; set; }
    public string text { get; set; }
    public string title { get; set; }
    public string address { get; set; }
    public decimal latitude { get; set; }
    public decimal longitude { get; set; }
    public string packageId { get; set; }
    public string stickerId { get; set; }
}
public class ReceiveMessage : Message<MessageType> { }
public class SendMessage : Message<string> { }


Event Model 也改成這樣:
public class Event
{
    public EventType type { get; set; }
    public string timestamp { get; set; }
    public Source source { get; set; }
    public string replyToken { get; set; }
    public ReceiveMessage message { get; set; }
}


ReplyBody Model

將 Message 分開後,Reply data 就可以獨立成一個 Model:

public class ReplyBody
{
    public string replyToken { get; set; }
    public List<SendMessage> messages { get; set; }
}


將 Reply 獨立成一個類別

有了ReplyBody Model 後,透過 Newtonsoft.Json 就可以將資料轉換成 JOSN 的格式。此外,回覆訊息是一個可以被重複使用 (reuse) 的功能,將它獨立成一個類別有助於讓架構更好。
public class Reply
{
    public const string API_URL = "https://api.line.me/v2/bot/message/reply";
    private WebRequest req;

    public Reply(ReplyBody body)
    {
        //--- set header and body required infos ---
        req = WebRequest.Create(API_URL);
        req.Method = "POST";
        req.ContentType = "application/json";
        req.Headers["Authorization"] = "Bearer " + WebConfigurationManager.AppSettings["AccessToken"];
        
        // --- format to json and add to request body ---
        using (var streamWriter = new StreamWriter(req.GetRequestStream()))
        {
            string data = JsonConvert.SerializeObject(body);
            streamWriter.Write(data);
            streamWriter.Flush();
        }
    }

    /*
        --- send message to LINE ---
        return response data
    */
    public string send()
    {
        string result = null;
        try
        {
            WebResponse response = req.GetResponse();
            using (var streamReader = new StreamReader(response.GetResponseStream()))
            {
                result = streamReader.ReadToEnd();
            }
        }
        catch (WebException ex)
        {
            Trace.WriteLine(ex.ToString());
        }
        return result;
    }
}

這裡將Reply單獨抽離不是最佳的做法,您可以依照實際狀況將 WebRequest 等功能做更高階的抽象化,以利後續加入其他更多的 API。


實作「回音」功能

有了Reply類別後,就可以輕鬆的使用回覆訊息的功能了。回到 LineController 的 webhook 將 Reply 的功能加入,這邊目前僅處理 message type 的 event 進行回覆:
public IHttpActionResult webhook([FromBody] LineWebhookModels data)
{
    if (data == null) return BadRequest();
    if (data.events == null) return BadRequest();

    foreach (Event e in data.events)
    {
        if (e.type == EventType.message)
        {
            ReplyBody rb = new ReplyBody()
            {
                replyToken = e.replyToken,
                messages = procMessage(e.message)
            };
            Reply reply = new Reply(rb);
            reply.send();

        }
    }
    return Ok(data);
}


透過procMessage將「回音」的效果實現,在這邊僅處理 sticker 與 text 兩種 message type,其餘您可以自行擴充或改變處理的方式:
private List<SendMessage> procMessage(ReceiveMessage m)
{
    List<SendMessage> msgs = new List<SendMessage>();
    SendMessage sm = new SendMessage()
    {
        type = Enum.GetName(typeof(MessageType), m.type)
    };
    switch (m.type)
    {
        case MessageType.sticker:
            sm.packageId = m.packageId;
            sm.stickerId = m.stickerId;
            break;
        case MessageType.text:
            sm.text = m.text;
            break;
        default:
            sm.type = Enum.GetName(typeof(MessageType), MessageType.text);
            sm.text = "很抱歉,我只是一隻回音機器人,目前只能回覆基本貼圖與文字訊息喔!";
            break;
    }
    msgs.Add(sm);
    return msgs;
}

雖然 API 看似可以回覆與用戶相同的貼圖,但實際上 API 僅能回覆內建的貼圖,您可以參考這個官方釋出的 Sticker List 來確認那些是內建貼圖。 

現在大功告成,試驗一下是否能跟回音機器人講話了:



Security Issue

隨然 LINE 已經強迫要求使用 https 進行資料傳輸,但不代表 webhook server 不會被 CSRF 攻擊,因此 LINE 在傳遞給 webhook 資料時會在 Header 夾帶一個X-Line-Signature的簽名,根據官方文件說明,這個 Signature 是由 Channel Secret 作為私鑰,與 Request Body 進行 HMAC-SHA256 進行加密計算。由於 Channel Secret 只有開發人員知道,而且 Request Body 也在 SSL 加密保護下,因此只有開發人員能夠驗證 X-Line-Signature 這組簽名是否有效。



取得 Channel Secret

為了進行加密計算,從應用程式主控台中將 Channel Secret 取出並暫時寫到Web.config 中,以利後續使用:

<appSettings>
 <add key="AccessToken" value="c..."/>
 <add key="ChannelSecret" value="3d..."/>
</appSettings>


Verify the Signature

透過官方所示之演算法,進行 Signature 驗證實作。這邊借用 .NET Web API 的 Authentication and Authorization 機制來實作。保持原來的 Web API Lifecycle,複寫 AuthorizeAttribute 定義客製化驗證是最佳的方式:

public class Signature : AuthorizeAttribute
{
    public override void OnAuthorization(HttpActionContext actionContext)
    {
        HttpRequestMessage Request = actionContext.Request;

        IEnumerable<string> headerValues;
        if (Request.Headers.TryGetValues("X-Line-Signature", out headerValues))
        {
            string lineSignature = headerValues.FirstOrDefault(),
                   reqBody = Request.Content.ReadAsStringAsync().Result;

            byte[] screct = Encoding.UTF8.GetBytes(WebConfigurationManager.AppSettings["ChannelSecret"]),
                   body = Encoding.UTF8.GetBytes(reqBody),
                   hash = new HMACSHA256(screct).ComputeHash(body);       
            string mySignature = Convert.ToBase64String(hash);
            if (mySignature == lineSignature) return;
        }
        HttpResponseMessage Response = Request.CreateResponse(HttpStatusCode.ExpectationFailed);
        Response.StatusCode = HttpStatusCode.InternalServerError;
        actionContext.Response = Response;
    }
}

其中,複寫 OnAuthorization,這個方法會在進入 Controller 之前被觸發,藉由參數HttpActionContext可以從原始的 Request data 取出必要資料進行驗證。

由 Request.Headers 中取出X-Line-Signature(由LINE所發的簽名),再由 Request.Content 取出 Request body 資料,透過 .NET 內建的 HMACSHA256 物件進行加密運算。

如果加密運算後與LINE所發的簽名一致則表示該訊息為 LINE 官方所發出,否則回應 InternalServerError (500 Error)。


加上驗證屬性 AuthorizeAttribute

有了客製化的 Signature AuthorizeAttribute 後,就可以在 LineController 的 Action 中附加,表示該 Controller 必須先通過驗證,通過後才會進入該 Controller:
[HttpPost]
[Route]
[Signature]
public IHttpActionResult webhook([FromBody] LineWebhookModels data)
{
    ....
    return Ok(data);
}

做成 AuthorizeAttribute 的好處是,未來若擴充更多 LINE 相關的 API 時,直接在 Controller 中加入該 Attribute 即可。


結論

以上就是使用 .NET Web API 建立 LINE webhook 程式的介紹,希望對需要使用 C# 語言開發的朋友會有幫助。畢竟無論是官方或是網路上對於 C# 怎麼建立 LINE webhook 的詳細說明實在太少,因此在此做小小的貢獻。


GitHub
這次整個介紹的詳細的程式碼可以在我的 GitHub 上找到,有任何問題也歡迎回饋給我。謝謝。


Refreence


9 則留言:

  1. 感謝分享這麼好的範例解說

    回覆刪除
  2. 您好,謝謝您的分享
    我用你的例子發生(403) Forbidden
    請問這是沒有權限使用line api嗎?
    我的權限是REPLY_MESSAGE
    PUSH_MESSAGE
    是否可以給予指點,謝謝

    回覆刪除
  3. 想問一下我在單機localhost 就收不到HTTP Post的消息了是..........?

    回覆刪除
  4. 非常感謝你的分享.

    回覆刪除
  5. 非常感謝您的分享,您的步驟講解非常的仔細。
    雖然因為我個人對MVC的架構不熟悉,中間遇到一點問題。
    但是解決了!現在可以成功的運行。

    回覆刪除
  6. 請問,要怎麼知道 line request 傳甚麼樣的 json 資料進來?line 文件沒有查到 @@

    回覆刪除
  7. 回覆的replyToken 要怎麼產生

    回覆刪除