之前由於想說來玩一下實驗一下Line Messaging API, 就寫了一個叫做新聞萬事通的聊天機器人, 一來是實驗一下API, 二來就是想做一些好玩的東西
加入新聞萬事通請按 :
一開始的想法很簡單, 掃描聊天內容內有連結的, 去正宗的新聞小幫手查詢, 不過後來越玩越上癮, 就又加了一些功能, 幾個目前有的功能:
- 掃描假新聞連結 (光只有對連結不太夠, 後來又加上簡單的標題比對)
- 可以在群組內加入
- 頭條新聞及其它的分類新聞
- ptt表特版, 八卦版
- 最近熱映電影及ptt評論
- 假新聞話題辨識, 因為現在Line上流傳的假新聞都是沒有連結的, 因此這功能是將聊天內容關鍵字部分去Google搜尋, 如果搜尋到是假新聞便會提醒(這是最後加上的功能)
這個聊天機器人是以Go寫成的, 部署到Heroku上, “沒有"使用任何的資料庫(呃, 那資料呢? 全部在記憶體內)
這篇主要不是要介紹新聞萬事通, 而是要來介紹怎用Go寫一個line的聊天機器人(之後有空再來介紹如何搭配其他語意分析服務), 順便老王賣瓜一下, 來介紹一個我自己包的API - lbotx (還沒補document, 還沒寫example, 不過有做過部分測試)
從Go Line Bot SDK開始
Line有提供一個給Go的SDK, 用這個SDK開始來寫其實也還蠻簡單的, 這邊從SDK開始講起, 至於前面申請跟設定的部分就麻煩看一下官方文件囉
首先, 它在原始碼內, 其實有個echo_bot的範例, 用它來當範本開始最適合也不過了:
func main() {
bot, err := linebot.New(
os.Getenv("CHANNEL_SECRET"),
os.Getenv("CHANNEL_TOKEN"),
)
if err != nil {
log.Fatal(err)
}
// Setup HTTP Server for receiving requests from LINE platform
http.HandleFunc("/callback", func(w http.ResponseWriter, req *http.Request) {
events, err := bot.ParseRequest(req)
if err != nil {
if err == linebot.ErrInvalidSignature {
w.WriteHeader(400)
} else {
w.WriteHeader(500)
}
return
}
for _, event := range events {
if event.Type == linebot.EventTypeMessage {
switch message := event.Message.(type) {
case *linebot.TextMessage:
if _, err = bot.ReplyMessage(event.ReplyToken, linebot.NewTextMessage(message.Text)).Do(); err != nil {
log.Print(err)
}
}
}
}
})
// This is just sample code.
// For actual use, you must support HTTPS by using `ListenAndServeTLS`, a reverse proxy or something else.
if err := http.ListenAndServe(":"+os.Getenv("PORT"), nil); err != nil {
log.Fatal(err)
}
}
跟寫一般的HTTP Server沒什麼兩樣, 只是你要透過linebot的Client去parse Http請求, 得到一或多個事件(events), 再根據你需要的事件去處理, 以這個例子來說, 它要處理的只有文字訊息, 因此:
event.Type == linebot.EventTypeMessage
判斷這個event是否為訊息(Message)switch message := event.Message.(type) { case *linebot.TextMessage:
判斷是否為文字訊息
為什麼要兩個步驟? 因為events有很多種, 而訊息的event, 又包含了不同的訊息, 像是:
- 文字 (linebot.EventTypeMessage)
- 影像 (linebot.ImageMessage)
- 影片 (linebot.VideoMessage)
- 聲音 (linebot.AudioMessage)
- 地點 (linebot.LocationMessage)
- 貼圖 (linebot.StickerMessage)
然後Events除了訊息(Message)外還有
- 跟隨/停止跟隨
- 加入/離開 群組(聊天室)
- Postback
- Beacon (進入/離開)
另外, 可以看到在處理TextMessage的最後呼叫了bot.ReplyMessage
, 這段等於聊天機器人去回應訊息, Line聊天機器人並不是以http response去回應訊息的, ·bot.ReplyMessage·是一個http call, 直接呼叫Line的API server
bot.ReplyMessage
後面可以接多個messages, 但注意不要分開呼叫bot.ReplyMessage
送多個訊息, 雖然他是直接呼叫Line API server, 但reply token 只有一次有效
如同送進來的訊息一樣, 回應的訊息不只有文字訊息一種, 比較特別的是還多了幾種特殊訊息型態可以運用
- Image map
- 有回應按鈕的模板
- 確認用的模板
- Carousel 多欄式模板
以Carousel為例:
imageURL := app.appBaseURL + "/static/buttons/1040.jpg"
template := linebot.NewCarouselTemplate(
linebot.NewCarouselColumn(
imageURL, "hoge", "fuga",
linebot.NewURITemplateAction("Go to line.me", "https://line.me"),
linebot.NewPostbackTemplateAction("Say hello1", "hello こんにちは", ""),
),
linebot.NewCarouselColumn(
imageURL, "hoge", "fuga",
linebot.NewPostbackTemplateAction("言 hello2", "hello こんにちは", "hello こんにちは"),
linebot.NewMessageTemplateAction("Say message", "Rice=米"),
),
)
if _, err := app.bot.ReplyMessage(
replyToken,
linebot.NewTemplateMessage("Carousel alt text", template),
).Do(); err != nil {
return err
}
這個例子回應了一個包含兩欄的Carousel message, 每欄有兩個動作按鈕, 這邊有一個限制, Carousel裡面每一欄的動作按鈕的數量必須要一致, 總共最多也只能五欄
寫新聞萬事通碰到的問題
其實也不算問題啦, 是覺得寫起來code很醜, 一開始寫簡單的聊天機器人, 以echo_bot開始去擴充就夠了, 但當功能越來越多時, 阿娘喂~~ 一堆if和switch case, 我是看到整個眼花, 不知道別人是怎樣, 但實在很難看
看看kitchensink的範例就知道了, 不好讀
另外像是Carousel也是一個麻煩的地方, 要一個個建出Column和它對應的actions, 然後產生一個linebot.CarouselTemplate
, 最後用這個template產生template message才可以送出, 上面的例子只有兩欄, 較為簡單, 感覺不到痛, 如果是不定數量的, 照這三個步驟, 才發一個message, 覺得麻煩又不易閱讀
Line messaging api其實有很多數量上的限制, 像是字數之類的, API本身不會做檢查, 必須送到server後, 才會回傳錯誤回來, 這點也是需要改進的
我想怎麼做?
我想怎麼做就是我後來包裝出來這這個lbotx所做的事情
lbotx 有什麼特色?
- 撇開if和switch…case (詳細如何呢? 後面再看code吧)
- Chaining handlers 由寫新聞萬事通的經驗來說, 一個event不太可能只有一段邏輯來處理, 通常會是層層把關, 比如說我們判斷多個指令, 可能會是先檢查是不是A, 然後再檢查是不是B, 一直下去, 但這樣就一堆if了, 這是為了解決這一問屜
- 讓reply message變好懂一點
- 提供一些方便的工具
開始使用lbotx
lbotx是一個line bot sdk的再包裝, 所以她底層還是依賴著linebot
bot, e := NewBot("test", "test")
server := http.HandleFunc("/callback", bot)
bot本身就是一個http.Handler, 所以不需要包裝在另一個HandlerFunc裡面, 那如果你用Gin呢?
r := gin.Default()
r.GET("/callback", bot.Gin())
也是支援的
撇開if和switch…case
剛剛有說到, 光處理一個文字訊息, 我們需要先判斷event是不是一個訊息, 然後再判斷是不是文字訊息, 判斷event的型別是字串的比對, 但判斷是不是文字訊息又是用到變數型態的辨別, 這已經是reflection了, 這設計不是很好看, 因此在lbotx用:
bot.OnText(func(context *lbotx.BotContext, msg string) (bool, error) {
fmt.Println(msg)
context.Messages.AddTextMessage("test1")
context.Set("test", "test")
tested = tested + 1
return true, nil
})
因為lbotx已經把判斷的部分包裝了, 所以用這樣就可以了
對於其他種Event, 也是有的
bot.OnVideo(func(context *lbotx.BotContext, data []byte) (bool, error) {
...
return false, nil
})
bot.OnLocation(func(context *lbotx.BotContext, location *linebot.LocationMessage) (bool, error) {
...
return false, nil
})
bot.OnFollow(func(context *lbotx.BotContext) (bool, error) {
fmt.Println("follow : " + context.GetUserId())
user, _ := context.GetUser()
...
return false, nil
})
不同event預設接進來的參數都不同, 不過都有context, context裡面帶有原始的Event資料, 並且可以讓你帶資料到下一個Handler去
Chaining handlers
bot.OnText(func(context *BotContext, msg string) (bool, error) {
fmt.Println("first handler")
context.Messages.AddTextMessage(msg)
context.Set("test", context.Get("test").(string)+"a")
next := false
if context.Event.Source.GroupID != "" {
next = true
}
return next, nil
})
bot.OnText(func(context *BotContext, msg string) (bool, error) {
//Should never run when type = user
fmt.Println("second handler")
context.Messages.AddTextMessage("test1")
context.Set("test", "test")
//throw error
return true, errors.New("Error on purpose")
})
上面就是一個Chaning handlers的範例, 有兩個OnText, 因此當有Text message進來時, 這兩個handler就會一前一後被執行
只有兩種狀況可以中斷chaning handlers不執行下面剩下的handlers:
- 回傳值為false (這值代表的是要不要執行下一個還是到此為止)
- 錯誤發生時 (也就是回傳error, 這時候OnError就會被呼叫到)
上面的範例第一個handler在非群組訊息時就才繼續下一個, 另外最後一個會回傳錯誤
前面有提到context可以帶值在chaining handlers間傳遞, 就是利用context.Set
和context.Get
另外, 由於Reply token只能用一次, 而且在多個handlers時, 讓handler自己reply message並不合適, 因此改用context.Messages.AddMessage
這類的, 所有handlers執行完後, 會被一次送出
讓reply message變好懂一點
主要是針對Carousel才會想去改動這部分:
b := NewButtonMessageBuilderWith("https://upload.wikimedia.org/wikipedia/commons/c/c4/Leaky_bucket_analogy.JPG", "Leaky Bucket", "For test")
b.WithMessageAction("test", "test1")
b.WithURIAction("test2", "http://www.google.com")
b.WithPostbackAction("test3", "test3data", "test3")
message, _ := b.Build("AltText")
d := NewCarouselMessageBuilder()
for i := 0; i < 5; i++ {
col := d.AddColumn()
col.WithImage("http://upload.wikimedia.org/wikipedia/commons/c/c4/Leaky_bucket_analogy.JPG")
col.WithText("test")
col.WithTitle("test")
col.WithMessageAction("Message", "test")
col.WithURIAction("Google", "http://www.google.com")
}
message, _ = d.Build("altText")
採用了Builder的方式取代一直append array, 雖然code沒省多少, 但看起來比較明暸一點
後來又覺得Carousel裡面的東西, 其實都很一致, 所以又多了下面這種寫法:
b := NewCarouselMessageBuilder()
g := b.GetColumnGenerator()
g.WithImage("http://myhost.com/image/{{.Index}}")
g.WithText("Hi {{.Name}}")
g.WithMessageAction("Press me", "I'm {{.Name}}")
data := []struct {
Index int
Name string
}{
{1, "John"},
{2, "Mary"},
{3, "Julian"},
}
b.GenerateColumnsWith(func(data []struct {
Index int
Name string
}) []interface{} {
ret := make([]interface{}, len(data))
for i, d := range data {
ret[i] = d
}
return ret
}(data)...)
message, _ := b.Build("altText")
這叫Column generator, 是借助了Go的text/template這個pacakge, 設好template後, 餵資料就可以了
提供一些方便的工具
BotContext
前面提到的context, 還算一個蠻重要的東西, 在前面的範例裡面有一個user, _ := context.GetUser()
這是用來取代linebot.GetProfile()
的, 原本的GetProfile
寫法較為繁瑣, 把它放到context的話, handler之間可以共用, 如果有handler已經從server取過後, 另一個用到就不需要重取
OnTextWith
這是一個OnText變形, 只處理符合條件的Text Message
bot.OnTextWith("Hello, {{name}}. Can you give me {{thing}}?", func(context *BotContext, text string) (bool, error) {
assert.Equal(t, context.Params["name"], "Julian")
assert.Equal(t, context.Params["thing"], "apple")
return true, nil
})
第一個參數是一個包含變數的文字模板, 符合這模板的才會去執行這handler, 此外{{}}裡面的字串也會被當變數取出, 可以從context.Params
取得, 雖然沒api.ai那樣強大, 但這樣的應該勉強堪用吧
還有其他嗎?
document還沒補齊, 也還沒sample codes, 這需要之後來補齊了, 本來是想包裝成line跟Facebook通用的, 不過還沒真的去看Facebook, 就先把Line的包一包吧
新聞萬事通也還沒改成這個架構, 這架構打算在我下一個bot上來應用吧, 新聞萬事通, 目前用戶太少了, 沒動力改 :P