File: src/live/CommentProvider.coffee
###*
# 放送中の番組のコメントの取得と投稿を行うクラスです。
# @class CommentProvider
###
_ = require "lodash"
Cheerio = require "cheerio"
deepFreeze = require "deep-freeze"
Request = require "request-promise"
{Socket} = require "net"
{sprintf} = require "sprintf"
Emitter = require "../Emitter"
NicoUrl = require "../NicoURL"
NicoException = require "../NicoException"
NicoLiveComment = require "./NicoLiveComment"
chatResults = deepFreeze
SUCCESS : 0
CONTINUOUS_POST : 1
THREAD_ID_ERROR : 2
TICKET_ERROR : 3
DIFFERENT_POSTKEY : 4
_DIFFERENT_POSTKEY : 8
LOCKED : 5
COMMANDS =
connect : _.template """
<thread thread="<%- thread %>" version="20061206"
res_from="-<%- firstGetComments %>"/>
"""
post : _.template """
<chat thread="<%-threadId%>" ticket="<%-ticket%>"
postkey="<%-postKey%>" mail="<%-command%>" user_id="<%-userId%>"
premium="<%-isPremium%>"><%-comment%></chat>
"""
module.exports =
class CommentProvider extends Emitter
@ChatResult : chatResults
###*
# @param {NicoLiveInfo} liveInfo
# @return {Promise}
###
@instanceFor : (liveInfo) ->
unless liveInfo?
throw new TypeError("liveInfo must be instance of NicoLiveInfo")
Promise.resolve new CommentProvider(liveInfo)
###*
# @private
# @propery {NicoLiveInfo} _live
###
_live : null
###*
# @private
# @propery {net.Socket} _socket
###
_socket : null
###*
# @private
# @propery {Object} _postInfo
###
_postInfo : null
# ticket : null
# postKey : null
# threadId : null
###*
# @property {Boolean} isFirstResponseProsessed
###
isFirstResponseProsessed : false
###*
# @constructor
# @param {NicoLiveInfo} _live
###
constructor : (@_live) ->
super
@isFirstResponseProsessed = false
@_postInfo =
ticket : null
postKey : null
threadId : null
###*
# このインスタンスが保持しているNicoLiveInfoオブジェクトを取得します。
# @method getLiveInfo
# @return {NicoLiveInfo}
###
getLiveInfo : ->
return @_live
###*
# @private
# @method _canContinue
###
_canContinue : ->
if @disposed
throw new Error("CommentProvider has been disposed")
return
###*
# [Method for testing] Stream given xml data as socket received data.
# @private
# @method _pourXMLData
# @param {String} xml
###
_pourXMLData : (xml) ->
@_didReceiveData(xml)
###*
# コメントサーバーへ接続します。
#
# 既に接続済みの場合は接続を行いません。
# 再接続する場合は `CommentProvider#reconnect`を利用してください。
#
# @method connect
# @fires CommentProvider#did-connect
# @param {Object} [options]
# @param {Number} [options.firstGetComments=100] 接続時に取得するコメント数
# @param {Number} [options.timeoutMs=5000] タイムアウトまでのミリ秒
# @return {Promise}
###
connect : (options = {}) ->
@_canContinue()
return Promise.resolve(@) if @_socket?
serverInfo = @_live.get "comment"
options = _.defaults {}, options,
firstGetComments: 100
timeoutMs : 5000
new Promise (resolve, reject) =>
timerId = null
@_socket = new Socket
# @once "receive", @_threadInfoDetector
@_socket
.once "connect", =>
@once "_did-receive-connection-response", =>
clearTimeout timerId
resolve(@)
return
# Send thread information
params = _.assign({}, {firstGetComments: options.firstGetComments}, serverInfo)
@_socket.write COMMANDS.connect(params) + '\0'
return
.on "data", @_didReceiveData.bind(@)
.on "error", @_didErrorOnSocket.bind(@)
.on "close", @_didCloseSocket.bind(@)
@_socket.connect
host : serverInfo.addr
port : serverInfo.port
timerId = setTimeout =>
reject new Error("[CommentProvider: #{@_live.id}] Connection timed out.")
return
, options.timeoutMs
###*
# @method reconnect
# @param {Object} options 接続設定(connectメソッドと同じ)
# @return {Promise}
###
reconnect : (options) ->
@_canContinue()
@_socket.destroy() if @_socket?
@_socket = null
@connect()
###*
# コメントサーバから切断します。
# @method disconnect
# @fires CommentProvider#did-disconnect
####
disconnect : ->
@_canContinue()
return unless @_socket?
@_socket.removeAllListeners()
@_socket.destroy()
@_socket = null
@emit "did-close-connection"
return
###*
# APIからpostkeyを取得します。
# @private
# @method _ferchPostKey
# @return {Promise}
###
_fetchPostKey : ->
@_canContinue()
threadId = @_live.get("comment.thread")
url = sprintf NicoUrl.Live.GET_POSTKEY, threadId
postKey = ""
# retry = if _.isNumber(retry) then Math.min(Math.abs(retry), 5) else 5
Request.get
resolveWithFullResponse : true
url : url
jar : @_live._session.cookie
.then (res) =>
if res.statusCode is 200
# 正常に通信できた時
postKey = /^postkey=(.*)\s*/.exec res.body
postKey = postKey[1] if postKey?
if postKey isnt ""
# ポストキーがちゃんと取得できれば
@_postInfo.postKey = postKey
Promise.resolve postKey
else
Promise.reject new Error("Failed to fetch post key")
###*
# コメントを投稿します。
# @method postComment
# @param {String} msg 投稿するコメント
# @param {String|Array.<String>} [command] コマンド(184, bigなど)
# @param {Number} [timeoutMs]
# @return {Promise}
###
postComment : (msg, command = "", timeoutMs = 3000) ->
@_canContinue()
if typeof msg isnt "string" || msg.replace(/\s/g, "") is ""
return Promise.reject new Error("Can not post empty comment")
unless @_socket?
return Promise.reject new Error("No connected to the comment server.")
command = command.join(" ") if Array.isArray(command)
@_fetchPostKey().then =>
defer = Promise.defer()
timerId = null
postInfo =
userId : @_live.get("user.id")
isPremium : @_live.get("user.isPremium")|0
comment : msg
command : command
threadId : @_postInfo.threadId
postKey : @_postInfo.postKey
ticket : @_postInfo.ticket
disposer = @_onDidReceivePostResult ({status}) ->
disposer.dispose()
clearTimeout timerId
switch status
when chatResults.SUCCESS
defer.resolve()
when chatResults.THREAD_ID_ERROR
defer.reject new NicoException
message : "Failed to post comment. (reason: thread id error)"
code : status
when chatResults.TICKET_ERROR
defer.reject new NicoException
message : "Failed to post comment. (reason: ticket error)"
code : status
when chatResults.DIFFERENT_POSTKEY, chatResults._DIFFERENT_POSTKEY
defer.reject new NicoException
message : "Failed to post comment. (reason: postkey is defferent)"
code : status
when chatResults.LOCKED
defer.reject new NicoException
message : "Your posting has been locked."
code : status
when chatResults.CONTINUOUS_POST
defer.reject new NicoException
message : "Can not post continuous the same comment."
code : status
else
defer.reject new NicoException
message : "Failed to post comment. (status: #{status})"
code : status
return
timerId = setTimeout ->
disposer.dispose()
defer.reject new Error("Post result response is timed out.")
, timeoutMs
@_socket.write COMMANDS.post(postInfo) + "\0"
defer.promise
###*
# インスタンスを破棄します。
# @method dispose
###
dispose : ->
@_live = null
@_postInfo = null
@disconnect()
super
#
# Event Listeners
#
###*
# コメント受信処理
# @private
# @method _didReceiveData
# @param {String} xml
###
_didReceiveData : (xml) ->
@emit "did-receive-data", xml
comments = []
$elements = Cheerio.load(xml)(":root")
$elements.each (i, element) =>
$element = Cheerio(element)
switch element.name
when "thread"
# Did receive first connection response
@_postInfo.ticket = $element.attr "ticket"
@emit "_did-receive-connection-response"
# console.info "CommentProvider[%s]: Receive thread info", @_live.get("id")
when "chat"
comment = NicoLiveComment.fromRawXml($element.toString(), @_live.get("user.id"))
comments.push comment
@emit "did-receive-comment", comment
# 配信終了通知が来たら切断
if comment.isPostByDistributor() and comment.comment is "/disconnect"
@emit "did-end-live", @_live
@disconnect()
when "chat_result"
# Did receive post result
status = $element.attr "status"
status = status | 0
comment = NicoLiveComment.fromRawXml($element.find("chat").toString(), @_live.get("user.id"))
@emit "did-receive-post-result", {status}
@emit "did-receive-comment", comment
return
if @isFirstResponseProsessed is no
@isFirstResponseProsessed = yes
@emit "did-process-first-response", comments
return
###*
# コネクション上のエラー処理
# @private
# @method _didErrorOnSocket
###
_didErrorOnSocket : (error) ->
@emit "did-error", error
return
###*
# コネクションが閉じられた時の処理
# @private
# @method _didCloseSocket
###
_didCloseSocket : (hadError) ->
if hadError
@emit "error", "Connection closing error (unknown)"
@emit "did-close-connection"
return
###*
# コメントサーバのスレッドID変更を監視するリスナ
# @private
# @method _didRefreshLiveInfo
###
_didRefreshLiveInfo : ->
# 時々threadIdが変わるのでその変化を監視
@_postInfo.threadId = @_live.get("comment").thread
return
#
# Event Handlers
#
###*
# @private
# @event CommentProvider#did-receive-post-result
# @param {Number} status
###
###*
# @private
# @method _onDidReceivePostResult
# @param {Function} listener
# @return {Disposable}
###
_onDidReceivePostResult : (listener) ->
@on "did-receive-post-result", listener
###*
# Fire on received and processed thread info and comments first
# @event CommentProvider#did-process-first-response
# @param {Array.<NicoLiveComment>}
###
###*
# @method onDidProcessFirstResponse
# @param {Function} listener
# @return {Disposable}
###
onDidProcessFirstResponse : (listener) ->
@on "did-process-first-response", listener
###*
# Fire on raw response received
# @event CommentProvider#did-receive-data
# @params {String} data
###
###*
# @method onDidReceiveData
# @param {Function} listener
# @return {Disposable}
###
onDidReceiveData : (listener) ->
@on "did-receive-data", listener
###*
# Fire on comment received
# @event CommentProvider#did-receive-comment
# @params {NicoLiveComment} comment
###
###*
# @method onDidReceiveComment
# @param {Function} listener
# @return {Disposable}
###
onDidReceiveComment : (listener) ->
@on "did-receive-comment", listener
###*
# Fire on error raised on Connection
# @event CommentProvider#did-error
# @params {Error} error
###
###*
# @method onDidError
# @param {Function} listener
# @return {Disposable}
###
onDidError : (listener) ->
@on "did-error", listener
###*
# Fire on connection closed
# @event CommentProvider#did-close-connection
###
###*
# @method onDidCloseConnection
# @param {Function} listener
# @return {Disposable}
###
onDidCloseConnection : (listener) ->
@on "did-close-connection", listener
###*
# Fire on live ended
# @event CommentProvider#did-end-live
###
###*
# @method onDidEndLive
# @param {Function} listener
# @return {Disposable}
###
onDidEndLive : (listener) ->
@on "did-end-live", listener