UNPKG

@tf2autobot/tradeoffer-manager

Version:
654 lines (558 loc) 20.4 kB
"use strict"; const Async = require('async'); const FileManager = require('file-manager'); const {LeastUsedCache} = require('@doctormckay/stdlib').DataStructures; const {appDataDirectory} = require('@doctormckay/stdlib').OS; const SteamCommunity = require('@tf2autobot/steamcommunity'); const Zlib = require('zlib'); const Helpers = require('./helpers.js'); module.exports = TradeOfferManager; const SteamID = TradeOfferManager.SteamID = require('steamid'); const ETradeOfferState = TradeOfferManager.ETradeOfferState = require('../resources/ETradeOfferState.js'); const EOfferFilter = TradeOfferManager.EOfferFilter = require('../resources/EOfferFilter.js'); const EResult = TradeOfferManager.EResult = require('../resources/EResult.js'); const EConfirmationMethod = TradeOfferManager.EConfirmationMethod = require('../resources/EConfirmationMethod.js'); const ETradeStatus = TradeOfferManager.ETradeStatus = require('../resources/ETradeStatus.js'); const TradeOffer = require('./classes/TradeOffer.js'); require('util').inherits(TradeOfferManager, require('events').EventEmitter); /** * @typedef {object} OptionsObject * @property {SteamUser} [steam] * @property {SteamCommunity} [community] * @property {string} [domain] * @property {string} [language] * @property {number} [pollInterval=30000] * @property {number} [minimumPollInterval=1000] * @property {number} [pollFullUpdateInterval=120000] * @property {number} [cancelTime] * @property {number} [pendingCancelTime] * @property {number} [cancelOfferCount] * @property {number} [cancelOfferCountMinAge] * @property {boolean} [globalAssetCache=false] * @property {number} [assetCacheMaxItems=500] * @property {number} [assetCacheGcInterval=120000] * @property {object} [pollData] * @property {string} [dataDirectory] * @property {boolean} [gzipData=false] * @property {boolean} [savePollData=false] * @property {boolean} [useAccessToken=false] */ /** * @param {OptionsObject} [options] * @constructor */ function TradeOfferManager(options) { options = options || {}; this._steam = options.steam; this._domain = options.domain || 'localhost'; this._language = options.language; if (this._domain == 'doctormckay.com' && !process.env.MCKAY_BOX) { throw new Error("Please fill in your own domain. I'm pretty sure you don't own doctormckay.com."); } this._community = options.community || new SteamCommunity(); this._pollTimer = null; this._lastPoll = 0; this._lastPollFullUpdate = 0; this._pendingOfferSendResponses = 0; this._dataGzip = options.gzipData; var assetCacheSize = options.assetCacheMaxItems || 500; var assetCacheGcInterval = options.assetCacheGcInterval || 120000; // 2 minutes if (options.globalAssetCache) { global._steamTradeOfferManagerAssetCache = global._steamTradeOfferManagerAssetCache || new LeastUsedCache(assetCacheSize, assetCacheGcInterval); this._assetCache = global._steamTradeOfferManagerAssetCache; } else { this._assetCache = new LeastUsedCache(assetCacheSize, assetCacheGcInterval); } // Set up disk persistence if (!options.dataDirectory && options.dataDirectory !== null) { if (process.env.OPENSHIFT_DATA_DIR) { options.dataDirectory = process.env.OPENSHIFT_DATA_DIR + "/node-steam-tradeoffer-manager"; } else { options.dataDirectory = appDataDirectory({ appName: 'node-steam-tradeoffer-manager', appAuthor: 'doctormckay' }); } } if (options.dataDirectory) { this.storage = new FileManager(options.dataDirectory); } this.pollInterval = options.pollInterval || 30000; this.minimumPollInterval = options.minimumPollInterval || 1000; this.pollFullUpdateInterval = options.pollFullUpdateInterval || 120000; this.cancelTime = options.cancelTime; this.pendingCancelTime = options.pendingCancelTime; this.cancelOfferCount = options.cancelOfferCount; this.cancelOfferCountMinAge = options.cancelOfferCountMinAge || 0; // Make sure poll intervals are sane var sanityChecks = { pollInterval: 1000, minimumPollInterval: 1000, pollFullUpdateInterval: 1000 }; for (var i in sanityChecks) { var sanityValue = sanityChecks[i]; var actualValue = this[i]; // negative values are only allowed for pollInterval if (i == 'pollInterval' && actualValue < 0) { continue; } if (actualValue < sanityValue) { this._warn(`Option ${i} failed sanity check: provided value (${actualValue}) is too low. ${i} has been forced to ${sanityValue}.`); this[i] = sanityValue; } } this.pollData = options.pollData || {}; this.useAccessToken = options.useAccessToken !== false; this.accessToken = null; this.apiKey = null; this.steamID = null; if (this._language) { if (this._language == 'szh') { this._language = 'zh'; this._languageName = 'schinese'; } else if (this._language == 'tzh') { this._language = 'zh'; this._languageName = 'tchinese'; } else if (this._language == 'br') { this._language = 'pt-BR'; this._languageName = 'brazilian'; } else { var lang = require('languages').getLanguageInfo(this._language); if (!lang.name) { this._language = null; this._languageName = null; } else { this._languageName = lang.name.toLowerCase(); } } } if (this._steam) { this._steam.on('tradeOffers', (count) => { this.doPoll(); }); // This is an instance of https://www.npmjs.com/package/steam-user, and newItems is emitted when new items are announced this._steam.on('newItems', (count) => { this.doPoll(); }); } if (options.savePollData) { this._getPollDataFromDisk = true; this.on('pollData', (pollData) => { if (this.steamID) { this._persistToDisk('polldata_' + this.steamID + '.json', JSON.stringify(pollData)); } }); } } TradeOfferManager.prototype.setCookies = function(cookies, familyViewPin, callback) { if (typeof familyViewPin === 'function') { callback = familyViewPin; familyViewPin = null; } try { var loginSecureCookie = cookies.find(cookie => cookie.indexOf('steamLoginSecure=') === 0); if (!loginSecureCookie) { throw new Error('steamLoginSecure cookie not found'); } var cookieValueMatch = loginSecureCookie.match(/steamLoginSecure=([^;]+)/); if (!cookieValueMatch) { throw new Error('steamLoginSecure cookie is malformed'); } var cookieValue = decodeURIComponent(cookieValueMatch[1].trim()); var accessToken = cookieValue.split('||')[1]; if (!accessToken) { throw new Error('Access token not found'); } this.accessToken = accessToken; } catch (ex) { if (this.useAccessToken) { callback && callback(ex); return; } } this._community.setCookies(cookies); this.steamID = this._community.steamID; if (this._getPollDataFromDisk) { delete this._getPollDataFromDisk; var filename = 'polldata_' + this.steamID + '.json'; this._getFromDisk([filename], (err, files) => { if (files[filename]) { try { this.pollData = JSON.parse(files[filename].toString('utf8')); } catch (ex) { this.emit('debug', 'Error parsing poll data from disk: ' + ex.message); } } }); } var finish = (err) => { var hadError = !!err; if (hadError) { // We had an error. But, if this error was the lack of an API key and we have an access token, // we can fall back to using that. if (err.message == 'No API key created for this account' && this.accessToken) { hadError = false; // Emit a warning if the user hadn't explicitly opted in to using access tokens if (!this.useAccessToken) { this._warn( 'An API key has not been created for this account; access token will be used instead for API requests.' + '\n For more information, see: https://github.com/DoctorMcKay/node-steam-tradeoffer-manager/wiki/Access-Tokens' + '\n To disable this warning, create an API key or set useAccessToken to true in TradeOfferManager options.' ); } } } if (hadError) { callback && callback(err); return; } // Smooth sailing ahead if (this._languageName) { this._community.setCookies(['Steam_Language=' + this._languageName]); } clearTimeout(this._pollTimer); if (this.pollInterval >= 0) { this.doPoll(); } callback && callback(); }; if (familyViewPin) { this.parentalUnlock(familyViewPin, (err) => { if (err) { callback && callback(err); return; } if (this.accessToken && this.useAccessToken) { finish(); } else { this._checkApiKey(finish); } }); } else { if (this.accessToken && this.useAccessToken) { finish(); } else { this._checkApiKey(finish); } } }; TradeOfferManager.prototype.shutdown = function() { clearTimeout(this._pollTimer); this._community = new SteamCommunity(); this._steam = null; this.apiKey = null; this.accessToken = null; }; TradeOfferManager.prototype.parentalUnlock = function(pin, callback) { this._community.parentalUnlock(pin, (err) => { if (callback) { callback(err || null); } }); }; TradeOfferManager.prototype._checkApiKey = function(callback) { if (this.apiKey) { if (callback) { callback(); } return; } this._community.getWebApiKey(this._domain, (err, key) => { if (err) { callback(err); return; } this.apiKey = key; callback(); }); }; TradeOfferManager.prototype._persistToDisk = function(filename, content) { if (!this.storage) { return; } if (typeof content === 'string') { content = Buffer.from(content, 'utf8'); } if (this._dataGzip) { Zlib.gzip(content, (err, data) => { if (err) { this.emit('debug', `Cannot gzip ${filename}: ${err.message}`); } else { this.storage.writeFile(filename + '.gz', data).catch(err => { this.emit('debug', `Cannot write ${filename}.gz: ${err.message}`); }); } }); } else { this.storage.writeFile(filename, content).catch(err => { if (err) { this.emit('debug', `Cannot write ${filename}: ${err.message}`); } }); } }; TradeOfferManager.prototype._getFromDisk = function(filenames, callback) { if (!this.storage) { callback(null, {}); return; } if (this._dataGzip) { filenames = filenames.map(name => name + '.gz'); } this.storage.readFiles(filenames).then(results => { var files = {}; results.forEach((file) => { if (file.contents) { files[file.filename] = file.contents; } }); if (this._dataGzip) { Async.mapValues(files, (content, filename, mapCb) => { Zlib.gunzip(content, (err, data) => { if (err) { mapCb(null, null); } else { mapCb(null, data); } }); }, (err, files) => { var renamed = {}; for (var i in files) { if (files.hasOwnProperty(i)) { renamed[i.replace(/\.gz$/, '')] = files[i]; } } callback(null, renamed); }); } else { callback(null, files); } }); }; /** * Get the contents of your own specific inventory context. * @param {int} appid - The Steam application ID of the game for which you want an inventory * @param {int} contextid - The ID of the "context" within the game you want to retrieve * @param {boolean} tradableOnly - true to get only tradable items and currencies * @param callback */ TradeOfferManager.prototype.getInventoryContents = function(appid, contextid, tradableOnly, callback) { // are we logged in? if (!this.steamID) { callback(new Error("Not Logged In")); return; } this.getUserInventoryContents(this.steamID, appid, contextid, tradableOnly, callback); }; /** * Get the contents of a user's specific inventory context. * @param {SteamID|string} sid - The user's SteamID as a SteamID object or a string which can parse into one * @param {int} appid - The Steam application ID of the game for which you want an inventory * @param {int} contextid - The ID of the "context" within the game you want to retrieve * @param {boolean} tradableOnly - true to get only tradable items and currencies * @param callback */ TradeOfferManager.prototype.getUserInventoryContents = function(sid, appid, contextid, tradableOnly, callback) { this._community.getUserInventoryContents(sid, appid, contextid, tradableOnly, this._languageName || "english", callback); }; /** * Get the contents of your own specific inventory context. * @deprecated Use getInventoryContents instead * @param {int} appid - The Steam application ID of the game for which you want an inventory * @param {int} contextid - The ID of the "context" within the game you want to retrieve * @param {boolean} tradableOnly - true to get only tradable items and currencies * @param callback */ TradeOfferManager.prototype.loadInventory = function(appid, contextid, tradableOnly, callback) { // are we logged in? if (!this.steamID) { callback(new Error("Not Logged In")); return; } this.loadUserInventory(this.steamID, appid, contextid, tradableOnly, callback); }; /** * Get the contents of a user's specific inventory context. * @deprecated Use getUserInventoryContents instead * @param {SteamID|string} sid - The user's SteamID as a SteamID object or a string which can parse into one * @param {int} appid - The Steam application ID of the game for which you want an inventory * @param {int} contextid - The ID of the "context" within the game you want to retrieve * @param {boolean} tradableOnly - true to get only tradable items and currencies * @param callback */ TradeOfferManager.prototype.loadUserInventory = function(sid, appid, contextid, tradableOnly, callback) { this._community.getUserInventory(sid, appid, contextid, tradableOnly, callback); }; /** * Get the token parameter from your account's Trade URL * @param {function} callback */ TradeOfferManager.prototype.getOfferToken = function(callback) { this._community.getTradeURL((err, url, token) => { if (err) { callback(err); return; } callback(null, token); }); }; TradeOfferManager.prototype.getOffersContainingItems = function(items, includeInactive, callback) { if (typeof includeInactive === 'function') { callback = includeInactive; includeInactive = false; } if (typeof items.length === 'undefined') { // not an array items = [items]; } this.getOffers(includeInactive ? EOfferFilter.All : EOfferFilter.ActiveOnly, (err, sent, received) => { if (err) { callback(err); return; } callback(null, sent.filter(filterFunc), received.filter(filterFunc)); }); function filterFunc(offer) { return items.some(item => offer.containsItem(item)); } }; /** * Create a new TradeOffer object. * @param {string|SteamID} partner - Their full Trade URL or their SteamID (as a SteamID object or a string that can parse into one) * @param {string} [token] - Their trade token, if you aren't friends with them * @returns {TradeOffer} */ TradeOfferManager.prototype.createOffer = function(partner, token) { if (typeof partner === 'string' && partner.match(/^https?:\/\//)) { // It's a trade URL I guess var url = require('url').parse(partner, true); if (!url.query.partner) { throw new Error("Invalid trade URL"); } partner = SteamID.fromIndividualAccountID(url.query.partner); token = url.query.token; } var offer = new TradeOffer(this, partner, token); offer.isOurOffer = true; offer.fromRealTimeTrade = false; return offer; }; require('./webapi.js'); require('./assets.js'); require('./polling.js'); /** * Get a trade offer that is already sent (either by you or to you) * @param {int|string} id - The offer's numeric ID * @param {function} callback */ TradeOfferManager.prototype.getOffer = function(id, callback) { this._apiCall('GET', 'GetTradeOffer', 1, {"tradeofferid": id}, (err, body) => { if (err) { callback(err); return; } if (!body.response) { callback(new Error("Malformed API response")); return; } if (!body.response.offer) { callback(new Error("No matching offer found")); return; } // Make sure the response is well-formed if (Helpers.offerMalformed(body.response.offer)) { callback(new Error("Data temporarily unavailable")); return; } this._digestDescriptions(body.response.descriptions); Helpers.checkNeededDescriptions(this, [body.response.offer], (err) => { if (err) { callback(err); return; } callback(null, Helpers.createOfferFromData(this, body.response.offer)); }); }); }; /** * Get a list of trade offers either sent to you or by you * @param {int} filter * @param {Date} [historicalCutoff] - Pass a Date object in the past along with ActiveOnly to also get offers that were updated since this time * @param {function} callback */ TradeOfferManager.prototype.getOffers = function(filter, historicalCutoff, callback) { if ([EOfferFilter.ActiveOnly, EOfferFilter.HistoricalOnly, EOfferFilter.All].indexOf(filter) == -1) { throw new Error('Unexpected value "' + filter + '" for "filter" parameter. Expected a value from the EOfferFilter enum.'); } if (typeof historicalCutoff === 'function') { callback = historicalCutoff; historicalCutoff = new Date(Date.now() + 31536000000); } else if (!historicalCutoff) { historicalCutoff = new Date(Date.now() + 31536000000); } // Currently the GetTradeOffers API doesn't include app_data, so we need to get descriptions from the WebAPI var options = { "get_sent_offers": 1, "get_received_offers": 1, "get_descriptions": 0, "language": this._language, "active_only": (filter == EOfferFilter.ActiveOnly) ? 1 : 0, "historical_only": (filter == EOfferFilter.HistoricalOnly) ? 1 : 0, "time_historical_cutoff": Math.floor(historicalCutoff.getTime() / 1000), "cursor": 0 }; var sentOffers = []; var receivedOffers = []; var request = () => { this._apiCall('GET', 'GetTradeOffers', 1, options, (err, body) => { if (err) { callback(err); return; } if (!body.response) { callback(new Error("Malformed API response")); return; } // Make sure at least some offers are well-formed. Apparently some offers can be empty just forever. Because Steam. var allOffers = (body.response.trade_offers_sent || []).concat(body.response.trade_offers_received || []); if (allOffers.length > 0 && (allOffers.every(Helpers.offerMalformed) || allOffers.some(Helpers.offerSuperMalformed))) { callback(new Error("Data temporarily unavailable")); return; } sentOffers = sentOffers.concat(body.response.trade_offers_sent || []); receivedOffers = receivedOffers.concat(body.response.trade_offers_received || []); options.cursor = body.response.next_cursor || 0; if (typeof options.cursor == 'number' && options.cursor != 0 && options.cursor <= 100) { this.emit('debug', 'GetTradeOffers with cursor ' + options.cursor); request(); } else { finish(); } }); }; var finish = () => { //manager._digestDescriptions(body.response.descriptions); // Let's check the asset cache and see if we have descriptions that match these items. // If the necessary descriptions aren't in the asset cache, this will request them from the WebAPI and store // them for future use. Helpers.checkNeededDescriptions(this, sentOffers.concat(receivedOffers), (err) => { if (err) { callback(new Error("Descriptions: " + err.message)); return; } var sent = sentOffers.map(data => Helpers.createOfferFromData(this, data)); var received = receivedOffers.map(data => Helpers.createOfferFromData(this, data)); callback(null, sent, received); this.emit('offerList', filter, sent, received); }); }; request(); }; TradeOfferManager.prototype._notifySessionExpired = function(err) { this.emit('sessionExpired', err); this._community._notifySessionExpired(err); }; TradeOfferManager.prototype._warn = function(msg) { process.emitWarning(msg, 'Warning', 'steam-tradeoffer-manager'); };