Thursday, October 25, 2018

Bài 38: Xây dựng chatbot server với Go

Trong bài trước chúng ta đã tìm hiểu về cách tạo Facebook page và Facebook app. Bài này chúng ta sẽ cùng xây dựng chatbot server viết bằng Go.

Khung của một server chatbot vô cùng đơn giản. Đó là 1 server phục vụ 2 request GET và POST từ facebook, cụ thể main.go được viết như sau:
package main

import (
    "net/http"
    "log"
    "github.com/gorilla/mux"
)


func main() {
    r := mux.NewRouter()
    r.HandleFunc("/", chatbotHandler)
    if err := http.ListenAndServe(":8080", r); err != nil {
        log.Fatal(err.Error())
    }
}

func chatbotHandler(w http.ResponseWriter, r *http.Request) {
    switch r.Method {
    case "GET":
        verifyWebhook(w, r)
    case "POST":
        processWebhook(w, r)
    default:
        w.WriteHeader(http.StatusBadRequest)
        log.Errorf("Không hỗ trợ phương thức HTTP %v", r.Method)
    }
}

func verifyWebhook(w http.ResponseWriter, r *http.Request) {
}

func processWebhook(w http.ResponseWriter, r *http.Request) {
}

Xác minh webhook


Ở cuối bài trước chúng ta thấy là việc xác minh thất bại do chúng ta chưa làm server. Bây giờ chúng ta sẽ thực hiện phần xác minh này.
Quy trình xác minh webhook của Facebook app như sau: 1. Nền tảng Messenger của Facebook sẽ gửi yêu cầu GET tới webhook mà chúng ta khai báo với 3 tham số: - hub.mode: luôn là "subscribe" - hub.verify_token: chứa mã xác minh mà chúng ta đã khai báo khi thiết lập webhook. Chuỗi này tôi đã chọn là "GoBot". Bạn có thể chọn tùy thích. - hub.challenge: chuỗi số mà chúng ta phải gửi lại khi xác nhận kết quả xác minh. 2. Chúng ta xác minh rằng mã Facebook gửi đến ở hub.verify_token khớp với mã mình chọn và phản hồi bằng tham số hub.challenge. 3. Nền tảng Messenger đăng ký webhook của chúng ta với ứng dụng đã tạo.
Hàm verifyWebhook xử lý việc xác minh này cụ thể như sau:
func verifyWebhook(w http.ResponseWriter, r *http.Request) {
    mode := r.URL.Query().Get("hub.mode")
    challenge := r.URL.Query().Get("hub.challenge")
    token := r.URL.Query().Get("hub.verify_token")
 
    if mode == "subscribe" && token == "GoBot" {
        w.WriteHeader(200)
        w.Write([]byte(challenge))
    } else {
        w.WriteHeader(404)
        w.Write([]byte("Error, wrong validation token"))
    }
}
Giờ biên dịch server và thử xác minh nhé! Lúc này nảy sinh 2 vấn đề:
- File thực thi chúng ta vừa biên dịch xong làm sao chạy để Facebook hiểu mà xác minh vì chúng ta đang ở máy tính trong mạng local bên ngoài không thấy được.
- Facebook đòi địa chỉ URL webhook phải là https.

Rất may chúng ta đã có một công cụ tuyệt vời giải quyết vấn đề trên đó là ngrok. Các bạn tải ngrok tại đây và làm theo hướng dẫn để cài đặt nhé. Sau khi cài đặt xong chúng ta đơn giản chạy ngrok http 8080 trong terminal hay Command Prompt nó sẽ tự tạo link http và https liên kết tới máy chúng ta:
Chúng ta copy địa chỉ https tương ứng rồi dán vào hộp thoại xác minh webhook ở Facebook app rồi chọn "Xác minh và lưu". Nhưng tôi vẫn bị lỗi như màn hình bên dưới:
Tôi chợt nhớ ra là mình chưa chạy server. Sau khi chạy server thì việc xác minh hoàn tất. Lúc này ở phần Messenger trong Facebook App, mục Webhook báo chúng ta đã xác mình hoàn tất và chúng ta đã chọn 2 sự kiện messages, messaging_postbacks để theo dõi. Ngay dưới đó là phần chọn trang để nhận sự kiện từ Facebook. Bạn quên phần này thì bot của bạn không bao giờ nhận được tin từ Facebook.
Chúng ta chọn "Chọn trang" rồi chọn trang bạn vừa tạo ở bài trước trong danh sách rồi chọn "Đăng ký" là hoàn tất phần này. Khi không muốn đăng ký nữa, chúng ta chọn nút "Hủy đăng ký". Chúng ta có thể đăng ký nhận sự kiện từ nhiều trang cùng lúc.
ngrok còn có 1 chức năng hay nữa là cung cấp cho chúng ta thông tin Facebook đã gửi cho server chúng ta. Bạn vào link ứng với mục "Web interface" của ngrok mà chúng ta đã chạy sẽ thấy thông tin xác minh mà facebook đã gửi:

Giờ bot chạy được chưa? Chưa! Chúng ta còn thiếu phần xử lý khi nhận tin từ Facebook, tức phải hoàn tất hàm processWebhook nữa.


Xử lý sự kiện Facebook


Cái chúng ta cần quan tâm lúc này là facebook sẽ gửi cho chúng ta cái gì. Trên trang tài liệu Facebook cho chúng ta biết đó là 1 định dạng JSON có cấu trúc như sau:
{
    "object":"page",
    "entry":[
        {
            "id":"<PAGE_ID>",
            "time":1458692752478,
            "messaging":[
                {
                    "sender":{
                        "id":"<PSID>"
                    },
                    "recipient":{
                        "id":"<PAGE_ID>"
                    },

                    ...
                }
            ]
        }
    ]
}
Chúng ta thấy là entry là slice, mỗi phần tử là thông tin ứng với một Facebook page. Nó tương ứng việc app có thể đăng ký lắng nghe sự kiện nhiều trang tôi nói ở trên. ID của page mà tôi nói bạn cần ở bài trước là dùng ở chỗ này để giúp chúng ta xử lý đúng trang lắng nghe. Tất nhiên nếu chỉ đăng ký 1 trang thì không cần bận tâm đến nó. ID page có thể lấy bằng cách vào phần "Giới thiệu" của page rồi kéo xuống dưới cùng sẽ thấy:
Tôi biết nhiều người tận dụng việc ngrok hiển thị nội dung gửi và nhận nên dùng nó để xem Facebook gửi gì cho mình trước rồi mới tạo struct tương ứng với json đó để xử lý:
Như hình trên các bạn có thể thấy là tôi đã gửi chuỗi "alo" đến trang Facebook Page GoBot và server nhận được nội dung JSON như trên. Cách này có cái hay là khi thấy mình xử lý có gì đó sai sai thì kiểm tra lại những gì ngrok đã nhận để điều chỉnh lại struct cho hợp lý vì nhiều tình huống sẽ trả về khác nhau mà đôi lúc chúng ta coi tài liệu không kĩ nên sót.
Tuy nhiên nếu chưa xử lý gì mà chờ xem rồi mới xử lý thì cần lưu ý những điểm sau:
- Dù không xử lý gì thì ít nhất luôn phản hồi 200 OK về cho Facebook nếu không muốn bị xếp vào diện lỗi.
- Nếu quá 20 giây từ khi gửi mà không thấy bot server phản hồi, Facebook sẽ gửi lặp lại tin đó nhiều lần nữa.
- Sau 15 phút mà server không phản hồi thì Facebook xem như webhook lỗi và cảnh báo.
- Sau 8 giờ cảnh báo thì webhook bạn đăng ký sẽ bị vô hiệu hóa và nếu muốn bạn phải xác minh lại.

Một việc mà nhiều bạn mới làm quen lúng túng là không tìm ra chỗ để tiến hành xác minh lại webhook. Nó như thế này: mặc dù đăng ký webhook ở mục Messenger nhưng một khi đã xác minh xong thì sau này muốn cập nhật lại link webhook bạn phải vào mục Webhook bên dưới mục Messenger và chọn phần "Edit Subscription":

Quay trở lại phần xử lý sự kiện, chúng ta phải đọc nội dung JSON nhận được và phản hồi phù hợp. Trong phần xử lý bên dưới, tôi xử lý echo, nghĩa là người dùng gửi gì tôi trả lại đúng câu đó. Để khác biệt chút tôi sẽ gửi lại chuỗi in hoa.

Đầu tiên chúng ta phải khai báo một số struct để tiếp nhận xử lý JSON mà Facebook gửi. Phân tích JSON bên trên thì tôi quyết định tạo thêm 1 file message.go để lưu các struct này. Cụ thể như sau:
package main

type (
    Request struct {
        Object string `json:"object,omitempty"`
        Entry  []struct {
            ID        string      `json:"id,omitempty"`
            Time      int64       `json:"time,omitempty"`
            Messaging []Messaging `json:"messaging,omitempty"`
        } `json:"entry,omitempty"`

    Messaging struct {
        Sender    *User    `json:"sender,omitempty"`
        Recipient *User    `json:"recipient,omitempty"`
        Timestamp int      `json:"timestamp,omitempty"`
        Message   *Message `json:"message,omitempty"`
    }

    User struct {
        ID string `json:"id,omitempty"`
    }

    Message struct {
        MID  string `json:"mid,omitempty"`
        Text string `json:"text,omitempty"`
    }
)
Khai báo omitempty nghĩa là nếu giá trị đó rỗng (0 với kiểu số, "" với chuỗi, [] với slice và nil với kiểu con trỏ) thì khi đóng JSON gửi đi, nó sẽ bị loại bỏ khỏi JSON.
Hàm processWebhook ở main.go được xử lý như sau:
func processWebhook(w http.ResponseWriter, r *http.Request) {
    defer r.Body.Close()
    var req Request
    err := json.NewDecoder(r.Body).Decode(&req)
    if err != nil {
        w.WriteHeader(404)
        w.Write([]byte("Message not supported"))
        return
    }

    if req.Object == "page" {
        for _, entry := range req.Entry {
            for _, event := range entry.Messaging {
                if event.Message != nil {
                    sendText(event.Sender, strings.ToUpper(event.Message.Text))
                }
            }
        }
        w.WriteHeader(200)
        w.Write([]byte("Got your message"))
    } else {
        w.WriteHeader(404)
        w.Write([]byte("Message not supported"))
    }
}
Các bạn có thể thấy hàm này đơn giản đọc nội dung được gửi tới, chuyển từ JSON sang biến req kiểu struct Request tương ứng rồi duyệt từng giá trị event của thuộc tính Messaging trong từng giá trị entry của thuộc tính Entry. Với mỗi event, chúng ta thấy thông tin tin nhắn chứa trong thuộc tính Message nhưng đây là đối tượng kiểu con trỏ nên để đảm bảo chúng ta cần kiểm tra khác nil trước khi sử dụng. Hàm sendText đóng vai trò gửi tin nhắn phản hồi.


Gửi tin phản hồi


Facebook có quy định cấu trúc JSON phản hồi và URL nhận tin nhắn tại đây. Cấu trúc này như sau:
"messaging_type": "<MESSAGING_TYPE>",
"recipient":{
  "id":"<PSID>"
},
"message":{
  "text":"hello, world!"
}
Do đó tôi bổ sung 2 struct tương ứng vào file message.go như sau:
type (
    Request struct {...}

    Messaging struct {...}

    User struct {...}

    Message struct {...}

    ResponseMessage struct {
        MessageType string      `json:"messaging_type"`
        Recipient   *User       `json:"recipient"`
        Message     *ResMessage `json:"message,omitempty"`
    }

    ResMessage struct {
        Text string `json:"text,omitempty"`
    }
)
Giờ chúng ta viết hàm sendText nữa là xong:
const (
    FBMessageURL  = "https://graph.facebook.com/v3.1/me/messages"
    PageToken       = "<Mã nhận được ở bước tạo mã khi tạo Facebook App ở bài trước>"
    MessageResponse = "RESPONSE"
)

func sendText(recipient *User, message string) error {
    m := ResponseMessage{
        MessageType: MessageResponse,
        Recipient:   recipient,
        Message: &ResMessage{
            Text: message,
        },
    }

    body := new(bytes.Buffer)
    err := json.NewEncoder(body).Encode(&m)
    if err != nil {
        log.Error("Json: " + err.Error())
        return err
    }

    req, err := http.NewRequest("POST", FBMessageURL, body)
    if err != nil {
        log.Error("http:" + err.Error())
        return err
    }

    req.Header.Add("Content-Type", "application/json")
    req.URL.RawQuery = "access_token=" + PageToken
    client := &http.Client{Timeout: time.Second * 30}

    resp, err := client.Do(req)
    if err != nil {
        log.Error("request: " + err.Error())
        return err
    }
    defer resp.Body.Close()

    return nil
}
Một số lưu ý:
- Loại tin nhắn phản hồi có nhiều kiểu khác nhau, ở đây đơn giản tôi chọn là "RESPONSE", các loại khác các bạn tham khảo thêm ở đây.
- FBMessageURL là địa chỉ gửi phản hồi, trong tài liệu để là v2.6 nhưng phiên bản mới nhất hiện tại của nó là v3.1 nên tôi dùng nó.
- PageToken là mã bạn được Facebook cấp cho khi chọn phần tạo mã khi tạp app ở bài trước. Mã này sẽ giúp server có quyền giống quyền của bạn khi tương tác trên page bạn chọn. Do đó server có thể gửi tin nhắn phản hồi cho người dùng được.
Lúc này chắc các bạn nóng lòng muốn chạy thử lắm. Nào chúng ta cùng xem thử thành quả nhé:
- Biên dịch và chạy GoBot server.
- Chạy ngrok nếu chưa chạy hoặc hết hạn. Các bạn lưu ý là ngrok có 8 giờ chạy liên tục nếu bạn không đăng ký tài khoản. Sau thời gian đó bạn phải chạy lại nó.
- Cập nhật lại link ngrok cấp trong phần "Edit Subcription" ở mục Webhook trên Facebook App.
- Mở trình duyệt vào link https://m.me/<ID trang của bạn> hoặc vào trực tiếp Messenger rồi tìm kiếm tên trang của bạn. Nhớ đặt tên độc độc chút để kiếm cho nhanh. Một cách khác là vào trang trên Facebook, ở nút "Gửi tin nhắn", chúng ta chọn vào và chọn "Thử nghiệm nút" để mở cửa sổ chat.
- Gõ 1 chuỗi gì đó và chờ xem bot server của bạn phản hồi nhé.
Nếu bạn không thấy phản hồi gì cả thì xem lại các phần theo thứ tự sau nhé:
- Màn hình ngrok: xem coi nó báo là nhận POST chưa và trạng thái có phải 200 OK không. Nếu nó màu đỏ là lỗi và bạn xem lại bot server của bạn có thể chạy lỗi hoặc chưa chạy. Nếu không thấy ngrok báo đã nhận POST thì bạn xem lại coi trên Facebook App bạn đã chọn đăng ký nhận sự kiện từ trang chưa nhé. Bởi lẽ nếu đã xác minh thành công thì ngrok luôn nhận trừ khi bạn quên đăng ký nhận sự kiện hoặc là webhook bị vô hiệu hóa như tôi đã đề cập ở trên. Trường hợp bị vô hiệu hóa cần tắt ngrok chạy lại rồi đăng ký xác minh webhook lại.
- Trang web mô tả gửi nhận của ngrok: xem coi Facebook gửi tin cho server như thế nào.
- Server hoạt động có lỗi không? Có khi bị crash trước khi gửi phản hồi. Nếu vẫn chạy bạn nên debug để xác định xem đã nhận được tin nhắn chưa và xử lý như thế nào.


Phản hồi hành động chat


Khi chat trên Messenger chúng ta thấy Facebook có cập nhật các hành động như đối phương đã xem hay chưa hay họ đang gõ trả lời. Những hành động đó Facebook cũng cung cấp cho chúng ta thông qua khai báo thêm send_action trong cấu trúc ResponseMessage. Thuộc tính này dạng chuỗi có 3 giá trị:
- "typing_on": hiển thị trạng thái báo bot đang gõ phản hồi.
- "typing_off": ẩn trạng thái báo gõ phản hồi.
- "mark_seen": xác nhận bot đã xem tin, trên cửa số chat sẽ hiển thị avatar của trang ngay dưới câu của đối phương để báo việc đã xem.
Tôi sửa cấu trúc ResponseMessage lại để bổ sung chức năng này:
ResponseMessage struct {
    MessageType string      `json:"messaging_type"`
    Recipient   *User       `json:"recipient"`
    Message     *ResMessage `json:"message,omitempty"`
    Action      string      `json:"sender_action,omitempty"`
}
Giờ chúng ta viết hàm sendAction đảm nhận việc gửi trạng thái hành động. Nếu gộp chung với hàm sendText ở trên thì khá rối rắm mà viết hàm mới lại thêm công đoạn đóng gói gửi Facebook thì thừa quá. Chắc lúc này nhiều bạn cũng nghĩ như tôi là nên tách hàm sendFBRequest đảm nhận việc đóng gói và gửi Facebook, còn gửi cái gì để các hàm chức năng tương ứng lo. Thế là tôi sửa lại hàm sendText, viết mới 2 hàm sendFBRequest và sendAction như sau:
func sendFBRequest(url string, m interface{}) error {
body := new(bytes.Buffer)
err := json.NewEncoder(body).Encode(&m)
if err != nil {
log.Error("sendFBRequest:json.NewEncoder: " + err.Error())
return err
}

req, err := http.NewRequest("POST", url, body)
if err != nil {
log.Error("sendFBRequest:http.NewRequest:" + err.Error())
return err
}

req.Header.Add("Content-Type", "application/json")
req.URL.RawQuery = "access_token=" + PageToken
client := &http.Client{Timeout: time.Second * 30}

resp, err := client.Do(req)
if err != nil {
log.Error("sendFBRequest:client.Do: " + err.Error())
return err
}
defer resp.Body.Close()

return nil
}

func sendText(recipient *User, message string) error {
    m := ResponseMessage{
        MessageType: MessageResponse,
        Recipient:   recipient,
        Message: &ResMessage{
            Text: message,
        },
    }
    return sendFBRequest(FBMessageURL, &m)
}

func sendAction(recipient *User, action string) error {
    m := ResponseMessage{
        MessageType: MessageResponse,
        Recipient:   recipient,
        Action:      action,
    }
    return sendFBRequest(FBMessageURL, &m)
}
Bên chỗ hàm processWebhook bổ sung thêm 3 hàm sendAction luôn như sau:
for _, entry := range req.Entry {
    for _, event := range entry.Messaging {
        if event.Message != nil {
            sendAction(event.Sender, MarkSeen)
            sendAction(event.Sender, TypingOn)
            sendText(event.Sender, strings.ToUpper(event.Message.Text))
            sendAction(event.Sender, TypingOff)
        }
    }
}
MarkSeen, TypingOn và TypingOff là 3 hằng tôi khai báo 3 chuỗi hành động ở trên. Các bạn biên dịch và chạy lại sẽ thấy báo đã xem, có báo tying, rồi tắt ngay sau khi phản hồi.

Như vậy chúng ta đã hoàn thành viết một Messenger chatbot đơn giản bằng Go. Trong bài tới tôi sẽ cập nhật bot để nó xử lý việc cung cấp tỉ giá các ngoại tệ so với đồng Việt Nam
Tóm tắt:
 - Server chatbot phải xử lý 2 request GET và POST từ Facebook để xác minh server và xử lý tin nhắn từ người dùng trên page.
- Để Facebook có thể truy cập đến server trên máy chúng ta thì ngrok là một lựa chọn phù hợp. Ngoài chuyện tạo URL hỗ trợ https, ngrok còn cung cấp thông tin gửi nhận để chúng ta tiện theo dõi khi lập trình.
- Những gì Facebook gửi chúng ta phải xử lý và phản hồi trong vòng 20 giây nếu không Facebook sẽ gửi lại và nếu kéo dài dẫn đến webhook bị vô hiệu hóa.
- Hiện tại bot đã có thể phản hồi tin văn bản và các hành động.

2 comments:

  1. Bài viết rất hay. Cho mình hỏi khi khai báo các struct để xử lý JSON, thì tại sao dùng struct pointer mà không khai báo struct thông thường, ví dụ :
    recipient: *User mà không phải là recipient: User

    ReplyDelete
    Replies
    1. Struct thường gồm nhiều trường nên khi truyền tham số là struct nhất là struct phức tạp thì sẽ tốn nhiều bộ nhớ lưu trữ trên stack và cũng tốn thời gian để xử lý lưu này. Dùng con trỏ luôn nhanh hơn nhờ vùng nhớ chiếm cũng nhỏ và cố định.

      Delete