製作一個 Hubot 的噗浪 Adapter
###前言
我似乎非常喜歡搞前言這套,所以請大家聽我慢慢說完吧!
大約是三、四個月前的事情,網友向我邀文,我就告訴他最近 HuBot 更新後,將 Adapter 分離出來,以 Module 的形式載入,我想之後的更新會很棒吧!
不過,我卻拖到前幾天,我才心血來潮的在一天飆出機器人(原因不明,而且還被很多地方卡到陰)
這就是,故事的開始(好,請不要打我!)
(本文以 Deploy 到 Heroku 為最終目標)
###有雷,要先防護一下!
根據我的經驗,我被雷炸死超多次了! (把 Adapter 什麼的寫出來一天一定夠,但是除雷讓我用了一半以上的時間 Orz)
- Hubot 除了內建的兩個 Adapter 之外都要以 Node.js 的 Module 方式才能運作(這代表說你一定得放到 node_modules 才會運作)
- Hubot 的 bin/hubot 裡面寫著 npm install 所以不管你怎麼改原始碼也不能改變第一點的狀況
- 當你 Deploy 到 Heroku 上的時候,不能用 npm link
- 在 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 的注意事項。
- hubot-plurk 已經被我佔在 npm 上了,如果想丟到 npm 安裝可能不能用這個名字
- 用 npm pack 就會產生 hubot-plurk-0.x.x.tgz 的檔案,丟到 Dropbox 之類的網站後,在 package.json 相依版本的地方設定這個網址就可以安裝(不用透過 npm )
- Procfile 裡面的 web: 建議改成 worker: 因為用 web dyno 會因為沒有人打開頁面而暫停運作(要持續運作得用 worker 但會犧牲掉 Hubot 內建的網頁功能)
- 在下載下來的 Hubot 用 make package 指令就可以產生 deploy 用的資料夾
- scripts 資料夾內是互動部分,不需要像 Adapter 如此大費周章處理(新增檔案並且設計好對白,之後就會回噗了~)
我開發用的機器人在此,大家可以去跟他玩玩 https://plurk.com/elct9620_bot