蒼時弦也
蒼時弦也
資深軟體工程師
發表於

製作一個 Hubot 的噗浪 Adapter

###前言

我似乎非常喜歡搞前言這套,所以請大家聽我慢慢說完吧!

大約是三、四個月前的事情,網友向我邀文,我就告訴他最近 HuBot 更新後,將 Adapter 分離出來,以 Module 的形式載入,我想之後的更新會很棒吧!

不過,我卻拖到前幾天,我才心血來潮的在一天飆出機器人(原因不明,而且還被很多地方卡到陰)

這就是,故事的開始(好,請不要打我!)

(本文以 Deploy 到 Heroku 為最終目標)

###有雷,要先防護一下!

根據我的經驗,我被雷炸死超多次了! (把 Adapter 什麼的寫出來一天一定夠,但是除雷讓我用了一半以上的時間 Orz)

  1. Hubot 除了內建的兩個 Adapter 之外都要以 Node.js 的 Module 方式才能運作(這代表說你一定得放到 node_modules 才會運作)
  2. Hubot 的 bin/hubot 裡面寫著 npm install 所以不管你怎麼改原始碼也不能改變第一點的狀況
  3. 當你 Deploy 到 Heroku 上的時候,不能用 npm link
  4. 在 Heroku 上所有 module 都得用 npm 安裝(package.json內設定)

其實上述都在討論同一件事情: Node.JS 的模組

而且模組不能設定相對路徑之類的來安裝,一定要透過

  • NPM 官方的檔案
  • Git
  • HTTP

上述三種方式才能安裝(說實在的 tgz 也是個雷,雖然說可用 tarball 裝,但是用 tar -zcf 壓縮是裝不了的)

被這些雷到可能是我笨(牆角)

###建立 Adapter

只需要兩個檔案

  • package.json
  • plurk.coffee

有這兩個就足以變成 Node.JS 的模組了~

在開始 Coding 之前,先來設定一下 package.json 弄好相依

因為那個 Heroku 的 Cron Add-on 會把運算花費算到裡面,那就乾脆用 Node.JS 的 cron 模組就好了!而登入噗浪還需要 OAuth 才行,也裝上 OAuth 這樣

Hubot 在讀取非內建模組時,會自動在前面加上 hubot- 的前置。

###建立 Robot 跟 API

基本上程式碼都是參考 Twitter 的 Adapter 來製作,但是實際上竟然只有一樣用 OAuth 這一點而已(昏)

Twitter 有 Streaming 可用而 Plurk 則得用 Comet 方式來達到即時讀取。

 1
 2Robot = require("hubot").robot()
 3Adapter = require("hubto").adapter()
 4
 5EventEmitter = require("events").EventEmitter
 6
 7oauth = require("oauth")
 8cronJob = require("cron").CronJob
 9
10class Plurk exntends Adapter
11
12class PlurkStreaming exnteds EventEmitter

先弄個基本架構,至於為什麼要叫 PlurkStreaming 只是因為參考的是 Twitter 而已(被拖走)

接著先給 Plurk 這個 Class 放進去幾個Method。 (基本上只要有 run, send, reply 就夠了,而 run 用來做初始化的部分)

1
2class Plurk entends Adapter
3  send: (plurk_id, strings…) ->
4  
5  reply: (plurk_id, strings…) ->
6  
7  run: ->
8  

看起來有點東西,來弄主要的 API 結合部分。

 1
 2class PlurkStreaming extends EventEmitter
 3  
 4  consuctor: (options) ->
 5    
 6  plurk: (callback) ->
 7    #觀察河道
 8  getChannel: ->
 9    #取得 Comet 網址
10  reply: (plurk_id, message) ->
11    #回噗
12  acceptFriends: ->
13    #接受好友
14  get: (path, callback) ->
15    #GET 請求
16  post: (path, body, callback)->
17    #POST 請求(其實是裝飾)
18  request: (method, path, body, callback)->
19    #主要的 OAuth 請求
20  comet: (server, callback)->
21    #噗浪的 Comet 傳回是 JavaScript Callback 要另外處理後才會變成 JSON

然後我們先把注意力集中到 constructor 上,先把建構子弄好。

 1
 2  constructor: (options) ->
 3    super()
 4    if options.key? and options.secret? and options.token? and options.token_secret?
 5      @key = options.key
 6      @secret = options.secret
 7      @token = options.token
 8      @token_secret = options.token_secret
 9      #建立 OAuth 連接
10      @consumer = new oauth.OAuth(
11        "https://www.plurk.com/OAuth/request_token",
12        "https://www.plurk.com/OAuth/access_token",
13        @key,
14        @secret,
15        "1.0",
16        "https://www.plurk.com/OAuth/authorize".
17        "HMAC-SHA1"
18      )
19      @domain = "www.plurk.com"
20      #初始化取得Comet網址
21      do @getChannel
22    else
23      throw new Error("參數不足,需要 Key, Secret, Token, Token Secret")

這樣建構子就差不多了!

接著來弄 request 這個 method (comet 很類似,這個寫好複製貼上一下~)

 1
 2  request: (method, path, body, callback) ->
 3    #記錄一下這次的 Request
 4    console.log("https://#{@domain}#{path}")
 5    
 6    # Callback 這邊先不丟進去,要用另一種方式處理
 7    request = @consumer.get("https://#{@domain}#{path}", @token, @token_secret, null)
 8    
 9    request.on "response", (res) ->
10      res.on "data", (chunk) ->
11        parseResponse(chunk+'', callback)
12      res.on "end", (data) ->
13        console.log "End Request: #{path}"
14      res.on "error", (data) ->
15        console.log "Error: " + data
16        
17    request.end()
18    
19    #處理資料
20    parseResponse = (data, callback) ->
21      if data.length > 0
22        #用 Try/Catch 避免處理 JSON 出錯導致整個中斷
23        try
24          callback null, JSON.parse(data)
25        catch err
26          console.log("Error Parse JSON:" + data, err)
27          #繼續執行
28          callback null, data || {}

大致上就是這樣,根據程式碼,其實是無視 POST 的。 (如果沒有特殊需求其實也不會用到 POST 方式)

而 Comet 的處理方式類似,不過我們要動用到 EventEmitter 的功能。 (避免一個 Request 還未結束又開始新的 Comet, 造成連續讀取兩次相同訊息的問題)

 1
 2  comet: (server, callback) ->
 3    #在 Callback 裡面會找不到自身,所以設定區域變數
 4    self = @
 5  
 6    #記錄一下這次的 Request
 7    console.log("[Comet] #{server}")
 8    
 9    # Callback 這邊先不丟進去,要用另一種方式處理
10    request = @consumer.get("https://#{@domain}#{path}", @token, @token_secret, null)
11    
12    request.on "response", (res) ->
13      res.on "data", (chunk) ->
14        parseResponse(chunk+'', callback)
15      res.on "end", (data) ->
16        console.log "End Request: #{path}"
17        #請求結束,發出事件通知可以進行下一次請求
18        self.emit "nextPlurk"
19      res.on "error", (data) ->
20        console.log "Error: " + data
21        
22    request.end()    
23    
24    #處理資料
25    parseResponse = (data, callback) ->
26      if data.length > 0
27        #用 try/catch 避免失敗中斷
28        try
29          #去掉 JavaScript 的 Callback
30          data = data.match(/CometChannel.scriptCallback\((.+)\);\s*/)
31          jsonData = ""
32          
33          if data?
34            jsonData = JSON.parse(data[1])
35          else
36            #如果沒有任何 Match 嘗試直接 parse
37            jsonData = JSON.parse(data)
38        catch err
39          console.log("[Comet] Error:", data, err)
40          
41        #用 Try/Catch 避免處理 JSON 出錯導致整個中斷
42        try
43          #只傳入 json 的 data 部分
44          callback null, jsonData.data
45        catch err
46          console.log("[Comet]Error Parse JSON:" + data, err)
47          #繼續執行
48          callback null, data || {}

至於為什麼要這樣做呢?因為在測試時竟然因為噗浪 Lag 而沒讀到完整的 Comet 資料,然後就炸掉了! (這樣至少不會造成運行中斷,睡覺時就不會碰到一個無法用就炸掉)

後面的 get 跟 post 就簡單多了!

1
2  get: (path, callback) ->
3    @request("GET", path, null, callback)
4    
5  post: (path, body, callback) ->
6    @request("POST", path, body, callback)
7    

接著處理取的 Comet 網址的 getChannel

 1
 2  getChannel: ->
 3    self = @
 4    
 5    @get "/APP/Realtime/getUserChannel", (error, data) ->
 6      if !error
 7        #檢查是否有 comet server
 8        if data.comet_server?
 9          self.channel = data.comet_server
10          #如果沒有 Channel Ready 就嘗試連接會失敗
11          self.emit('channel_ready')

接著處理 plurk 這部份

1  
2  plurk: (callback) ->
3    #其實官方文件是要設定 offset 的,不過目前沒有想到設定的方法,以及即使沒有設定也能正常運作
4    @comet @channel, (error, data) ->
5      if data?
6        #將一筆筆的資料一一遞送
7        for plurk in data
8          callback plurk

最後處理回噗跟接受好友就完成 API 的連接了! (當然,其他部分大家可以自行擴充)

 1
 2  reply: (plurk_id, message) ->
 3    #設定回噗的參數
 4    path = "/APP/Responses/responseAdd?plurk_id=#{plurk_id}&content=" + encodeURIComponent(message) + "&qualifier=says"
 5    @get path, (error, data)->
 6      #啥都不做
 7      
 8  acceptFriends: ->
 9    self = @
10    #用 Cron Module 的時候到了!
11    cronJob "0 0 * * * *", () ->
12      self.get "/APP/Alerts/addAllAsFriends", (error, data) ->
13        console.log("接受所有好友邀請:", data)
14    

那麼,先來處理 Plurk Adaper 好處理的部份

1
2  send: (plruk_id, strings…)->
3    #跟 Reply 一樣,直接交給 reply 做
4    @reply plurk_id, strings
5    
6  reply: (plurk_id, strings…) ->
7    strings.forEach (message) =>
8      @bot.reply(plruk_id, message)

接著把 run 處理好就可以上線運作摟!

 1
 2  run: ->
 3    self = @
 4    options =
 5      key: process.env.HUBOT_PLURK_KEY
 6      secret: process.env.HUBOT_PLURK_SECRET
 7      token: process.env.HUBOT_PLURK_TOKEN
 8      token_secret: process.env.HUBOT_PLURK_TOKEN_SECRET
 9      
10    #創建剛剛的 API
11    bot = new PlurkStreaming(options) 
12    
13    #依照 Twitter 的 new Robot.TextMessage 會沒有反應,所以參考 hubot-minecraft 的方式
14    r = @robot.constructor
15    
16    #處理噗浪河道訊息
17    @doPlurk = (data)->
18      #檢查是否為回噗
19      if data.response?
20        data.content_raw = data.response.content_raw
21        data.user_id = data.response.user_id
22      #確定有噗浪ID跟訊息
23      if data.plurk_id? and data.content_raw
24        self.receive new r.TextMessage(data.plurk_id, data.content_raw)
25    
26    #取得 Comet Server 完成,開始第一次 Comet 連接
27    bot.on "channel_ready", () ->
28      bot.plurk self.doPlurk
29    
30    #上一次 Comet 完成,繼續 Polling
31    bot.on "nextPlurk", ()->
32      bot.plurk self.doPlurk
33    
34    #定時接受好友邀請
35    do bot.acceptFriends
36    
37    @bot = bot

終於,完成 Adapter!

接下來簡單提醒一下 Deploy 到 Heroku 的注意事項。

  1. hubot-plurk 已經被我佔在 npm 上了,如果想丟到 npm 安裝可能不能用這個名字
  2. 用 npm pack 就會產生 hubot-plurk-0.x.x.tgz 的檔案,丟到 Dropbox 之類的網站後,在 package.json 相依版本的地方設定這個網址就可以安裝(不用透過 npm )
  3. Procfile 裡面的 web: 建議改成 worker: 因為用 web dyno 會因為沒有人打開頁面而暫停運作(要持續運作得用 worker 但會犧牲掉 Hubot 內建的網頁功能)
  4. 在下載下來的 Hubot 用 make package 指令就可以產生 deploy 用的資料夾
  5. scripts 資料夾內是互動部分,不需要像 Adapter 如此大費周章處理(新增檔案並且設計好對白,之後就會回噗了~)

我開發用的機器人在此,大家可以去跟他玩玩 https://plurk.com/elct9620_bot