├── .gitignore ├── package.json ├── test.js ├── lib ├── poll.js ├── credential.js ├── raffle.js ├── utils.js └── channel.js ├── index.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hitbox-chat", 3 | "version": "0.1.5", 4 | "description": "A chat client library for hitbox.tv", 5 | "author": "tsholmes", 6 | "license": "BSD-2-Clause", 7 | "engines": [ 8 | "node >= 0.10.0" 9 | ], 10 | "dependencies": { 11 | "socket.io-client": "~0.9.16", 12 | "request": "~2.40.0" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "http://github.com/tsholmes/hitbox-chat.git" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | 2 | var HitboxChatClient = require("./"); 3 | var readline = require("readline"); 4 | 5 | var rl = readline.createInterface({ 6 | input: process.stdin, 7 | output: process.stdout 8 | }); 9 | 10 | rl.question("channel: ", function(answer) { 11 | var client = new HitboxChatClient().on("connect", function() { 12 | var channel = client.joinChannel(answer); 13 | channel.on("login", function(name, role) { 14 | console.log(name, role); 15 | }).on("chat", function(name,text,role) { 16 | console.log(name + ": " + text); 17 | }).on("motd", function(text) { 18 | console.log("=== " + text + " ==="); 19 | }).on("slow", function(slowTime) { 20 | console.log("*** Slow mode: " + slowTime + "s ***"); 21 | }).on("info", function(text) { 22 | console.log("--- " + text + " ---"); 23 | }).on("poll", function(poll) { 24 | console.log("??? " + poll.question + " " + JSON.stringify(poll.choices)); 25 | }).on("raffle", function(raffle) { 26 | console.log("!!! " + raffle.question + " " + JSON.stringify(raffle.choices)); 27 | }).on("other", function(method,params) { 28 | console.log(method, params); 29 | }); 30 | }); 31 | rl.close(); 32 | }); 33 | 34 | -------------------------------------------------------------------------------- /lib/poll.js: -------------------------------------------------------------------------------- 1 | 2 | var utils = require("./utils"); 3 | 4 | function HitboxPoll(channel, params) { 5 | if (!utils.isa(this,HitboxPoll)) return new HitboxPoll(channel, params); 6 | 7 | this.channel = channel; 8 | 9 | this.onmessage(params); 10 | } 11 | HitboxPoll.prototype = { 12 | // internal update methods 13 | onmessage: function(params) { 14 | var notify = {}; 15 | 16 | this.startTime = (params.start_time ? new Date(params.start_time) : this.startTime) || new Date(); 17 | 18 | if (this.status == "started" && params.status == "paused") { 19 | notify.pause = true; 20 | } else if (this.status == "paused" && params.status == "started") { 21 | notify.start = true; 22 | } else if (this.status == "paused" && params.status == "ended") { 23 | notify.end = true; 24 | } 25 | this.status = params.status; // always sent 26 | 27 | this.question = params.question || this.question || ""; 28 | this.choices = params.choices || this.choices || []; 29 | for (var i = 0; i < this.choices.length; i++) { 30 | this.choices[i].votes = Number(this.choices[i].votes); 31 | } 32 | this.voters = params.voters || this.voters || []; 33 | 34 | if (params.votes && params.votes != this.votes) { 35 | notify.vote = true; 36 | } 37 | this.votes = params.votes || this.votes || 0; 38 | 39 | for (var not in notify) { 40 | this.emit(not); 41 | } 42 | }, 43 | // external API functions 44 | vote: function(choice) { 45 | if (this.status != "started") { 46 | throw "Poll is not currently running"; 47 | } 48 | choice = Number(choice); 49 | if (!(choice >= 0 && choice < this.choices.length)) { 50 | throw "Invalid choice"; 51 | } 52 | this.channel.send("votePoll", { choice: choice.toString() }); 53 | } 54 | }; 55 | utils.mixin(HitboxPoll, utils.emitter); 56 | 57 | module.exports = HitboxPoll; 58 | -------------------------------------------------------------------------------- /lib/credential.js: -------------------------------------------------------------------------------- 1 | 2 | var utils = require("./utils"); 3 | 4 | function Credential() { 5 | if (!utils.isa(this, Credential)) { 6 | throw "Cannot instantiate abstract Credential"; 7 | } 8 | this.ready = false; 9 | this.waiters = []; 10 | } 11 | Credential.prototype = { 12 | markReady: function() { 13 | this.ready = true; 14 | this.consumeQueue(); 15 | }, 16 | consumeQueue: function() { 17 | var callback; 18 | while (callback = this.waiters.shift()) { 19 | callback(this.name, this.token); 20 | } 21 | }, 22 | withCredential: function(callback) { 23 | this.waiters.push(callback); 24 | if (this.ready) { 25 | this.consumeQueue(); 26 | } 27 | } 28 | }; 29 | 30 | function ImmediateCredential(name, token) { 31 | if (!utils.isa(this, ImmediateCredential)) return new ImmediateCredential(name, token); 32 | utils.super(this, ImmediateCredential); 33 | 34 | this.name = name; 35 | this.token = token; 36 | 37 | this.markReady(); 38 | } 39 | utils.extend(ImmediateCredential, Credential); 40 | 41 | function DummyCredential() { 42 | if (!utils.isa(this, DummyCredential)) return new DummyCredential(); 43 | utils.super(this, DummyCredential, "UnknownSoldier", null); 44 | } 45 | utils.extend(DummyCredential, ImmediateCredential); 46 | 47 | function UserPassCredential(name, password) { 48 | if (!utils.isa(this, UserPassCredential)) return new UserPassCredential(name, password); 49 | utils.super(this, UserPassCredential); 50 | 51 | this.name = name; 52 | 53 | var t = this; 54 | utils.safePost("https://api.hitbox.tv/auth/token", { 55 | login: name, 56 | pass: password, 57 | app: "desktop" 58 | }, function(data) { 59 | data = JSON.parse(data); 60 | t.token = data.authToken; 61 | t.markReady(); 62 | }); 63 | } 64 | utils.extend(UserPassCredential, Credential); 65 | 66 | module.exports = { 67 | Immediate: ImmediateCredential, 68 | Dummy: DummyCredential, 69 | UserPass: UserPassCredential 70 | }; 71 | -------------------------------------------------------------------------------- /lib/raffle.js: -------------------------------------------------------------------------------- 1 | 2 | var utils = require("./utils"); 3 | 4 | function HitboxRaffle(channel, params) { 5 | if (!utils.isa(this,HitboxRaffle)) return new HitboxRaffle(channel, params); 6 | 7 | this.channel = channel; 8 | 9 | this.onmessage(params); 10 | } 11 | HitboxRaffle.prototype = { 12 | // internal update methods 13 | onmessage: function(params) { 14 | var notify = {}; 15 | 16 | this.admin = params.forAdmin || false; 17 | this.question = params.question || this.question || ""; 18 | this.choices = params.choices || this.choices || []; 19 | this.startTime = (params.start_time ? new Date(params.start_time) : this.startTime) || new Date(); 20 | 21 | if (this.status == "started" && params.status == "started") { 22 | notify.vote = true; 23 | } else if (this.status != "started" && params.status == "started") { 24 | notify.start = true; 25 | } else if (this.status != "paused" && params.status == "paused") { 26 | notify.pause = true; 27 | } else if (this.status != "ended" && params.status == "ended") { 28 | notify.end = true; 29 | } else if (this.status != "hidden" && params.status == "hidden") { 30 | notify.hide = true; 31 | } else if (this.status != "deleted" && params.status == "deleted") { 32 | notify.delete = true; 33 | } 34 | this.status = params.status; 35 | 36 | for (var not in notify) { 37 | this.emit(not); 38 | } 39 | }, 40 | onwinner: function(params) { 41 | if (params.forAdmin) { 42 | this.emit("winner", params.winner_name, params.winner_email); 43 | } else { 44 | this.emit("win"); 45 | } 46 | }, 47 | // external API functions 48 | vote: function(choice) { 49 | if (this.status != "started") { 50 | throw "Raffle is not currently running"; 51 | } 52 | choice = Number(choice); 53 | if(!(choice >= 0 && choice < this.choices.length)) { 54 | throw "Invalid choice"; 55 | } 56 | this.channel.send("voteRaffle", { choice: choice.toString() }); 57 | } 58 | }; 59 | utils.mixin(HitboxRaffle, utils.emitter); 60 | 61 | module.exports = HitboxRaffle; 62 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 2 | var request = require("request"); 3 | var zlib = require("zlib"); 4 | 5 | utils = module.exports = { 6 | mixin: function(func, mix) { 7 | for (var method in mix) { 8 | func.prototype[method] = mix[method]; 9 | } 10 | }, 11 | extend: function(func, parent) { 12 | for (var method in parent.prototype) { 13 | func.prototype[method] = parent.prototype[method]; 14 | } 15 | func.prototype.super = parent; 16 | }, 17 | isa: function(t,clazz) { 18 | if (t.__proto__ == clazz.prototype) return true; 19 | var cur = t.__proto__.super; 20 | while (cur) { 21 | if (cur == clazz) return true; 22 | cur = cur.prototype.super; 23 | } 24 | return false; 25 | }, 26 | super: function(t,clazz) { 27 | clazz.prototype.super.apply(t,Array.prototype.slice.call(arguments, 2)); 28 | }, 29 | emitter: { 30 | on: function(type, callback) { 31 | if (!this._callbacks) this._callbacks = {}; 32 | if (!(type in this._callbacks)) { 33 | this._callbacks[type] = []; 34 | } 35 | this._callbacks[type].push(callback); 36 | return this; 37 | }, 38 | emit: function(type) { 39 | if (!this._callbacks) this._callbacks = {}; 40 | var args = Array.prototype.slice.call(arguments); 41 | args.shift(); 42 | if (type in this._callbacks) { 43 | var cbs = this._callbacks[type]; 44 | for (var i = 0; i < cbs.length; i++) { 45 | cbs[i].apply(this, args); 46 | } 47 | } 48 | } 49 | }, 50 | safeRequest: function(opts, callback) { 51 | opts.encoding = null; 52 | request(opts, function(_,res,body){ 53 | var encoding = res.headers["content-encoding"] || ""; 54 | if (~encoding.indexOf("gzip") || ~encoding.indexOf("deflate")) { 55 | zlib.unzip(body, function(_,data){callback(data.toString());}); 56 | } else { 57 | callback(body.toString()); 58 | } 59 | }); 60 | }, 61 | safeGet: function(url, callback) { 62 | utils.safeRequest({url:url}, callback); 63 | }, 64 | safePost: function(url, form, callback) { 65 | utils.safeRequest({url:url, method: "POST", form: form}, callback); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 2 | var socketio_client = require("socket.io-client"); 3 | var utils = require("./lib/utils"); 4 | var credential = require("./lib/credential"); 5 | var HitboxChannel = require("./lib/channel"); 6 | 7 | function HitboxChatClient(opts) { 8 | if (!utils.isa(this, HitboxChatClient)) return new HitboxChatClient(opts); 9 | 10 | opts = opts || {}; 11 | 12 | if (opts.name && opts.token) { 13 | this.credential = new credential.Immediate(opts.name, opts.token); 14 | } else if (opts.name && opts.pass) { 15 | this.credential = new credential.UserPass(opts.name, opts.pass); 16 | } else { 17 | this.credential = new credential.Dummy(); 18 | } 19 | 20 | this.channels = {}; 21 | this.connected = false; 22 | 23 | this.open(); 24 | } 25 | HitboxChatClient.prototype = { 26 | // internal handlers 27 | onconnect: function(socket) { 28 | this.connected = true; 29 | var t = this; 30 | this.socket = socket; 31 | socket.on("message", function(data) { 32 | t.onmessage(JSON.parse(data)); 33 | }); 34 | socket.on("disconnect", function() { 35 | t.emit("disconnect"); 36 | }); 37 | this.emit("connect"); 38 | }, 39 | onmessage : function(message) { 40 | var channel = message.params.channel; 41 | if (channel in this.channels) { 42 | this.channels[channel].onmessage(message); 43 | } else { 44 | throw "Unknown channel " + channel; 45 | } 46 | }, 47 | // internal websocket functions 48 | send: function(method, params, auth) { 49 | var t = this; 50 | this.credential.withCredential(function(name, token) { 51 | params.name = name; 52 | if (auth) { 53 | params.token = token; 54 | } 55 | t.socket.emit("message", { 56 | method: method, 57 | params: params 58 | }); 59 | }); 60 | }, 61 | open: function() { 62 | var t = this; 63 | utils.safeGet("http://www.hitbox.tv/api/chat/servers?redis=true", function(body){ 64 | var servers = JSON.parse(body); 65 | var i = -1; 66 | (function next() { 67 | i = (i + 1) % servers.length; 68 | var sock = socketio_client.connect("http://" + servers[i].server_ip, { timeout: 5000 }); 69 | sock.on("connect", t.onconnect.bind(t, sock)); 70 | sock.on("connect_timeout", next); 71 | sock.on("error", next); 72 | })(); 73 | }); 74 | }, 75 | // external API functions 76 | joinChannel: function(channel) { 77 | if (!this.connected) { 78 | throw "WTF"; 79 | } 80 | 81 | var ch = this.channels[ch]; 82 | 83 | if (!ch) { 84 | ch = this.channels[channel] = new HitboxChannel(this, channel); 85 | } 86 | 87 | ch.join(); 88 | 89 | return ch; 90 | } 91 | } 92 | utils.mixin(HitboxChatClient, utils.emitter); 93 | 94 | module.exports = HitboxChatClient; 95 | -------------------------------------------------------------------------------- /lib/channel.js: -------------------------------------------------------------------------------- 1 | 2 | var utils = require("./utils"); 3 | var HitboxPoll = require("./poll"); 4 | var HitboxRaffle = require("./raffle"); 5 | 6 | function HitboxChannel(client, channel) { 7 | if (!utils.isa(this, HitboxChannel)) return new HitboxChannel(client, channel); 8 | 9 | this.channel = channel; 10 | this.client = client; 11 | this.joined = false; 12 | this.loggedIn = false; 13 | this.role = null; 14 | this.name = null; 15 | this.defaultColor = "0000FF"; 16 | 17 | this.poll = null; 18 | this.raffle = null; 19 | } 20 | HitboxChannel.prototype = { 21 | // internal handlers 22 | onmessage: function(message) { 23 | if (message.method == "loginMsg") { 24 | this.loggedin = true; 25 | this.name = message.params.name; 26 | this.role = message.params.role; 27 | this.emit("login", message.params.name, message.params.role); 28 | } else if (message.method == "chatMsg") { 29 | this.emit("chat", message.params.name, message.params.text, message.params.role); 30 | } else if (message.method == "motdMsg") { 31 | this.emit("motd", message.params.text); 32 | } else if (message.method == "slowMsg") { 33 | this.emit("slow", message.params.slowTime); 34 | } else if (message.method == "infoMsg") { 35 | this.emit("info", message.params.text); 36 | } else if (message.method == "pollMsg") { 37 | if (this.poll) { 38 | this.poll.onmessage(message.params); 39 | if (this.poll.status == "ended") { 40 | this.poll = null; 41 | } 42 | } else { 43 | this.poll = new HitboxPoll(this, message.params); 44 | this.emit("poll", this.poll); 45 | } 46 | } else if (message.method == "raffleMsg") { 47 | if (this.raffle) { 48 | this.raffle.onmessage(message.params); 49 | if (this.raffle.status == "delete") { 50 | this.raffle = null; 51 | } 52 | } else { 53 | this.raffle = new HitboxRaffle(this, message.params); 54 | this.emit("raffle", this.raffle); 55 | } 56 | } else if (message.method == "winnerMsg") { 57 | if (this.raffle) { 58 | this.raffle.onwinner(message.params); 59 | } else { 60 | throw "WTF?"; 61 | } 62 | } else { 63 | // catch all so if something else happens its more visible 64 | this.emit("other", message.method, message.params); 65 | } 66 | }, 67 | // internal websocket functions 68 | send: function(method, params, auth) { 69 | params.channel = this.channel; 70 | this.client.send(method, params, auth); 71 | }, 72 | // external API functions 73 | join: function() { 74 | if (this.joined) { 75 | return; 76 | } 77 | this.joined = true; 78 | this.send("joinChannel", { isAdmin: false }, true); 79 | }, 80 | leave: function() { 81 | if (!this.joined) { 82 | return; 83 | } 84 | this.client.send("partChannel", { 85 | channel: this.channel, 86 | name: this.name 87 | }); 88 | this.joined = false; 89 | this.loggedIn = false; 90 | this.role = null; 91 | this.name = null; 92 | }, 93 | sendMessage: function(text,nameColor) { 94 | var color = nameColor || this.defaultColor; 95 | this.send("chatMsg", { 96 | nameColor: color, 97 | text: text 98 | }); 99 | } 100 | } 101 | utils.mixin(HitboxChannel, utils.emitter); 102 | 103 | module.exports = HitboxChannel; 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hitbox-chat 2 | 3 | A chat client library for hitbox.tv 4 | 5 | ## API 6 | 7 | ### HitboxChatClient 8 | 9 | #### HitboxChatClient(opts:Object) 10 | Constructs a hitbox.tv chat client. Exposed as the result of calling `require("hitbox-chat")` 11 | ``` 12 | opts = null 13 | | { user: "tsholmes", pass: "hunter2" } 14 | | { user: "tsholmes", token: "0123456789abcdef0123456789abcdef0123457" } 15 | ``` 16 | 17 | #### HitboxChatClient#on(event:String, callback:Function) 18 | Adds an event listener 19 | ``` 20 | (event, callback) = ("connect", Function()) 21 | | ("disconnect", Function()); 22 | ``` 23 | 24 | #### HitboxChatClient#joinChannel(channel:String):HitboxChannel 25 | Joins a channel and returns the channel object 26 | 27 | ### HitboxChannel 28 | 29 | #### HitboxChannel#on(event:String, callback:Function) 30 | Adds an event listener 31 | ``` 32 | (event, callback) = ("login", Function(name:String, role:String)) 33 | | ("chat", Function(name:String, text:String, role:String)) 34 | | ("motd", Function(text:String)) 35 | | ("slow", Function(slowTime:Number)) 36 | | ("info", Function(text:String)) 37 | | ("poll", Function(poll:HitboxPoll)) 38 | | ("raffle", Function(raffle:HitboxRaffle)) 39 | | ("other", Function(method:String, params:Object) 40 | ``` 41 | 42 | #### HitboxChannel#join() 43 | (Re)joins the channel with the credentials specified in the client. 44 | 45 | #### HitboxChannel#leave() 46 | Leaves the channel. Stops receiving events on this channel (once the server receives the leave request). 47 | 48 | #### HitboxChannel#sendMessage(text:String, nameColor:String) 49 | Sends a message to this channel with the given name color (or the default color if null) 50 | 51 | #### HitboxChannel#defaultColor:String 52 | The default name color when sending messages 53 | 54 | ### HitboxPoll 55 | 56 | #### HitboxPoll#on(event:String, callback:Function) 57 | Adds an event listener 58 | ``` 59 | (event, callback) = ("pause", Function()) 60 | | ("start", Function()) // sent on restart after pause 61 | | ("vote", Function()) // sent when (every time?) someone new votes 62 | | ("end", Function()) 63 | ``` 64 | 65 | #### HitboxPoll#vote(choice:Number) 66 | Votes for the choice specified by (0-based) index `choice`. 67 | 68 | #### HitboxPoll#startTime:Date 69 | The time the poll was started 70 | 71 | #### HitboxPoll#status:String 72 | The status of the poll. One of `{ started, paused, ended }`. 73 | 74 | #### HitboxPoll#question:String 75 | The question the poll is asking. 76 | 77 | #### HitboxPoll#choices:Array[Object] 78 | The choices for responding to the poll. 79 | ``` 80 | choices = [ 81 | { text: "choice 0", votes: 1 }, 82 | { text: "choice 1", votes: 4 }, 83 | ... 84 | ] 85 | ``` 86 | 87 | #### HitboxPoll#voters:Array[String] 88 | The list of usernames that voted in the poll. 89 | 90 | #### HitboxPoll#votes:Number 91 | The number of votes cast in the poll. (same as `voters.length`) 92 | 93 | ### HitboxRaffle 94 | 95 | #### HitboxRaffle#on(event:String, callback:Function) 96 | Adds an event listener 97 | ``` 98 | (event, callback) = ("pause", Function()) // picking winner 99 | | ("start", Function()) // restart after pause 100 | | ("vote", Function()) 101 | | ("end", Function()) // winner chosen 102 | | ("hide", Function()) // hidden after winner chosen 103 | | ("delete", Function()) // deleted 104 | | ("winner", Function(name:String, email:String)) // (ADMIN ONLY) name and email of winner 105 | | ("win", Function()) // (CLIENT ONLY) you won! 106 | ``` 107 | 108 | #### HitboxRaffle#vote(choice:Number) 109 | Votes for the choice specified by (0-based) index `choice`. 110 | 111 | #### HitboxRaffle#startTime:Date 112 | The time the raffle was started 113 | 114 | #### HitboxRaffle#status:String 115 | The status of the raffle. One of `{ started, paused, ended, hidden, deleted}`. 116 | 117 | #### HitboxRaffle#question:String 118 | The question or title of the raffle. 119 | 120 | #### HitboxRaffle#Choices:Array[Object] 121 | The choices for responding to the raffle. 122 | ``` 123 | choices = [ // ADMIN 124 | { text: "choice 0", count: 1 }, 125 | { text: "choice 1", count: 2 }, 126 | ... 127 | ] 128 | choices = [ // CLIENT 129 | { text: "choice 0" }, 130 | { text: "choice 1" }, 131 | ... 132 | ] 133 | ``` 134 | 135 | ##Example Usage 136 | 137 | ``` 138 | var HitboxChatClient = require("hitbox-chat"); 139 | 140 | // (username, token) or () for guest 141 | var client = new HitboxChatClient({name:"tsholmes", pass:"hunter2"}); 142 | client.on("connect", function() { 143 | // handle connect 144 | var channel = client.joinChannel("tsholmes"); 145 | channel.on("login", function(name, role) { 146 | /* 147 | * successfully joined channel 148 | * role is one of { 149 | * guest: read-only (bad or no credentials) 150 | * anon: normal read/write 151 | * user: mod 152 | * admin: owner/staff 153 | * } 154 | */ 155 | }).on("chat", function(name,text,role) { 156 | // chat message received 157 | channel.sendMessage("Hi " + name, "00FF00"); 158 | }).on("motd", function(text) { 159 | // message of the day changed 160 | }).on("slow", function(slowTime) { 161 | // slow mode enabled. limited to 1 message every slowTime seconds 162 | }).on("info", function(text) { 163 | // info message (bans, kicks, etc) 164 | }).on("poll", function(poll) { 165 | // poll started 166 | poll.vote(0); 167 | }).on("raffle", function(raffle){ 168 | // raffle started 169 | raffle.vote(0); 170 | raffle.on("win", function() { 171 | // you won! 172 | }); 173 | }).on("other", function(method,params) { 174 | // something else that isn't handled yet. params is raw event JSON 175 | }); 176 | }).on("disconnect", function() { 177 | // handle disconnect 178 | }); 179 | ``` 180 | --------------------------------------------------------------------------------