soulbound-node-dota2
Version:
A node-steam plugin for Dota 2.
378 lines (336 loc) • 14.8 kB
JavaScript
/**
* Dota 2 module
* @module Dota2
*/
/**
* A Long class for representing a 64 bit two's-complement integer value
* derived from the Closure Library for stand-alone use and extended with unsigned support.
* @external Long
* @see {@link https://www.npmjs.com/package/long|long} npm package
*/
const steam = require("steam");
const winston = require("winston");
const moment = require("moment");
const DOTA_APP_ID = 570;
var EventEmitter = require('events').EventEmitter,
fs = require("fs"),
util = require("util"),
Long = require("long"),
Protobuf = require('protobufjs'),
Dota2 = exports;
Protobuf.parse.defaults.keepCase = true;
var folder = fs.readdirSync(__dirname + '/proto');
/**
* Protobuf schema. See {@link http://dcode.io/protobuf.js/Root.html|Protobufjs#Root}.
* This object can be used to obtain special protobuf types.
* Object types can be created by `Dota2.schema.lookupType("TypeName").encode(payload :Object).finish();`.
* Enum types can be referenced by `Dota2.schema.lookupEnum("EnumName").values`, which returns an object array representing the enum.
* @alias module:Dota2.schema
*/
Dota2.schema = Protobuf.loadSync(folder.map(filename => __dirname + '/proto/' + filename));
/**
* The Dota 2 client that communicates with the GC
* @class
* @alias module:Dota2.Dota2Client
* @param {Object} steamClient - Node-steam client instance
* @param {boolean} debug - Print debug information to console
* @param {boolean} debugMore - Print even more debug information to console
* @extends {EventEmitter} EventEmitter
* @fires module:Dota2.Dota2Client#event:ready
* @fires module:Dota2.Dota2Client#event:unhandled
* @fires module:Dota2.Dota2Client#event:hellotimeout
* @fires module:Dota2.Dota2Client#event:popup
* @fires module:Dota2.Dota2Client#event:sourceTVGamesData
* @fires module:Dota2.Dota2Client#event:inventoryUpdate
* @fires module:Dota2.Dota2Client#event:practiceLobbyUpdate
* @fires module:Dota2.Dota2Client#event:practiceLobbyCleared
* @fires module:Dota2.Dota2Client#event:lobbyInviteUpdate
* @fires module:Dota2.Dota2Client#event:lobbyInviteCleared
* @fires module:Dota2.Dota2Client#event:practiceLobbyJoinResponse
* @fires module:Dota2.Dota2Client#event:practiceLobbyListData
* @fires module:Dota2.Dota2Client#event:practiceLobbyResponse
* @fires module:Dota2.Dota2Client#event:lobbyDestroyed
* @fires module:Dota2.Dota2Client#event:friendPracticeLobbyListData
* @fires module:Dota2.Dota2Client#event:inviteCreated
* @fires module:Dota2.Dota2Client#event:partyUpdate
* @fires module:Dota2.Dota2Client#event:partyCleared
* @fires module:Dota2.Dota2Client#event:partyInviteUpdate
* @fires module:Dota2.Dota2Client#event:partyInviteCleared
* @fires module:Dota2.Dota2Client#event:joinableCustomGameModes
* @fires module:Dota2.Dota2Client#event:chatChannelsData
* @fires module:Dota2.Dota2Client#event:chatJoin
* @fires module:Dota2.Dota2Client#event:chatJoined
* @fires module:Dota2.Dota2Client#event:chatLeave
* @fires module:Dota2.Dota2Client#event:chatMessage
* @fires module:Dota2.Dota2Client#event:profileCardData
* @fires module:Dota2.Dota2Client#event:playerMatchHistoryData
* @fires module:Dota2.Dota2Client#event:playerInfoData
* @fires module:Dota2.Dota2Client#event:playerStatsData
* @fires module:Dota2.Dota2Client#event:trophyListData
* @fires module:Dota2.Dota2Client#event:hallOfFameData
* @fires module:Dota2.Dota2Client#event:playerCardRoster
* @fires module:Dota2.Dota2Client#event:playerCardDrafted
* @fires module:Dota2.Dota2Client#event:liveLeagueGamesUpdate
* @fires module:Dota2.Dota2Client#event:leagueData
* @fires module:Dota2.Dota2Client#event:topLeagueMatchesData
* @fires module:Dota2.Dota2Client#event:teamData
* @fires module:Dota2.Dota2Client#event:matchesData
* @fires module:Dota2.Dota2Client#event:matchDetailsData
* @fires module:Dota2.Dota2Client#event:matchMinimalDetailsData
* @fires module:Dota2.Dota2Client#event:matchmakingStatsData
* @fires module:Dota2.Dota2Client#event:topFriendMatchesData
* @fires module:Dota2.Dota2Client#event:tipResponse
* @fires module:Dota2.Dota2Client#event:tipped
*/
Dota2.Dota2Client = function Dota2Client(steamClient, debug, debugMore) {
EventEmitter.call(this);
this.debug = debug || false;
this.debugMore = debugMore || false;
/**
* The logger used to write debug messages. This is a WinstonJS logger,
* feel free to configure it as you like
* @type {winston.Logger}
*/
this.Logger = new (winston.Logger)({
transports: [
new (winston.transports.Console)({
'timestamp': () => moment().format("d MMMM HH:mm:ss"),
'formatter': options => options.timestamp() + " - " + (options.message ? options.message : "")
})
]
});
if(debug) this.Logger.level = "debug";
if(debugMore) this.Logger.level = "silly";
/** The current state of the bot's inventory. Contains cosmetics, player cards, ...
* @type {CSOEconItem[]}
*/
this.Inventory = [];
/** The chat channels the bot has joined
* @type {CMsgDOTAJoinChatChannelResponse[]}
*/
this.chatChannels = []; // Map channel names to channel data.
/** The lobby the bot is currently in. Falsy if the bot isn't in a lobby.
* @type {CSODOTALobby}
*/
this.Lobby = null;
/** The currently active lobby invitation. Falsy if the bot has not been invited.
* @type {CSODOTALobbyInvite}
*/
this.LobbyInvite = null;
/** The party the bot is currently in. Falsy if the bot isn't in a party.
* @type {CSODOTAParty}
*/
this.Party = null;
/** The currently active party invitation. Falsy if the bot has not been invited.
* @type {CSODOTAPartyInvite}
*/
this.PartyInvite = null;
var steamUser = new steam.SteamUser(steamClient);
this._user = steamUser;
this._client = steamClient;
this._gc = new steam.SteamGameCoordinator(steamClient, DOTA_APP_ID);
this._appid = DOTA_APP_ID;
this._gcReady = false;
this._gcClientHelloIntervalId = null;
this._gcConnectionStatus = Dota2.schema.lookupEnum("GCConnectionStatus").values.GCConnectionStatus_NO_SESSION;
// node-steam wants this as a simple object, so we can't use CMsgProtoBufHeader
this._protoBufHeader = {
"msg": "",
"proto": {
"client_steam_id": this._client.steamID,
"source_app_id": this._appid
}
};
var self = this;
this._gc.on("message", function fromGC(header, body, callback) {
/* Routes messages from Game Coordinator to their handlers. */
callback = callback || null;
var kMsg = header.msg;
self.Logger.silly("Dota2 fromGC: " + Dota2._getMessageName(kMsg));
if (kMsg in self._handlers) {
if (callback) {
self._handlers[kMsg].call(self, body, callback);
} else {
self._handlers[kMsg].call(self, body);
}
} else {
self.emit("unhandled", kMsg, Dota2._getMessageName(kMsg));
}
});
this._sendClientHello = function() {
if (self._gcReady) {
if (self._gcClientHelloIntervalId) {
clearInterval(self._gcClientHelloIntervalId);
self._gcClientHelloIntervalId = null;
}
return;
}
if (self._gcClientHelloCount > 10) {
self.Logger.warn("ClientHello has taken longer than 30 seconds! Reporting timeout...")
self._gcClientHelloCount = 0;
self.emit("hellotimeout");
}
self.Logger.debug("Sending ClientHello");
if (!self._gc) {
self.Logger.error("Where the fuck is _gc?");
} else {
self._protoBufHeader.msg = Dota2.schema.lookupEnum("EGCBaseClientMsg").values.k_EMsgGCClientHello;
var payload = {
engine : 1,
secret_key : "",
client_session_need : 104
};
self._gc.send(
self._protoBufHeader,
Dota2.schema.lookupType("CMsgClientHello").encode(payload).finish()
);
}
self._gcClientHelloCount++;
};
};
util.inherits(Dota2.Dota2Client, EventEmitter);
// Methods
/**
* Converts a 64bit Steam ID to a Dota2 account ID by deleting the 32 most significant bits
* @alias module:Dota2.Dota2Client.ToAccountID
* @param {string} steamID - String representation of a 64bit Steam ID
* @returns {number} Dota2 account ID corresponding with steamID
*/
Dota2.Dota2Client.prototype.ToAccountID = function(steamID) {
return new Long.fromString(""+steamID).sub('76561197960265728').toNumber();
};
/**
* Converts a Dota2 account ID to a 64bit Steam ID
* @alias module:Dota2.Dota2Client.ToSteamID
* @param {string} accid - String representation of a Dota 2 account ID
* @returns {external:Long} 64bit Steam ID corresponding to the given Dota 2 account ID
*/
Dota2.Dota2Client.prototype.ToSteamID = function(accid) {
return new Long.fromString(accid+"").add('76561197960265728');
};
/**
* Reports to Steam that you're playing Dota 2, and then initiates communication with the Game Coordinator.
* @alias module:Dota2.Dota2Client#launch
*/
Dota2.Dota2Client.prototype.launch = function() {
/* Reports to Steam that we are running Dota 2. Initiates communication with GC with EMsgGCClientHello */
this.Logger.debug("Launching Dota 2");
this.AccountID = this.ToAccountID(this._client.steamID);
this.Party = null;
this.Lobby = null;
this.PartyInvite = null;
this.Inventory = [];
this.chatChannels = [];
this._user.gamesPlayed([{
"game_id": this._appid
}]);
// Keep knocking on the GCs door until it accepts us.
// This is very bad practice and quite trackable.
// The real client tends to send only one of these.
// Really we should just send one when the connection status is GC online
this._gcClientHelloCount = 0;
this._gcClientHelloIntervalId = setInterval(this._sendClientHello, 6000);
//Also immediately send clienthello
setTimeout(this._sendClientHello, 1000);
};
/**
* Stop sending a heartbeat to the GC and report to steam you're no longer playing Dota 2
* @alias module:Dota2.Dota2Client#exit
*/
Dota2.Dota2Client.prototype.exit = function() {
/* Reports to Steam we are not running any apps. */
this.Logger.debug("Exiting Dota 2");
/* stop knocking if exit comes before ready event */
if (this._gcClientHelloIntervalId) {
clearInterval(this._gcClientHelloIntervalId);
this._gcClientHelloIntervalId = null;
}
this._gcReady = false;
if (this._client.loggedOn) this._user.gamesPlayed([]);
};
Dota2.Dota2Client.prototype.sendToGC = function(type, payload, handler, callback) {
var self = this;
if (!this._gcReady) {
this.Logger.warn("GC not ready, please listen for the 'ready' event.");
if (callback) callback(-1, null); // notify user that something went wrong
return null;
}
this._protoBufHeader.msg = type;
this._gc.send(this._protoBufHeader, // protobuf header, same for all messages
payload, // payload of the message
Dota2._convertCallback.call(self, handler, callback) // let handler treat callback so events are triggered
);
}
// Events
/**
* Emitted when the connection with the GC has been established
* and the client is ready to take requests.
* @event module:Dota2.Dota2Client#ready
*/
/**
* Emitted when the GC sends a message that isn't yet treated by the library.
* @event module:Dota2.Dota2Client#unhandled
* @param {number} kMsg - Proto message type ID
* @param {string} kMsg_name - Proto message type name
*/
/**
* Emitted when the connection with the GC takes longer than 30s
* @event module:Dota2.Dota2Client#hellotimeout
*/
// Handlers
var handlers = Dota2.Dota2Client.prototype._handlers = {};
handlers[Dota2.schema.lookupEnum("EGCBaseClientMsg").values.k_EMsgGCClientWelcome] = function clientWelcomeHandler(message) {
/* Response to our k_EMsgGCClientHello, now we can execute other GC commands. */
// Only execute if _gcClientHelloIntervalID, otherwise it's already been handled (and we don't want to emit multiple 'ready');
if (this._gcClientHelloIntervalId) {
clearInterval(this._gcClientHelloIntervalId);
this._gcClientHelloIntervalId = null;
}
this.Logger.debug("Received client welcome.");
// Parse any caches
this._gcReady = true;
//this._handleWelcomeCaches(message);
this.emit("ready");
};
handlers[Dota2.schema.lookupEnum("EGCBaseClientMsg").values.k_EMsgGCClientConnectionStatus] = function gcClientConnectionStatus(message) {
/* Catch and handle changes in connection status, cuz reasons u know. */
var status = Dota2.schema.lookupType("CMsgConnectionStatus").decode(message).status;
if (status) this._gcConnectionStatus = status;
switch (status) {
case Dota2.schema.lookupEnum("GCConnectionStatus").values.GCConnectionStatus_HAVE_SESSION:
this.Logger.debug("GC Connection Status regained.");
// Only execute if _gcClientHelloIntervalID, otherwise it's already been handled (and we don't want to emit multiple 'ready');
if (this._gcClientHelloIntervalId) {
clearInterval(this._gcClientHelloIntervalId);
this._gcClientHelloIntervalId = null;
this._gcReady = true;
this.emit("ready");
}
break;
default:
this.Logger.debug("GC Connection Status unreliable - " + status);
// Only execute if !_gcClientHelloIntervalID, otherwise it's already been handled (and we don't want to emit multiple 'unready');
if (!this._gcClientHelloIntervalId) {
this._gcClientHelloIntervalId = setInterval(this._sendClientHello, 5000); // Continually try regain GC session
this._gcReady = false;
this.emit("unready");
}
break;
}
};
require("./handlers/cache");
require("./handlers/inventory");
require("./handlers/chat");
require("./handlers/guild");
require("./handlers/community");
require("./handlers/helper");
require("./handlers/match");
require("./handlers/lobbies");
require("./handlers/parties");
require("./handlers/leagues");
require("./handlers/sourcetv");
require("./handlers/team");
require("./handlers/custom");
require("./handlers/general");
require("./handlers/fantasy");
require("./handlers/compendium");