巴哈姆特 Chatbot 之亂:用 Ruby on Rails 接收 Webhook
六月底的時候發現巴哈姆特似乎想為他們推出的 Messaging APP (哈哈姆特)舉辦一個聊天機器人的比賽,看到之後想說還算蠻有趣的,所以我就跟朋友很隨意的組成一個團隊來開發。
跟大多數我們熟悉串接 Chatbot 的機制是類似的,我們可以用 Webhook 的方式接收一個來自使用者發送的訊息,然後再透過程式處理後回傳訊息給使用者。
了解 Webhook 機制
在程式設計中,我們常常會使用一種叫做「Hook(鉤子)」或者「Callback(回呼)」的機制,用比較好懂的角度去說明,他是一個在「程式執行中插入額外動作」
舉例來說,我們會有像這樣的程式
- 接收訊息
- 顯示訊息
假設我們要增加一個 Hook 就會變成像這樣
- 接收訊息
- Hooks (可能有多個)
- 顯示訊息
而 Webhook 就是指這個 Hook 利用 Web(網站)的方式執行,所以當這些 Messaging APP 收到訊息後,會利用 Webhook 做一些事情(像是發送給我們自己的伺服器)然後再繼續動作。
了解 Signature 機制
不過當我們收到一段訊息的時候,要怎麼知道這段訊息是來是正確的使用者?
這就要靠 Signature 機制來幫助我們,透過一個共用的密鑰(Token)來對訊息內容加密,當我們收到訊息的時候只要用同樣的密鑰對訊息加密,就會獲得一段驗證碼,當我們比對驗證碼跟發送者提供給我們相同時,就可以假設這是可信的訊息。
有些網站提供檔案下載時會提供 MD5 校驗碼也是同樣的原理。
以哈哈姆特的 Webhook 為例子,我們會從哈哈姆特收到一個 Webhook 請求,這個請求會包含類似下面的資訊。
- X-BAHA-DATA-SIGNATURE 標頭(Header)
- 內容(Ex. 某段訊息)
巴哈使用的是 SHA1 演算法(MD5 是另外一種),所以我們就要把內容用 SHA1 計算,再比對巴哈給我們的 X-BAHA-DATA-SIGNATURE
來驗證是否是來自巴哈,因為加密的密碼理論上只會有我們自己跟巴哈知道。
接收請求
如果你還沒有用過 Ruby on Rails 的話,可以參考龍哥所寫的為你自己學 Ruby on Rails 這本書,在網站上看到的部分就足夠你入門。
首先,我們希望有一個網址(Endpoint)可以接收請求,所以要在 config/routes.rb
定義一個控制器(Controller)來處理。
1Rails.application.routes.draw do
2 # ...
3
4 post :bahamut, to: 'webhook#bahamut'
5end
透過 Ruby 的 DSL 特性,我們就可以定義出一個叫做 /bahamut
的位址,用來接收巴哈姆特的 Webhook。然後在上面定義要使用 Webhook 控制器上面的 #bahamut
方法來處理這個位址的動作。
1# app/controllers/webhook_controller.rb
2class WebhookController < ActionController::API
3 def bahamut
4 # TODO: Implement Chatbot Handler
5 render plain: 'Hello World'
6 end
7end
在這邊我們可能需要下一點指令才能測試,或者你可以使用 Postman 這套軟體來模擬 POST 請求。
POST 請求一般是我們送出表單的操作,所以無法直接用打開網頁的方式開啟。
1curl -XPOST http://localhost:3000/bahamut
然後我們就能看到我們的終端機(Terminal)出現了 Hello World
字樣。
如果我們希望巴哈能發送訊息到我們自己的本機電腦(localhost)就必須讓我們的電腦能在網路上被找到,這可以利用 Ngrok 這套軟體達成,透過 Ngrok 我們可以得到一個暫時性的網址,如此一來就能將本機測試的網站被巴哈呼叫到。
我想大家可能有疑問,就是是不是一定要用 Ruby on Rails 才能做到,實際上因為 Ruby on Rails 對初學者來說是最容易搭建出網站的選項,才會選擇使用。不然只要是任何能處理網的程式語言,都是可以直接用來寫 Chatbot 的,只不過像是 Ruby on Rails 這類網站開發框架,能幫我們省下學習這些基礎知識的時間。
驗證 Signature
因為處理簽章(Signature)的機制比較複雜,在物件導向類型的語言中,我們會設計一個 Class 來專門處理這件事情。
所以我們來製作一個服務物件(Service Object)叫做 Signature Verifer (簽章驗證器)來專們針對巴哈姆特傳入的簽章做驗證。
1# app/services/signature_verifer.rb
2
3class SignatureVerifer
4 def initialize(request)
5 @request = request
6 # 讀取內容
7 @body = @request.body.read
8 # 讀取 Signature Header
9 @signature = request.headers['x-baha-data-signature']
10
11 # 把內容退回開頭(避免其他人讀取不到資料)
12 @request.body.rewind
13 end
14end
第一個步驟我們要設計驗證器的「初始化(Initialize)」階段要做什麼,我們預期會收到一個 HTTP 請求(request
)然後將裡面的簽章(x-baha-data-signature
)取出來,以及內容(對話訊息)取出來,這是我們在前面提到驗證是否是由巴哈發出的訊息所需要的資訊。
1# app/services/signature_verifer.rb
2
3class SignatureVerifer
4 # ...
5
6 private
7
8 def verify_signature
9 @verify_signature ||=
10 "sha1=#{OpenSSL::HMAC.hexdigest('SHA1', ENV['BAHA_SECRET'], @body)}"
11 end
12end
這個步驟是根據巴哈的文件將剛剛抓到的訊息跟聊天機器人的 Secret(秘鑰)做 SHA1 運算產生出我們自己計算的簽章,如此一來跟巴哈提供的比對,就會知道內容是不是一樣沒有被人偷偷竄改。
要特別注意的是
ENV['BAHA_SECRET']
這邊我是使用「環境變數」來儲存密鑰,這樣只有安裝伺服器的人會知道,就可以避面將這類敏感資訊放到程式碼之中。 在 Rails 5 之後,我們可以用
rails credentials:edit` 這個指令編輯一個加密的檔案,並把密鑰放到裡面,使用方法可以參考 Ruby on Rails 文件
1class SignatureVerifer
2 # ...
3
4 def valid?
5 @signature == verify_signature
6 end
7
8 private
9
10 # ...
11end
最後再提供一個 valid?
方法,用來讓我們查詢是否正確就可以了!
我們修改一下 WebhookController
來做一個簡單的檢查。
1class WebhookController < ActionController::API
2 def bahamut
3 return unathorized_error unless valid_signature?
4 # TODO: Implement Chatbot Handler
5 render plain: 'Hello World'
6 end
7
8 private
9
10 def valid_signature?
11 SignatureVerifer.new(request).valid?
12 end
13
14 def unauthorized_error
15 render json: { error: 'Unauthorized' }, status: :unauthorized
16 end
17end
假設我們透過 SignatureVerifer
驗證失敗的話,就回傳一個 JSON 資訊表示未驗證,並且設定 HTTP 的狀態碼為 401(未授權)的狀態。
JSON 是一種資料格式,常常用在不同伺服器溝同時當作交歡資料的格式,我們從巴哈收到的訊息也是 JSON 格式。
發送回應
既然我們已經可以接收訊息,如果使用者都沒有辦法收到任何回應的話肯定會覺得奇怪,所以下一步就是要能發送訊息給使用者。
哈哈姆特目前支援文字、圖片、貼圖跟事件幾種類型,其中事件是最容易做的,打好基底後也會變得更容易修改成支援其他類型的發送程式。
我們先來看一下從巴哈接收到的訊息會是怎樣的格式(JSON)
1{
2 "botid":<BOT_ID>,
3 "time":1512353744843,
4 "messaging":[
5 {
6 "sender_id":<SENDER_ID>,
7 "message":{
8 "text":"Hello~"
9 }
10 }
11 ]
12}
我們需要關注的只有 messaging
區塊的部分,裡面描述了「多個訊息」而每個訊息都會有「發送者」和「內容」兩個資訊。在上面這從官方文件複製的訊息範例中,使用者發送的內容是一段「文字(text)」
在 Rails 接收到之後,會自動的做好 JSON 解析的處理,所以我們可以直接像這樣使用。
1# 照每一個訊息處理
2params['messaging'].each do |message|
3 # 解析訊息跟回覆
4end
在開始處理之前,我們需要先能夠發送訊息到哈哈姆特。因為步驟也是比較多的,所以我們需要製作一個 Sender (發送器)物件來處理。
1require 'net/http'
2
3# app/services/text_sender.rb
4class TextSender
5 def initialize(recipient, message)
6 @receipient = receipient
7 @message = message
8 end
9end
首先我們在初始化階段要把「接收者」跟想要發送出去的「訊息」記錄起來。
1require 'net/http'
2
3# app/services/text_sender.rb
4class TextSender
5 ENDPOINT = 'https://us-central1-hahamut-8888.cloudfunctions.net/' \
6 "messagePush?access_token=#{ENV['BAHA_TOKEN']}"
7 # ...
8
9 def perform
10 # 發送訊息
11 end
12
13 def uri
14 @uri ||= URI(ENDPOINT)
15 end
16
17 def ssl?
18 uri.scheme == 'https'
19 end
20
21 private
22
23 def request
24 # 製作一個 HTTP 請求
25 end
26end
接下來我們將巴哈文件上所提供的位置,以及一些發送請求需要的一些資訊製作出來。
像是
URI
這類轉換是用於 Ruby 處理發送 HTTP 請求所需要的,所以我們都先做好處理方便使用。而ENV['BAHA_TOKEN']
跟前面的ENV['BAHA_SECRET']
用途是一樣的,都是需要避免直接寫在程式內的數值。
1require 'net/http'
2
3# app/services/text_sender.rb
4class TextSender
5 # ...
6
7 private
8
9 def request
10 return @request if @request.present?
11
12 # 產生一個 HTTP Post 請求
13 @request = Net::HTTP::Post.new(uri)
14 # 使用 JSON 格式(指定內容類型)
15 @request['Content-Type'] = 'application/json'
16 # 把要傳輸的內容轉換成 JSON 格式的資料
17 @request.body = body.to_json
18 @request
19 end
20end
因為我們要將訊息發給巴哈,巴哈再將訊息發給指定的使用者。
如果是 LINE 或者 Facebook Messenger 我們想對同一個人發訊息,在不同的 Chatbot 有不同的編號(ID)這樣就可以保護使用者不會被沒有授權的 Chatbot 騷擾,所以不論是發送還是接收,都需要透過巴哈的伺服器。
1require 'net/http'
2
3# app/services/text_sender.rb
4class TextSender
5 # ...
6
7 def perform
8 http = Net::HTTP.new(uri.host, uri.port)
9 http.use_ssl = ssl?
10 # TODO: 處理回應
11 # 發送請求給巴哈
12 http.request(request)
13 end
14
15 def body
16 {
17 recipient: {
18 id: @recipient
19 },
20 message: {
21 text: @message
22 }
23 }
24 end
25
26 private
27
28 # ...
29end
最後我們只需要將請求的內容(文字訊息)定義好,然後讓他可以發送出去,我們就能對使用者發送回應。
這邊的
recipient
通常會是我們收到的sender_id
我們可以在 Rails Console 裡面像這樣簡單測試是否可以發送訊息
1TextSender.new('巴哈帳號', 'Hello!')
自動回應相同訊息
我們將前面的程式整合後,可以改寫 WebhookController
讓他可以自動回應跟使用者相同的訊息。
1class WebhookController < ActionController::API
2 def bahamut
3 return unathorized_error unless valid_signature?
4
5 process_messages
6 render json: { message: 'OK' }, status: :ok
7 end
8
9 private
10
11 def process_messages
12 params['messaging'].each do |message|
13 TextSender.new(
14 message['sender_id'],
15 "PONG: #{message['message']['text']}"
16 )
17 end
18 end
19 # ...
20end
我們透過將每一條訊息(messaging
)取出來,然後將接收者設定為發送者(sender_id
)並把訊息內容前面加上 PONG:
用來區別,確保確實是經過我們的 Chatbot 處理後才回應的。
PING/PONG 跟 Hello World 都有點像是一個習慣,通常我們用來測試一個伺服器是否有正常運作,就會透過發送 PING 然後確認伺服器有回應 PONG 來當判斷,如果想換成任何想要的訊息都是沒問題的。
小結
其實這些步驟在大多數情況應該被製作成一個 Gem (Ruby 的套件,可以想像成 Mod 之類的東西)直接使用,不過最近比較忙就沒有時間好好設計並且封裝成 Gem。
不過這篇文章的概念在處理各種類型的 Chatbot 是很好用的,如果有興趣的話也蠻推薦大家詳細了解一下。