LV. 15
GP 20

【閒聊】用 Ruby on Rails 串接哈哈姆特

樓主 蒼時弦也 abr0616
GP7 BP-

作者標示-非商業性

本授權條款允許使用者重製、散布、傳輸以及修改著作,但不得為商業目的之使用。使用時必須按照著作人指定的方式表彰其姓名。

看到大家都在分享教學文,所以趕快寫一篇壓壓驚。

附註:哈拉版的支援排版不太適合讀技術文章,可以到我的部落格看比較好讀的版本

目前跟愉悅的小夥伴們正在開發手遊聊天機器人,之前一些東西還是測試中所以隨便套了圖片素材,最近終於可以可以貼出來給大家看看。





跟大多數我們熟悉串接 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)來處理。

Rails.application.routes.draw do
# ...
 
post :bahamut, to: 'webhook#bahamut'
end

透過 Ruby 的 DSL 特性,我們就可以定義出一個叫做 /bahamut 的位址,用來接收巴哈姆特的 Webhook。然後在上面定義要使用 Webhook 控制器上面的 #bahamut 方法來處理這個位址的動作。

# app/controllers/webhook_controller.rb
class WebhookController < ActionController::API
  def bahamut
    # TODO: Implement Chatbot Handler
    render plain: 'Hello World'
  end
end

在這邊我們可能需要下一點指令才能測試,或者你可以使用 Postman 這套軟體來模擬 POST 請求。

POST 請求一般是我們送出表單的操作,所以無法直接用打開網頁的方式開啟。


然後我們就能看到我們的終端機(Terminal)出現了 Hello World 字樣。

如果我們希望巴哈能發送訊息到我們自己的本機電腦(localhost)就必須讓我們的電腦能在網路上被找到,這可以利用 Ngrok 這套軟體達成,透過 Ngrok 我們可以得到一個暫時性的網址,如此一來就能將本機測試的網站被巴哈呼叫到。

我想大家可能有疑問,就是是不是一定要用 Ruby on Rails 才能做到,實際上因為 Ruby on Rails 對初學者來說是最容易搭建出網站的選項,才會選擇使用。不然只要是任何能處理網的程式語言,都是可以直接用來寫 Chatbot 的,只不過像是 Ruby on Rails 這類網站開發框架,能幫我們省下學習這些基礎知識的時間。

驗證 Signature


因為處理簽章(Signature)的機制比較複雜,在物件導向類型的語言中,我們會設計一個 Class 來專門處理這件事情。

所以我們來製作一個服務物件(Service Object)叫做 Signature Verifer (簽章驗證器)來專們針對巴哈姆特傳入的簽章做驗證。

# app/services/signature_verifer.rb

class SignatureVerifer
  def initialize(request)
    @request = request
    # 讀取內容
    @body = @request.body.read
    # 讀取 Signature Header
    @signature = request.headers['x-baha-data-signature']

    # 把內容退回開頭(避免其他人讀取不到資料)
    @request.body.rewind
  end
end

第一個步驟我們要設計驗證器的「初始化(Initialize)」階段要做什麼,我們預期會收到一個 HTTP 請求(request)然後將裡面的簽章(x-baha-data-signature)取出來,以及內容(對話訊息)取出來,這是我們在前面提到驗證是否是由巴哈發出的訊息所需要的資訊。

# app/services/signature_verifer.rb

class SignatureVerifer
  # ...
  
  private
  
  def verify_signature
    @verify_signature ||=
      "sha1=#{OpenSSL::HMAC.hexdigest('SHA1', ENV['BAHA_SECRET'], @body)}"
  end
end

這個步驟是根據巴哈的文件將剛剛抓到的訊息跟聊天機器人的 Secret(秘鑰)做 SHA1 運算產生出我們自己計算的簽章,如此一來跟巴哈提供的比對,就會知道內容是不是一樣沒有被人偷偷竄改。

要特別注意的是 ENV['BAHA_SECRET'] 這邊我是使用「環境變數」來儲存密鑰,這樣只有安裝伺服器的人會知道,就可以避面將這類敏感資訊放到程式碼之中。 在 Rails 5 之後,我們可以用 rails credentials:edit 這個指令編輯一個加密的檔案,並把密鑰放到裡面,使用方法可以參考 Ruby on Rails 文件

class SignatureVerifer
  # ...
  
  def valid?
      @signature == verify_signature
  end
  
  private
  
  # ...
end

最後再提供一個 valid? 方法,用來讓我們查詢是否正確就可以了!

我們修改一下 WebhookController 來做一個簡單的檢查。

class WebhookController < ActionController::API
  def bahamut
    return unathorized_error unless valid_signature?
    # TODO: Implement Chatbot Handler
    render plain: 'Hello World'
  end
  
  private
  
  def valid_signature?
    SignatureVerifer.new(request).valid?
  end
  
  def unauthorized_error
    render json: { error: 'Unauthorized' }, status: :unauthorized
  end
end

假設我們透過 SignatureVerifer 驗證失敗的話,就回傳一個 JSON 資訊表示未驗證,並且設定 HTTP 的狀態碼為 401(未授權)的狀態。

JSON 是一種資料格式,常常用在不同伺服器溝同時當作交歡資料的格式,我們從巴哈收到的訊息也是 JSON 格式。

發送回應


既然我們已經可以接收訊息,如果使用者都沒有辦法收到任何回應的話肯定會覺得奇怪,所以下一步就是要能發送訊息給使用者。

哈哈姆特目前支援文字、圖片、貼圖跟事件幾種類型,其中事件是最容易做的,打好基底後也會變得更容易修改成支援其他類型的發送程式。

我們先來看一下從巴哈接收到的訊息會是怎樣的格式(JSON)

{
"botid":<BOT_ID>,
"time":1512353744843,
"messaging":[
   {
     "sender_id":<SENDER_ID>,
     "message":{
       "text":"Hello~"
     }
   }
]
}

我們需要關注的只有 messaging 區塊的部分,裡面描述了「多個訊息」而每個訊息都會有「發送者」和「內容」兩個資訊。在上面這從官方文件複製的訊息範例中,使用者發送的內容是一段「文字(text)」

在 Rails 接收到之後,會自動的做好 JSON 解析的處理,所以我們可以直接像這樣使用。

# 照每一個訊息處理
params['messaging'].each do |message|
# 解析訊息跟回覆
end

在開始處理之前,我們需要先能夠發送訊息到哈哈姆特。因為步驟也是比較多的,所以我們需要製作一個 Sender (發送器)物件來處理。

require 'net/http'

# app/services/text_sender.rb
class TextSender
  def initialize(recipient, message)
    @receipient = receipient
    @message = message
  end
end

首先我們在初始化階段要把「接收者」跟想要發送出去的「訊息」記錄起來。

require 'net/http'

# app/services/text_sender.rb
class TextSender
             "messagePush?access_token=#{ENV['BAHA_TOKEN']}"
  # ...
 
  def perform
    # 發送訊息
  end
 
  def uri
    @uri ||= URI(ENDPOINT)
  end
  
  def ssl?
    uri.scheme == 'https'
  end
 
  private
  
  def request
    # 製作一個 HTTP 請求
  end
end

接下來我們將巴哈文件上所提供的位置,以及一些發送請求需要的一些資訊製作出來。

像是 URI 這類轉換是用於 Ruby 處理發送 HTTP 請求所需要的,所以我們都先做好處理方便使用。而 ENV['BAHA_TOKEN'] 跟前面的 ENV['BAHA_SECRET'] 用途是一樣的,都是需要避免直接寫在程式內的數值。

require 'net/http'

# app/services/text_sender.rb
class TextSender
  # ...
  
  private
  
  def request
    return @request if @request.present?

    # 產生一個 HTTP Post 請求
    @request = Net::HTTP::Post.new(uri)
    # 使用 JSON 格式(指定內容類型)
    @request['Content-Type'] = 'application/json'
    # 把要傳輸的內容轉換成 JSON 格式的資料
    @request.body = body.to_json
    @request
  end
end

因為我們要將訊息發給巴哈,巴哈再將訊息發給指定的使用者。

如果是 LINE 或者 Facebook Messenger 我們想對同一個人發訊息,在不同的 Chatbot 有不同的編號(ID)這樣就可以保護使用者不會被沒有授權的 Chatbot 騷擾,所以不論是發送還是接收,都需要透過巴哈的伺服器。

require 'net/http'

# app/services/text_sender.rb
class TextSender
  # ...
  
  def perform
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = ssl?
    # TODO: 處理回應
    # 發送請求給巴哈
    http.request(request)
  end

  def body
    {
      recipient: {
        id: @recipient
      },
      message: {
        text: @message
      }
    }
  end
  
  private
  
  # ...
end

最後我們只需要將請求的內容(文字訊息)定義好,然後讓他可以發送出去,我們就能對使用者發送回應。

這邊的 recipient 通常會是我們收到的 sender_id

我們可以在 Rails Console 裡面像這樣簡單測試是否可以發送訊息

TextSender.new('巴哈帳號', 'Hello!')

自動回應相同訊息


我們將前面的程式整合後,可以改寫 WebhookController 讓他可以自動回應跟使用者相同的訊息。

class WebhookController < ActionController::API
  def bahamut
    return unathorized_error unless valid_signature?
    
    process_messages
    render json: { message: 'OK' }, status: :ok
  end
  
  private
  
  def process_messages
    params['messaging'].each do |message|
      TextSender.new(
        message['sender_id'],
        "PONG: #{message['message']['text']}"
      )
    end
  end
  # ...
end

我們透過將每一條訊息(messaging)取出來,然後將接收者設定為發送者(sender_id)並把訊息內容前面加上 PONG: 用來區別,確保確實是經過我們的 Chatbot 處理後才回應的。

PING/PONG 跟 Hello World 都有點像是一個習慣,通常我們用來測試一個伺服器是否有正常運作,就會透過發送 PING 然後確認伺服器有回應 PONG 來當判斷,如果想換成任何想要的訊息都是沒問題的。

小結


其實這些步驟在大多數情況應該被製作成一個 Gem (Ruby 的套件,可以想像成 Mod 之類的東西)直接使用,不過最近比較忙就沒有時間好好設計並且封裝成 Gem。

不過這篇文章的概念在處理各種類型的 Chatbot 是很好用的,如果有興趣的話也蠻推薦大家詳細了解一下。
7
-
未登入的勇者,要加入 2 樓的討論嗎?
板務人員:

0 筆精華,吸收日月精華中
一個月內新增 0
歡迎加入共同維護。


face基於日前微軟官方表示 Internet Explorer 不再支援新的網路標準,可能無法使用新的應用程式來呈現網站內容,在瀏覽器支援度及網站安全性的雙重考量下,為了讓巴友們有更好的使用體驗,巴哈姆特即將於 2019年9月2日 停止支援 Internet Explorer 瀏覽器的頁面呈現和功能。
屆時建議您使用下述瀏覽器來瀏覽巴哈姆特:
。Google Chrome(推薦)
。Mozilla Firefox
。Microsoft Edge(Windows10以上的作業系統版本才可使用)

face我們了解您不想看到廣告的心情⋯ 若您願意支持巴哈姆特永續經營,請將 gamer.com.tw 加入廣告阻擋工具的白名單中,謝謝 !【教學】