這篇是延續"使用AWS lambda和Github來提供中華職棒賽程資料", 之前的做法是用Cloud watch加上lambda來做這件事, 但我跑的東西並不是那麼的頻繁, 在AWS上還是會被收到流量的費用, 因此就打算用更經濟的方式, 利用heroku免費的額度來做這事(真是壞客戶 XD)

目的是定時(比如說每四小時)去爬一些網頁的資訊, 爬這些網頁其實也不需要花太久時間

用Cloud watch + lambda的好處是不用架一台server, 但用Heroku這種PAAS其實也不用太去管server這事

Heroku是可以設定scheduled tasks的, 但額外的work dyno是要另外付費的, 因此, 如果需求不是需要太頻繁, 也不需要執行太久的, 這時候就可以利用ifttt來定時觸發一個url的方式來做

要定時觸發一個URL, ifttt applet該怎麼設定呢? 首先"this"要選用的是Date & Time, 如下:

設定上並沒有很多, 就像是每小時, 每天之類的, 沒辦法訂多個, 如果需要一次多個設定, 那就多新增幾個Applets吧

這邊設定每小時, 就設定每小時的15分來觸發吧

那"that"的動作呢? 觸發URL的動作是利用"Maker", 這是設計給iot用的吧, 不過, 拿來做這用途也是沒問題的:

Maker只有一個選項"Make a web request"

設定很單純, 就給定URL, 使用的HTTP Method(GET, POST, PUT …), Content-type, 跟Body

這邊我用的是POST + Json, Json裡面會帶一個TOKEN來辨識, 以免有心人士利用了這個URL, go的檢查範例如下:

func checkID(body io.Reader) bool {
        data, err := ioutil.ReadAll(body)

        if err != nil {
                return false
        }

        var rbody struct {
                Id string
        }
        err = json.Unmarshal(data, &rbody)

        if err != nil {
                return false
        }

        return rbody.Token == os.Getenv("SEC_TOKEN") && rbody.Token != ""
}

接到request後, 其實是可以把執行的task丟給另一個go routine處理, 原本的就可以回傳給ifttt, 避免執行太久而timeout的問題, 不過對heroku來說, 這還是在同一個web dyno上就是了

最近因為寫bot, 處理不少的HTML資料, 其中最常用的就是去取的Open Graph的內容, 取這部分的資料是做啥用呢? 現今, 多數的網頁已會用Open GraphTwitter Card 來描述網頁的一些屬性, 比如說標題, 相關圖片, 關聯影片等等, 而不管是Open Graph還是Twitter Card 都以HTML的meta tags存在的, 像這樣:

<meta property="og:site_name" content="TechCrunch" />
<meta property="og:site" content="social.techcrunch.com" />
<meta property="og:title" content="Hugo Barra is leaving his position as head of international at Xiaomi after 3.5&nbsp;years" />
<meta property="og:description" content="Chinese smartphone maker Xiaomi is losing its head of international and primary English-language spokesperson Hugo Barra after he announced his exit from the.." />
<meta property="og:image" content="https://tctechcrunch2011.files.wordpress.com/2016/05/hugo-barra-2.jpg?w=764&amp;h=400&amp;crop=1" />
<meta property="og:url" content="http://social.techcrunch.com/2017/01/22/hugo-barra-is-leaving-his-position-as-head-of-international-at-xiaomi-after-3-5-years/" />
<meta property="og:type" content="article" />
<meta name='twitter:card' content='summary_large_image' />
<meta name='twitter:image:src' content='https://tctechcrunch2011.files.wordpress.com/2016/05/hugo-barra-2.jpg?w=764&#038;h=400&#038;crop=1' />
<meta name='twitter:site' content='@techcrunch' />
<meta name='twitter:url' content='https://techcrunch.com/2017/01/22/hugo-barra-is-leaving-his-position-as-head-of-international-at-xiaomi-after-3-5-years/' />
<meta name='twitter:description' content='Chinese smartphone maker Xiaomi is losing its head of international and primary English-language spokesperson Hugo Barra after he announced his exit from the company.' />

(範例剛好跟上時事 :P)

在做我的bot新聞萬事通就是用這個來取得圖片跟標題的內容, 當然, 你在Facebook分享了一個連結, Facebook會自動帶上縮圖跟內容, 資料來源也是來自OG

OG

雖然Go也可以找到一兩個Open graph的parser, 看起來好像還算堪用, 不過因為自己用得多了, 所以索性自己寫一個叫OG:

GitHub/julianshen - OG

仿golang本身的json package, 也利用了Reflection, 因此還有點靈活度, 可以支援更多的資訊

安裝OG

go get -u github.com/julianshen/og

基本資料結構

type PageInfo struct {
	Title    string `meta:"og:title"`
	Type     string `meta:"og:type"`
	Url      string `meta:"og:url"`
	Site     string `meta:"og:site"`
	SiteName string `meta:"og:site_name"`
	Images   []*OgImage
	Videos   []*OgVideo
	Audios   []*OgAudio
	Twitter  *TwitterCard
	Content  string
}

PageInfo是預設的資料結構, 前面有提到, 有彈性可以支援更多的資訊, 所以這個除了直接使用外也可當作一個參考用的定義, 主要就是採用了Go的struct field tags, 定義了一個叫做"meta“的自訂tag, 對照前面的html範例就可知道, 後面的值 是html meta tag裡面的property, 因此你可以自定義自己的資料結構, 然後仿這個規則加上tag即可

也可支援巢狀式, Arrays, Pointer

GetPageInfo

GetPageInfo是以已經定義好的PageInfo這個struct為主, (希望)已經包含比較基本的og或twitter card tags了, 直接呼叫對應的API, 就可以取得這個url裡面的PageInfo的資料了

urlStr := "https://techcrunch.com/2017/01/22/yahoo-hacking-sec/"
pageInfo, e := og.GetPageInfoFromUrl(urlStr)

另外還有一個PageInfo.Content, 這是網頁去掉廣告跟多餘的東西的純文字內容, 這就是GetPageData所沒有的了

GetPageData

跟GetPageInfo不同, 這很適合用在自訂資料結構, 舉個例說, 如果我只想抓og:image的資料就像這樣:

ogImage := og.OgImage{}
urlStr := "https://techcrunch.com/2017/01/22/yahoo-hacking-sec/"
og.GetPageDataFromUrl(urlStr, &ogImage)

簡單談談Go reflection

這個package因為要仿json package使用struct field tags, 所以用了Go的Reflection機制

老實說, Go的reflection沒有像Java的設計那麼好, 寫到後面還蠻容易昏頭的, 而主要有三種要先搞清楚

以這個例子來說:

ogImage := og.OgImage{}
pImage := &ogImage

ogImage的Type是struct OgImage, 而Value是og.OgImage{}, pImage的Type即是*OgImage, 而ogImage的Kind則是struct, pImage則是ptr(pointer)

比較難搞懂的就是什麼時候要用Type, 什麼時候要用Value, Kind又是什麼時候? (作業?!)

type := reflect.TypeOf(ogImage)
value := reflect.Value(pImage)

用’TypeOf’可以取得變數的Type, 而’ValueOf‘則是值(value)的部分, 而value雖然代表變數的值, 但它是一個叫做Value的struct,並不是原本的資料型態 所以不要把它直接當參數呼叫函數用, 如果真有需要, 可以用Value.Interface{}轉成interface{}

StructField.Tag.Lookup則是可以查Field裡面的tag內容

所以這個og parser的原理就是走過所有fields, 只要有meta tags的話就拿去搜尋對應的html tags, 有的話再把html tag裡面content屬性填入值

最後小抱怨一下, Open GraphTwitter Card 實在很不一致, 一個用property, 一個用name, 因此在OG的做法是先用property, 如果再找不到用name去找

Heroku蠻好用的, 也用了好幾年了, 拿來做prototype真是方便, 不過慚愧的是, 我還沒付過錢給他(真惡劣) 最近chatbot玩得比較多, 不想花錢租server, 所以就比較頻繁的用它, 說到這裡, 照例, 先來廣告一下:

新的Line叭寇(Barcode)小幫手 (轉含有條碼的圖片給它, 它會幫你解讀):

好了, 回歸正題, Heroku 雖然是一個Paas的服務, 但它彈性非常大, 透過不同的buildpack也可以支援不同的語言跟框架, 不像Google的GAE, 支援的平台就比較有限

Heroku本身也是跑在Linux上, 因此, 如果你需要額外的套件, 其實也是沒問題的, 舉個例子, 雖然我這個叭寇小幫手是用Go來寫的 但卻會用到ZBar這個讀取條碼的C程式庫, Heroku上當然沒裝, 所以建置Go時, 會因為找不到 程式庫而失敗

在Linux上, 我們可以用apt-get去安裝套件, 以zbar這例子是apt-get install libzbar-dev, 但在Heroku上又該怎麼裝呢?

還是要透過buildpack, 在command line下執行下面指令:

heroku buildpacks:add --index 1 https://github.com/heroku/heroku-buildpack-apt

apt這個build pack是放在官方的Github上, 因為我們希望該需要的, 軟體在一開始就把它準備好了

除了加build pack外, 還是不夠的, 你的套件還是沒被安裝, 因此繼續加一個叫Aptfile的檔案, 內容是你需要安裝的套件

當你push你的程式到heroku上後, 它就會根據Aptfile去安裝相對應的套件了

好, 這算我以為我寫過但實際上沒有系列….啥? 剛剛把我以前寫的一個Go package - gopttcrawler更新後, 想說之前好像有寫過相關文章, 但實際上又沒找到(老了?)

沒關係, 把Readme拿來貼一貼就可以混一篇文了(混蛋!!!偷懶!!!)

gopttcrawler

這是我在新聞萬事通裡面用來抓取ptt文章的, 用法也很簡單, 基本上看最後兩種用法就好, 自己個人覺得那比較好用

原本的版本並沒處理18+那個擋在前面的畫面, 查了一下很多人(在python)的做法是去發post取得cookie, 再帶cookie去取, 後來發現, cookie是固定的, 只要這樣就好:

	cookie := http.Cookie{
		Name:  "over18",
		Value: "1",
	}
	req.AddCookie(&cookie)

所以現在像是八卦版這種18+的也可以抓取了

安裝方法: go get -u github.com/julianshen/gopttcrawler

使用方法請參考sample.go或是ptt_test.go

資料結構

type Article struct {
	ID       string //Article ID
	Board    string //Board name
	Title    string
	Content  string
	Author   string //Author ID
	DateTime string
	Nrec     int //推文數(推-噓)
}

type ArticleList struct {
	Articles     []*Article //Articles
	Board        string //Board
	PreviousPage int //Previous page id
	NextPage     int //Next page id
}

載入文章列表

  1. 載入最新一頁表特版文章
    articleList, _ := gopttcrawler.GetArticles("Beauty", 0)
    // the 1st parameter is the board name
    // the 2nd parameter is the page id. 0 indicates the latest page
  1. 載入前一頁文章列表
    prevArticleList, _ := articleList.GetFromPreviousPage()

載入文章內容

  1. 載入單篇文章詳細內容
    article := articleList.Articles[0]
    article.Load()
    fmt.Println(article.Content) //印出內文(html)
  1. 取得文章中所有圖片連結
    images := article.GetImageUrls()
  1. 取得文章中的連結
    links := article.GetLinks()

Iterator

新增Iterator功能:

	n := 100

	articles, e := gopttcrawler.GetArticles("movie", 0)
	
	if e != nil {
		....
	}

	iterator := articles.Iterator()

	i := 0
	for {
		if article, e := iterator.Next(); e == nil {
			if i >= n {
				break
			}
			i++

			log.Printf("%v %v", i, article)
		}
	}

上面這範例是抓取最新的100篇文章, 不用管第幾頁, 或是上一頁下一頁, 反正就一直抓

Go routine版本的GetArticles

	ch, done := gopttcrawler.GetArticlesGo("Beauty", 0)
	n := 100
	i := 0
	for article := range ch {
		if i >= n {
			done <- true
			break
		}
		i++
		log.Printf("%v %v", i, article)
	}

這範例一樣也是抓一百篇, 只是抓文章的部分被放到go routine去了, 會立即回傳兩個channel, 第一個是receive only channel, 跟Iterator類似, 一次拿一篇文章, 可以用range, 第二個是一個bool channel, 拿夠了送個訊息通知他終止go routine, 如果把receive部分放到select去, 就是non blocking了, 不會被讀上一頁下一頁的IO給卡住

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