Saturday, October 27, 2018

Bài 40: Triển khai chatbot trên Lambda

Trong các bài trước chúng ta đã làm quen với Facebook Messenger chatbot viết bằng Go. Trong bài này tôi giới thiệu việc triển khai chatbot server trên môi trường AWS Lambda.

Tại sao lại Lambda


Để đưa chatbot vào hoạt động chúng ta không thể để nó chạy local mãi được. Chúng ta có nhiều cách để triển khai server cho chatbot như mua hoặc thuê host, sử dụng các dịch vụ PAAS như heroku hay sử dụng dịch vụ FAAS như AWS Lambda hay Google Cloud Functions. Ở đây tôi chọn Lambda vì những ưu thế sau:
- Cung cấp sẵn HTTPS: Dịch vụ API Gateway của AWS gắn với Lambda luôn cung cấp URL HTTPS.
- Khả năng mở rộng: Do chúng ta làm Messenger chatbot nên khả năng cùng lúc phải xử lý tin nhắn từ nhiều người dùng là bình thường. Lambda hỗ trợ tốt việc này khi nó dễ dàng chạy cả ngàn server để phục vụ các yêu cầu cùng lúc.
- Gói miễn phí: Hiện tại AWS miễn phí 1 triệu yêu cầu mỗi tháng cho API Gateway và Lambda. Lượng miễn phí này đủ cho chúng ta dùng cho đến khi có lượng người dùng đủ lớn. Do đó Lambda khá phù hợp cho sinh viên hoặc các startup cần thử nghiệm nhanh mà chi phí hạn hẹp.

Trước khi tìm hiểu cách đưa chatbot server lên Lambda, chúng ta hãy cùng tìm hiểu các dịch vụ của Amazon Web Services dùng trong dự án.

Amazon Web Services


Chắc hẳn chúng ta ai cũng từng nghe đến Amazon, trang thương mại điện tử lớn nhất thế giới. Khởi nguồn của nó là một trang bán sách trực tuyến, sau này mới mở rộng kinh doanh sang cách mặt hàng khác. Trong suốt 24 năm hoạt động, đội ngũ IT của Amazon đã tạo ra rất nhiều dịch vụ và công cụ để phục vụ cho trang web amazon.com và các nhu cầu khác của công ty. Dần dần những dịch vụ này được mở rộng cho bên ngoài thuê sử dụng và hình thành nên Amazon Web Services (AWS) dịch vụ cloud hàng đầu thế giới. 3/4 lợi nhuận của Amazon đến từ AWS. Hiện tại AWS cung cấp 20 nhóm dịch vụ gồm hàng trăm dịch vụ khác nhau. Có thể nói bạn nghĩ mình cần gì đó là AWS có sẵn dịch vụ hoặc có thể kết hợp các dịch vụ lại để phục vụ bạn.

Lambda


Lambda là dịch vụ của AWS theo mô hình serverless hướng dịch vụ hàm (Function As A Service - FAAS). Lambda chứa các hàm thực thi và khi được gọi, một server nhỏ được khởi chạy để thực thi hàm này. Sau khi chạy xong, server sẽ tắt đi.

Lambda hoạt động theo nguyên tắt hướng sự kiện, nghĩa là cách dịch vụ khác trong AWS sau khi được Lambda đăng ký nhận sự kiện thì khi có sự kiện tương ứng của dịch vụ đó xảy ra, Lambda sẽ nhận sự kiện và server chứa hàm chức năng sẽ được gọi. Nhiều server chứa hàm chức năng được gọi cùng lúc nếu nhiều sự kiện tương ứng xảy ra đồng thời. Hiện tại có 20 dịch vụ là nguồn tạo sự kiện gọi Lambda.
Lambda hỗ trợ xây dựng hàm chức năng từ nhiều ngôn ngữ khác nhau như Go, NodeJS, Python, Java, v.v..

Ưu điểm của Lambda:
- Giúp chúng ta không phải bận tâm quản lý server, chỉ cần lo hàm chức năng là đủ.
- Server chỉ được chạy khi có sự kiện tương ứng nên chúng ta chỉ phải trả tiền cho những gì sử dụng. Hiện tại Lambda tính phí theo công thức: thời gian chạy x bộ nhớ sử dụng.
- Hiện tại Lambda được miễn phí 1 triệu lần gọi mỗi tháng.

Nhược điểm của Lambda:
- Thời gian xử lý tối đa là 15 phút. Hết thời gian này server sẽ tự tắt. Trước đây thời gian tối đa là 5 phút.
- Một lần chạy là mỗi lần khởi động server nên Lambda hoạt động theo mô hình stateless, không có lưu trạng thái.
- Debug lambda không đơn giản.

API Gateway


Như ở trên có đề cập, Lambda chỉ nhận sự kiện từ các dịch vụ của AWS nên để nhận từ bên ngoài Lambda cần một dịch vụ khác gọi hay nói cách khác là nhận sự kiện của một dịch vụ mà bên ngoài gọi được đến dịch vụ đó. Với các cuộc gọi HTTP thì dịch vụ tương ứng là API Gateway.

API Gateway cung cấp 1 URL và cho phép bên ngoại gọi đến. Lúc này API Gateway sẽ chuyển dữ liệu nó nhận được đến Lambda tương ứng rồi chờ nhận dữ liệu phản hồi từ Lambda và trả về cho nơi đã gọi nó. Thời gian hoạt động tối đa hiện nay của API Gateway là 29 giây nghĩa là sau 29 giây, mọi phản hồi từ Lambda là vô giá trị, API Gateway sẽ không trả về cho bên gọi nữa.

GoBot trên Lambda với apex up


Đăng ký tài khoản AWS

Để có thể sử dụng Lambda, việc đầu tiên chúng ta phải làm là đăng ký tài khoản AWS. Lưu ý là AWS đòi chúng ta cung cấp thông tin thẻ tín dụng mới cho phép sử dụng vì lý do các dịch vụ trên AWS có thu phí. Tuy nhiên AWS luôn dành một khoản miễn phí cho chúng ta nếu biết tận dụng. Bước đăng ký tôi sẽ không nêu ở đây nhưng bạn nào gặp khó khăn cứ nêu ở comment, tôi sẽ giúp.

Bước tiếp theo chúng ta phải làm là tạo người dùng tương tác với AWS và tạo vai trò (role) để khi thực thi trên Lambda dùng khi cần tương tác các dịch vụ khác.

Tạo người dùng

Khi tạo tài khoản AWS, bạn đã có tài khoản cao cấp nhất có quyền trên mọi dịch vụ mà AWS cung cấp cho bạn. Bạn không nên sử dụng nó để lập trình vì nếu lộ kẻ gian có thể dùng nó để tạo hàng loạt server cũng như sử dụng vô tội vạ các dịch vụ mà bạn là người phải trả tiền.

Để tạo một tài khoản khác phục vụ cho việc lập trình bạn thực hiện theo các bước như sau:
(1) Vào trang tạo người dùng tại đây, chọn "Add user" :
(2) Chọn tên, ở đây tôi chọn 'gobot'. Sau đó chọn kiểu truy cập là 'Programmatic access' nghĩa là chỉ dùng để tương tác qua API hoặc CLI, không vào trang web quản lý (console) được. Sau đó chọn "Next: Permission" để thêm quyền cho user này.
(3) Tiếp theo chọn quyền, ở đây để đơn giản tôi chọn "Administration Access" nghĩa là có thể tương tác mọi dịch vụ. Sau đó chọn "Next:Review"
(4) Ở màn hình review nếu thấy mọi thông tin chính xác, hãy chọn "Create user" để hoàn tất việc tạo người dùng mới.
(5) Như vậy việc tạo người dùng hoàn tất, chúng ta cần tải file .csv về hoặc copy 2 thông tin access key và secret key để dùng sau na.
(6) Tạo thư mục ~/.aws để lưu trữ các thông tin cần cho việc tương tác với các dịch vụ AWS sau này. Tại thư mục .aws, tạo 2 file như sau:
- File "credentials" lưu các cặp access key và secret key đại diện các người dùng để tương tác sử dụng các dịch vụ AWS. Cấu trúc file này có dạng như sau: 
[gobot]
aws_access_key_id = xxxxxxxx
aws_secret_access_key = xxxxxxxxxxxxxxxxxxxxxxxx
Lúc này mở Terminal lên gõ "export AWS_PROFILE=gobot" là các dịch vụ kết nối AWS có thể dùng thông tin này để chứng thực người dùng. Nếu bạn sử dụng Windows, hãy xem hướng dẫn tại đây.
- File "config" mô tả thông tin khu vực (region) mà chúng ta định thao tác và định dạng xuất:

[profile gobot]
region=us-east-1
output=json
Ở đây tôi chọn region hoạt động là Bắc Virginia ở Mỹ (US East (N. Virginia)) và định dạng xuất là json. Các bạn lưu ý là mỗi region sẽ lưu trữ khác nhau nên nếu bạn tạo ở region này thì đừng đi kiếm nó ở region khác. Thông tin region hiện tại ở console là góc trên bên trái:
Do chatbot server nói chuyện trực tiếp với Facebook nên tôi đặt ở Mỹ tiện hơn là ở gần Việt Nam. Ở Mỹ thì region này là rẻ nhất. Gần Việt Nam thì có Singapore với mã region là ap-southeast-1.

Tạo vai trò

Khi dịch vụ đưa lên Lambda và chạy thì vai trò mà chúng ta tạo ở đây sẽ quyết định dịch vụ chúng ta có thể tương tác được với các dịch vụ nào của AWS. Để tạo vai trò chúng ta thực hiện theo các bước sau:
(1) Tại chức năng IAM ở trên mà chúng ta đã tạo người dùng, chọn Role từ danh sách chức năng bên trái.
(2) Chọn "Create role" để tạo vai trò mới:
(3) Chọn đối tượng sử dụng vai trò, ở đây chúng ta chọn Lambda. Chọn "Next: Permissions" để thêm các quyền cho vai trò này.

(4) Thêm policies là các quyền mà AWS đã quy định sẵn, ở đây hiện tại tôi chọn AWSLambdaBasicExecutionRole. Chọn "Next: Review" để xem lại các thông tin đã chọn.
(5) Ở đây chúng ta cần đặt tên cho vai trò mới, tôi chọn là GoBot rồi chọn "Create role"
(6) Như bên dưới có thể thấy là role đã được tạo xong



Apex Up

Apex up là công cụ giúp chúng ta đưa dịch vụ lên Lambda mà không cần phải vào AWS console để tạo từng bước phức tạp hay chạy lệnh AWS CLI rối rắm. Với Apex Up, mọi việc chỉ đơn giản là tạo file up.json rồi gõ up từ dòng lệnh là xong.

Đầu tiên chúng ta cần cài đặt Apex Up. Các bạn xem hướng dẫn tại đây. Tiếp theo chúng ta tạo file up.json trong thư mục dự án như bên dưới:
{
    "name": "gobot",
    "profile": "gobot",
    "regions": ["us-east-1"],
    "lambda": {
        "role": "<role ARN>",
        "memory": 128
    }
}
Role ARN ở trên có thể lấy như sau: Chọn vai trò đã tạo ở trên, ở đây là GoBot, màn hình sau xuất hiện. Chọn nút copy ở vùng đánh dấu đỏ để copy chuỗi ARN rồi dán vào file up.json ở trên.
Lambda khi chạy không cố định port (có lẽ nhiều server ảo chạy cùng lúc nên nếu chạy port cố định sẽ không chạy được) nên thay vì để "8080" như trước đây chúng ta phải gọi hàm lấy giá trị biến môi trường "PORT" để lấy port cần chạy do khi chạy Lambda truyền port sẽ chạy ở đây. Hàm main trong main.go được sửa lại như sau:
func main() {
    r := mux.NewRouter()
    r.HandleFunc("/", chatbotHandler)
    if err := http.ListenAndServe(":"+os.Getenv("PORT"), r); err != nil {
        log.Fatal(err.Error())
    }
}
Mọi thứ lúc này đã sẵn sàng để đưa lên Lambda rồi. Việc còn lại là đến thư mục dự án ở Terminal hoặc Command Prompt, thực hiện các thao tác sau:
- Lệnh export để thiết lập profile mình chọn là gobot để khi gọi up, nó sẽ lấy được access key và secret key của user gobot để tương tác.
-  Lệnh up sẽ tiến hành biên dịch, đóng gói rồi đưa dịch vụ của chúng ta lên Lambda và cấu hình API Gateway tương ứng. Nếu xem ở dịch vụ S3, bạn sẽ thấy có 1 bucket mới được tạo cho tên bắt đầu là up
- Lệnh up url sẽ cung cấp cho chúng ta URL đến API Gateway tương ứng. Chép URL này, mang lên dán vào webhook trong Facebook app, thay thế cho link ngrok trước đây rồi test lại sẽ thấy chatbot server hoạt động tốt.

Như vậy chúng ta đã hoàn tất việc xây dựng Facebook Messenger chatbot từ việc tạo page cho đến triển khai dịch vụ trên Lambda. 
Tóm tắt:
 - Lambda là cách triển khai chatbot server hiệu quả và rẻ nhất.
- AWS có rất nhiều dịch vụ khác nhau.
- Cần tạo thêm user để sử dụng cho việc tương tác với các dịch vụ AWS khi lập trình. Tuyệt đối không cung cấp cặp key cho ai và đưa vào git repo.
- Apex up là công cụ đưa server lên Lambda nhanh chóng và tiện lợi.

Thursday, October 25, 2018

Bài 39: Xây dựng chatbot server với Go (tiếp theo)

Trong bài trước, tôi chỉ đơn giản in hoa những gì nhận được và gửi lại thôi. Bot thực hiện mỗi chuyện đó thì đơn giản quá nên một số tính năng hay của nền tảng Messenger của Facebook cung cấp như menu, trả lời nhanh, xử lý postback, v.v... chưa được sử dụng. Trong bài này tôi sẽ cho bot thực hiện chức năng phức tạp hơn tí là hiển thị tỉ giá ngoại tệ với đồng Việt Nam. Nào chúng ta bắt đầu!

Đầu tiên chúng ta cần tìm một nơi có thể cung cấp tỉ giá thường xuyên cập nhật để có thể lấy thông tin. Tôi tìm được URL của ngân hàng ngoại thương (VCB) cung cấp dạng XML và ngân hàng Đông Á cung cấp dạng JSON. Tôi sẽ thử với dữ liệu của VCB, của Đông Á các bạn tự làm nhé.

Xử lý dữ liệu tỉ giá


Các chức năng của bot phức tạp lên nên tôi tách hẳn những xử lý này ra file bot.go luôn cho tiện quản lý. Lúc này phần xử lý các thuộc tính Messaging trong processWebhook tôi sẽ gom chung trong hàm processMessage để ở bot.go luôn. Lúc này hàm processWebhook ở main.go đơn giản 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 {
                    processMessage(&event)
                }
            }
        }
        w.WriteHeader(200)
        w.Write([]byte("Got your message"))
    } else {
        w.WriteHeader(404)
        w.Write([]byte("Message not supported"))
    }
}
Giờ mọi xử lý chúng ta tập trung ở bot.go, không đụng gì đến main.go nữa.
Đầu tiên là hàm xử lý lấy thông tin tỉ giá. Tỉ giá trả về dạng XML nên tôi khai báo struct và hàm xử lý như bên dưới:
package main

import (
    "encoding/xml"
    "net/http"
    "strings"

    "github.com/apex/log"
)

type (
    ExchangeRate struct {
        DateTime string   `xml:"DateTime"`
        Exrate   []Exrate `xml:"Exrate"`
        Source   string   `xml:"Source"`
    }

    Exrate struct {
        CurrencyCode string `xml:"CurrencyCode,attr"`
        CurrencyName string `xml:"CurrencyName,attr"`
        Buy          string `xml:"Buy,attr"`
        Transfer     string `xml:"Transfer,attr"`
        Sell         string `xml:"Sell,attr"`
    }
)

func processMessage(event *Messaging) {
    sendAction(event.Sender, MarkSeen)
    sendAction(event.Sender, TypingOn)
    sendText(event.Sender, strings.ToUpper(event.Message.Text))
    sendAction(event.Sender, TypingOff)
}

func getExchangeRateVCB() (*ExchangeRate, bool) {
    var exrate ExchangeRate

    req, err := http.NewRequest("GET", "http://www.vietcombank.com.vn/ExchangeRates/ExrateXML.aspx", nil)
    if err != nil {
        log.Errorf("getExchangeRateVCB: NewRequest: %s", err.Error())
        return &exrate, false
    }

    client := &http.Client{Timeout: time.Second * 30}
    resp, err := client.Do(req)
    if err != nil {
        log.Errorf("getExchangeRateVCB: client.Do: %s", err.Error())
        return &exrate, false
    }
    defer resp.Body.Close()

    err = xml.NewDecoder(resp.Body).Decode(&exrate)
    if err != nil {
        log.Errorf("getExchangeRateVCB: xml.NewDecoder: %s", err.Error())
        return &exrate, false
    }

    return &exrate, true
}
Giờ là lúc sửa lại hàm processMessage để cung cấp chức năng về thông tin tỉ giá.
Đầu tiên chúng ta cho người dùng chọn ngoại tệ họ muốn xem tỉ giá. Có nhiều cách làm việc này:
(1) Hiển thị danh sách các ngoại tệ, người dùng gõ viết tắt ngoại tệ nào thì bot trả lời thông tin tỉ giá ngoại tệ đó. Ví dụ họ gõ USD, bot trả về thông tin tỉ giá giữa đô la Mĩ và đồng Việt Nam. Cách này đơn giản nhất nhưng có điểm dở là người dùng sẽ gõ nhiều kiểu khác nhau và thậm chí gõ sai thì chúng ta xử lý rất mệt. Ví dụ: gõ usd hay usđ, ...
(2) Hiển thị danh sách ngoại tệ kèm thêm con số, người dùng nhập số tương ứng thì bot xử lý. Cách này tiện hơn cách trên vì nhập số ít sai sót nhưng nó chỉ phù hợp với các nền tảng chat chỉ hỗ trợ chat chuỗi văn bản.
(3) Hiển thị danh sách ngoại tệ dạng câu trả lời nhanh. Cách này khá đơn giản khi phát triển nhưng cũng khá tiện lợi cho người dùng nên tôi sẽ chọn nó cho chức năng này. Điểm dở của nó là danh sách trả lời nhanh này sẽ mất đi khi người dùng chọn câu trả lời nên không chọn lại được nữa. Tối đa hiển thị được 11 câu trả lời nhanh. Lưu ý trả lời nhanh không có chạy trên Messenger Lite.
(4) Hiển thị ngoại tệ dạng danh sách cuộn. Cách này thường dùng để cung cấp các thông tin có nhiều nội dung và hình ảnh đi kèm. Mỗi lần hiển thị được 10 mục. Tôi thấy hiển thị danh sách của Đông Á dùng cách này khá ổn do nó có cả hình ảnh là lá cờ các quốc gia nữa. Tài liệu về danh sách cuộn ở đây. Các bạn làm thử nhé!


Xử lý trả lời nhanh


Dựa trên tài liệu Facebook tôi sửa lại struct Message và bổ sung thêm struct QuickReply như sau:
type (
    ...   
    Message struct {
        MID        string      `json:"mid,omitempty"`
        Text       string      `json:"text,omitempty"`
        QuickReply *QuickReply `json:"quick_reply,omitempty"`
    }

    QuickReply struct {
        ContentType string `json:"content_type,omitempty"`
        Title       string `json:"title,omitempty"`
        Payload     string `json:"payload"`
    }

    ResMessage struct {
        Text       string       `json:"text,omitempty"`
        QuickReply []QuickReply `json:"quick_replies,omitempty"`       
   }
    ...
)
Cần phải viết thêm hàm để gửi câu trả lời nhanh nữa. Do gửi trả lời nhanh chỉ hơn gửi văn bản ở phần QuickReply nên tôi chỉnh lại hàm sendText luôn cho đồng nhất như sau: 
func sendTextWithQuickReply(recipient *User, message string, replies []QuickReply) error {
    m := ResponseMessage{
        MessageType: MessageResponse,
        Recipient:   recipient,
        Message: &ResMessage{
            Text:       message,
            QuickReply: replies,
        },
    }
    return sendFBRequest(FBMessageURL, &m)
}

func sendText(recipient *User, message string) error {
    sendTextWithQuickReply(user, message, nil)
}
Để đơn giản tôi quy định là nếu người dùng gõ vào "rate" thì chức năng tỉ giá này sẽ được bot xử lý. Hàm processMessage được sửa lại như sau: 
var (
    // Lưu thông tin ngoại tệ lấy được
    exRateList *ExchangeRate
    // Lưu nhóm ngoại tệ đang hiển thị của từng người dùng
    exRateGroupMap = make(map[string]int)
)

func processMessage(event *Messaging) {
    // Gửi hành động đã xem và đang trả lời
    sendAction(event.Sender, MarkSeen)
    sendAction(event.Sender, TypingOn)

    // Xử lý khi người dùng chọn trả lời nhanh
    if event.Message.QuickReply != nil {
        processQuickReply(event)
        return
    }
    // Xử lý khi người dùng gửi văn bản
    text := strings.ToLower(strings.TrimSpace(event.Message.Text))
    if text == "rate" {
        // Lưu nhóm ngoại tệ xem hiện tại
        exRateGroupMap[event.Sender.ID] = 1
        // Gửi danh sách ngoại tệ
        sendExchangeRateList(event.Sender)
    } else {
        // Gửi chuỗi nhận được sau khi chuyển sang chữ hoa
        sendText(event.Sender, strings.ToUpper(event.Message.Text))
    }
    // Gửi hành động đã trả lời xong
    sendAction(event.Sender, TypingOff)
}

func processQuickReply(event *Messaging) {
    recipient := event.Sender
    exRateGroup := exRateGroupMap[event.Sender.ID]
    switch event.Message.QuickReply.Payload {
    case "Next": // Trường hợp người dùng chọn "Xem tiếp"
        var i int
        // Kiểm tra nếu đã xem xong danh sách thì quay lại
        if exRateGroup*10 >= len(exRateList.Exrate) {
            exRateGroup = 1
        } else {
            exRateGroup++
        }
        exRateGroupMap[event.Sender.ID] = exRateGroup
        quickRep := []QuickReply{}
        // Mỗi lần hiển thị gồm 10 ngoại tệ
        for i = 10 * (exRateGroup - 1); i < 10*exRateGroup && i < len(exRateList.Exrate); i++ {
            exrate := exRateList.Exrate[i]
            quickRep = append(quickRep, QuickReply{ContentType: "text", Title: exrate.CurrencyName, Payload: exrate.CurrencyCode})
        }
        // Thêm nút "Xem tiếp"
        quickRep = append(quickRep, QuickReply{ContentType: "text", Title: "Xem tiếp", Payload: "Next"})
        sendTextWithQuickReply(recipient, "GoBot cung cấp chức năng xem tỉ giá giữa các ngoại tệ và đồng Việt Nam.\nMời bạn chọn ngoại tệ:", quickRep)
    default: // Trường hợp người dùng chọn 1 nút trả lời nhanh
        var exRate Exrate
        // Kiểm tra coi payload nhận được khớp với item nào
        for i := 10 * (exRateGroup - 1); i < 10*exRateGroup && i < len(exRateList.Exrate); i++ {
            if exRateList.Exrate[i].CurrencyCode == event.Message.QuickReply.Payload {
exRate = exRateList.Exrate[i]
break
            }
        }
        // Không tìm thấy item nào khớp
        if len(exRate.CurrencyCode) == 0 {
            sendText(recipient, "Không có thông tin về ngoại tệ này")
            return
        }
        // Trả về thông tin tìm được
        sendText(recipient, fmt.Sprintf("%s-VND\nGiá mua: %sđ\nGiá bán: %sđ\nGiá chuyển khoản: %sđ", exRate.CurrencyCode, exRate.Buy, exRate.Sell, exRate.Transfer))
    }
}

func sendExchangeRateList(recipient *User) {
    var (
        ok          bool
        i           int
        exRateGroup = exRateGroupMap[recipient.ID]
    )
    // Lấy danh sách ngoại tệ và lưu vào biến toàn cục exRateList
    exRateList, ok = getExchangeRateVCB()
    if !ok {
        sendText(recipient, "Có lỗi trong quá trình xử lý. Bạn vui lòng thử lại sau bằng cách gửi 'rate' cho tôi nhé. Cảm ơn!")
        return
    }
    quickRep := []QuickReply{}
    // Lấy nhóm 10 ngoại tệ
    for i = 10 * (exRateGroup - 1); i < 10*exRateGroup && i < len(exRateList.Exrate); i++ {
        exrate := exRateList.Exrate[i]
        quickRep = append(quickRep, QuickReply{ContentType: "text", Title: exrate.CurrencyName, Payload: exrate.CurrencyCode})
    }
    quickRep = append(quickRep, QuickReply{ContentType: "text", Title: "Xem tiếp", Payload: "Next"})
    sendTextWithQuickReply(recipient, "GoBot cung cấp chức năng xem tỉ giá giữa các ngoại tệ và đồng Việt Nam.\nMời bạn chọn ngoại tệ:", quickRep)
}
Tôi có ghi chú rồi nên chắc không cần nói gì hơn, chỉ lưu ý ở exRateList. Biến toàn cục này được cập nhật mỗi lần có người nhắn "rate" để luôn có cập nhật mới nhưng cũng giảm được số lần lấy dữ liệu nếu so với việc mỗi lần xử lý là lại lấy. Tuy nhiên cách này có chỗ dở là nếu người khác nhắn "rate" nó cũng được cập nhật nên nếu lúc đó bot thao tác cho người dùng này thì có thể bị lỗi. Giải quyết trường hợp này có thể dùng mutex.
Giờ chạy lại để tận hưởng thành quả nào!
 
Như vậy chúng ta đã hoàn thành chatbot server cung cấp chức năng thông tin tỉ giá ngoại tệ. Giờ chúng ta cùng tìm hiểu cách tạo menu và xử lý postback nhé.


Màn hình chào và menu



Màn hình chào là màn hình hiển thị khi một người dùng mở cửa sổ chat với page lần đầu tiên hoặc ngay sau khi họ chọn xóa dữ liệu chat với page. Màn hình này có 1 lời chào và một nút "Bắt đầu" hoặc "Get Started" để giúp người dùng chọn tránh bỡ ngỡ ban đầu. Khi họ bấm nút, bot sẽ được 1 sự kiện postback

Menu là danh sách các chức năng luôn hiển thị để người dùng có thể chọn nhanh chức năng họ cần. Khi họ chọn, bot sẽ nhận được sự kiện postback tương ứng.

Dựa trên tài liệu Facebook liên quan ở trên, tôi khai báo các struct và tạo hàm thiết lập màn hình chào và menu như sau:

type (
    PageProfile struct {
        Greeting       []Greeting       `json:"greeting,omitempty"`
        GetStarted     *GetStarted      `json:"get_started,omitempty"`
        PersistentMenu []PersistentMenu `json:"persistent_menu,omitempty"`
    }

    Greeting struct {
        Locale string `json:"locale,omitempty"`
        Text   string `json:"text,omitempty"`
    }

    GetStarted struct {
        Payload string `json:"payload,omitempty"`
    }

    PersistentMenu struct {
        Locale   string `json:"locale"`
        Composer bool   `json:"composer_input_disabled"`
        CTAs     []CTA  `json:"call_to_actions"`
    }

    CTA struct {
        Type    string `json:"type"`
        Title   string `json:"title"`
        URL     string `json:"url,,omitempty"`
        Payload string `json:"payload"`
        CTAs    []CTA  `json:"call_to_actions,omitempty"`
    }
)

const (
    GetStartedPB = "GetStarted"
    RatePB       = "rate"
)

func registerGreetingnMenu() bool {
    profile := PageProfile{
        Greeting: []Greeting{
            {
                Locale: "default",
                Text:   "Dịch vụ cung cấp thông tin tỉ giá hối đoái",
            },
        },
        GetStarted: &GetStarted{Payload: GetStartedPB},
        PersistentMenu: []PersistentMenu{
            {
                Locale:   "default",
                Composer: false,
                CTAs: []CTA{
                    {
                        Type:    "postback",
                        Title:   "Tỉ giá hối đoái",
                        Payload: RatePB,
                    },
                },
            },
        },
    }
    err := sendFBRequest("https://graph.facebook.com/v3.1/me/messenger_profile", &profile)
    if err != nil {
        log.Error("registerGreetingnMenu:" + err.Error())
        return false
    }
    return true
}
Hàm registerGreetingnMenu khi nào cần thay đổi chúng ta mới gọi bởi vì mỗi khi gọi, menu sẽ không có hiệu lực ngay mà phải refresh mới thấy. Do đó tôi thường để nó đầu hàm main, sau đó return luôn để chạy mỗi nó. Chạy xong thì comment 2 dòng này để hàm main hoạt động bình thường lại như trước.
Sau khi chạy, refresh màn hình Facebook hoặc Messenger chúng ta thấy menu dạng hamburger xuất hiện:
 
Muốn thấy nút "Bắt đầu" chúng ta phải xóa cuộc trò chuyện như hướng dẫn sau:
Sau khi đồng ý xóa cuộc trò chuyện và refresh lại trang, chúng ta sẽ thấy nút bắt đầu và lời chào xuất hiện:
Chắc hẳn bạn nóng lòng muốn chọn nó nhưng tôi khuyên là chưa vội vì chúng ta chưa hề xử lý gì khi ấn vào nó cả. Như tôi đề cập ở trên, khi ấn vào nút "Bắt đầu" hoặc item của menu thì Facebook gửi Postback cho bot nên chúng ta phải xử lý nó trước đã. Các bạn đọc tài liệu về Postback ở đây. Việc chúng ta bây giờ gồm 3 việc:
- Bổ sung thuộc tính Postback vào Messaging để nhận được postback do Facebook gửi tới.
- Sửa lại hàm processWebhook để nhận thông tin postback.
- Viết hàm processPostback để xử lý postback.
Đầu tiên, struct Messaging trong message.go được sửa lại như sau:
type (
    ...
    Messaging struct {
        Sender    *User     `json:"sender,omitempty"`
        Recipient *User     `json:"recipient,omitempty"`
        Timestamp int       `json:"timestamp,omitempty"`
        Message   *Message  `json:"message,omitempty"`
        PostBack  *PostBack `json:"postback,omitempty"`
    }
   
    PostBack struct {
        Title   string `json:"title,omitempty"`
        Payload string `json:"payload"`
    }
    ...
)
Hàm processWebhook trong main.go:
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 {
                    processMessage(&event)
                } else if event.PostBack != nil {
                    processPostBack(&event)
                }
            }
        }
        w.WriteHeader(200)
        w.Write([]byte("Got your message"))
    } else {
        w.WriteHeader(404)
        w.Write([]byte("Message not supported"))
    }
}
Hàm processPostback ở bot.go:
func processPostBack(event *Messaging) {
    // Gửi hành động đã xem và đang trả lời
    sendAction(event.Sender, MarkSeen)
    sendAction(event.Sender, TypingOn)

    switch event.PostBack.Payload {
    case GetStartedPB, RatePB:
        exRateGroupMap[event.Sender.ID] = 1
        sendExchangeRateList(event.Sender)
    }
    // Gửi hành động đã trả lời xong
    sendAction(event.Sender, TypingOff)
}
Để đơn giản thì việc người dùng chọn "Bắt đầu" hay từ menu đều xử lý cung cấp thông tin tỉ giá cả. Sau này nếu các bạn bổ sung chức năng khác thì tách riêng ra sau nhé.
Bây giờ chạy thử các bạn sẽ nhận được kết quả như ý khi nhấn bắt đầu hay chọn từ menu:
 
Như vậy là chúng ta đã hoàn thành các chức năng cơ bản cho 1 chatbot trên Facebook Messenger viết bằng Go. Còn 1 số phần khác nền tảng Messenger hỗ trợ hay cách đăng ký để Facebook xác nhận để mọi người có thể tương tác với bot của bạn các bạn tự nghiên cứu nhé. Nếu có vấn đề cứ comment bên dưới. Trả lời được tôi sẽ trả lời. 
Toàn bộ mã nguồn của phần này tôi để ở đây.

Trong bài tới tôi sẽ giới thiệu về cách triển khai chatbot server lêm dịch vụ Lambda của Amazon Web Services.


Tóm tắt:
 - Bot cung cấp chức năng thông tin tỉ giá hối đoái, lấy dữ liệu từ VCB dạng XML.
- Trả lời nhanh là công cụ nền tảng Messenger hỗ trợ giúp tạo các nút bên dưới nội dung chat để người dùng có thể chọn cho câu trả lời của họ mà không phải gõ. Lưu ý: Các lựa chọn mất sau khi người dùng chọn, tối đa 11 lựa chọn và không hiển thị được ở Messenger Lite.
- Màn hình chào hiển thị khi người dùng lần đầu chat với page và có nút "Bắt đầu" để kích hoạt tương tác giữa người dùng và chatbot.
- Menu là công cụ luôn hiển thị để người dùng chọn khi cần đến nhanh một chức năng nào đó.
- Khi người dùng ấn chọn nút "Bắt đầu" hoặc menu, bot sẽ nhận được sự kiện postback.

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.