[Go] 用Go寫line聊天機器人 - 新聞萬事通心得1

Reading time ~8 minutes

之前由於想說來玩一下實驗一下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.Setcontext.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