steam-user
Version:
Steam client for Individual and AnonUser Steam account types
1,008 lines (857 loc) • 32.4 kB
JavaScript
const BinaryKVParser = require('binarykvparser');
const StdLib = require('@doctormckay/stdlib');
const SteamID = require('steamid');
const VDF = require('kvparser');
const Helpers = require('./helpers.js');
const EBillingType = require('../enums/EBillingType.js');
const ELicenseFlags = require('../enums/ELicenseFlags.js');
const EMsg = require('../enums/EMsg.js');
const EResult = require('../enums/EResult.js');
const SteamUserBase = require('./00-base.js');
const SteamUserAppAuth = require('./appauth.js');
// This package is implicitly owned by anonymous users. They don't receive a license list, and should instead just
// automatically assume that they own package 17906 (and no others, including 0).
// https://steamdb.info/sub/17906/
const ANONYMOUS_DEDICATED_SERVER_COMP = 17906;
const PICSRequestType = {
User: 0,
Changelist: 1,
Licenses: 2,
PackageContents: 3,
AddToCache: 4
};
/**
* @typedef PackageFilter
* @type {object}
* @property {boolean} [excludeFree=false]
* @property {boolean} [excludeShared=false]
* @property {boolean} [excludeExpiring=false]
*/
/**
* @callback PackageFilterFunction
* @property {Proto_CMsgClientLicenseList_License} packageDetails
* @property {number} [idx]
* @property {Proto_CMsgClientLicenseList_License[]} [allPackages]
* @returns {boolean}
*/
class SteamUserApps extends SteamUserAppAuth {
/**
* Tell Steam that you're "playing" zero or more games.
* @param {array} apps - Array of integers (AppIDs) or strings (non-Steam game names) for the games you're playing. Empty to play nothing.
* @param {boolean} [force=false] If true, kick any other sessions logged into this account and playing games from Steam
*/
gamesPlayed(apps, force) {
if (!(apps instanceof Array)) {
apps = [apps];
}
let execute = async () => {
if (this._playingBlocked && force) {
await this.kickPlayingSession();
}
let processedApps = apps.map((app) => {
if (typeof app == 'string') {
app = {game_id: '15190414816125648896', game_extra_info: app};
} else if (typeof app != 'object') {
app = {game_id: app};
}
if (typeof app.game_ip_address == 'number') {
app.game_ip_address = {v4: app.game_ip_address};
}
return app;
});
this._send(EMsg.ClientGamesPlayedWithDataBlob, {games_played: processedApps});
processedApps.forEach((app) => {
if (app.game_id > Math.pow(2, 32)) {
// It's a non-Steam game.
return;
}
let appid = parseInt(app.game_id, 10);
if (!this._playingAppIds.includes(appid)) {
this.emit('appLaunched', appid);
}
});
this._playingAppIds.forEach((appid) => {
if (!processedApps.some(app => app.game_id == appid)) {
this.emit('appQuit', appid);
}
});
this._playingAppIds = processedApps.filter(app => app.game_id < Math.pow(2, 32)).map(app => parseInt(app.game_id, 10));
};
execute().catch(() => {
});
}
/**
* Kick any other session logged into your account which is playing a game from Steam.
* @param {function} [callback]
* @returns {Promise<{playingApp: number}>}
*/
kickPlayingSession(callback) {
return StdLib.Promises.callbackPromise([], callback, true, (resolve, reject) => {
this._send(EMsg.ClientKickPlayingSession, {});
Helpers.onceTimeout(10000, this, 'playingState', (err, blocked, playingApp) => {
if (err) {
return reject(err);
} else if (blocked) {
return reject(new Error('Cannot kick other session'));
} else {
return resolve({playingApp});
}
});
});
}
/**
* Get count of people playing a Steam app. Use appid 0 to get number of people connected to Steam.
* @param {int} appid
* @param {function} [callback]
* @returns {Promise<{playerCount: number}>}
*/
getPlayerCount(appid, callback) {
return StdLib.Promises.timeoutCallbackPromise(10000, ['playerCount'], callback, (resolve, reject) => {
this._send(EMsg.ClientGetNumberOfCurrentPlayersDP, {appid}, (body) => {
let err = Helpers.eresultError(body.eresult);
if (err) {
reject(err);
} else {
resolve({playerCount: body.player_count});
}
});
});
}
/**
* Get a list of apps or packages which have changed since a particular changenumber.
* @param {int} sinceChangenumber - Changenumber to get changes since. Use 0 to get the latest changenumber, but nothing else
* @param {function} [callback]
* @returns {Promise<{currentChangeNumber: number, appChanges: number[], packageChanges: number[]}>}
*/
getProductChanges(sinceChangenumber, callback) {
let args = ['currentChangeNumber', 'appChanges', 'packageChanges'];
return StdLib.Promises.timeoutCallbackPromise(10000, args, callback, (resolve, reject) => {
this._send(EMsg.ClientPICSChangesSinceRequest, {
since_change_number: sinceChangenumber,
send_app_info_changes: true,
send_package_info_changes: true
}, (body) => {
resolve({
currentChangeNumber: body.current_change_number,
appChanges: body.app_changes,
packageChanges: body.package_changes
});
});
});
}
/**
* Get info about some apps and/or packages from Steam.
* @param {int[]|object[]} apps - Array of AppIDs. May be empty. May also contain objects with keys {appid, access_token}
* @param {int[]|object[]} packages - Array of package IDs. May be empty. May also contain objects with keys {packageid, access_token}
* @param {boolean} [inclTokens=false] - If true, automatically retrieve access tokens if needed
* @param {function} [callback]
* @param {int} [requestType] - Don't touch
* @returns {Promise<{apps: Object<string, {changenumber: number, missingToken: boolean, appinfo: object}>, packages: Object<string, {changenumber: number, missingToken: boolean, packageinfo: object}>, unknownApps: number[], unknownPackages: number[]}>}
*/
getProductInfo(apps, packages, inclTokens, callback, requestType) {
// Adds support for the previous syntax
if (typeof inclTokens !== 'boolean' && typeof inclTokens === 'function') {
requestType = callback;
callback = inclTokens;
inclTokens = false;
}
// This one actually can take a while, so allow it to go as long as 60 minutes
return StdLib.Promises.timeoutCallbackPromise(3600000, ['apps', 'packages', 'unknownApps', 'unknownPackages'], callback, (resolve, reject) => {
requestType = requestType || PICSRequestType.User;
// Steam can send us the full response in multiple responses, so we need to buffer them into one callback
let appids = [];
let packageids = [];
let response = {
apps: {},
packages: {},
unknownApps: [],
unknownPackages: []
};
apps = apps.map((app) => {
if (typeof app === 'object') {
appids.push(app.appid);
return app;
} else {
appids.push(app);
return {appid: app};
}
});
packages = packages.map((pkg) => {
if (typeof pkg === 'object') {
packageids.push(pkg.packageid);
return pkg;
} else {
packageids.push(pkg);
return {packageid: pkg};
}
});
if (inclTokens) {
packages.filter(pkg => !pkg.access_token).forEach((pkg) => {
// Check if we have a license for this package which includes a token
let license = (this.licenses || []).find(lic => lic.package_id == pkg.packageid && lic.access_token != 0);
if (license) {
this.emit('debug', `Using token "${license.access_token}" from license for package ${pkg.packageid}`);
pkg.access_token = license.access_token;
}
});
}
this._send(EMsg.ClientPICSProductInfoRequest, {apps, packages}, async (body) => {
// If we're using the PICS cache, then add the items in this response to it
if (this.options.enablePicsCache) {
let cache = this.picsCache;
cache.apps = cache.apps || {};
cache.packages = cache.packages || {};
(body.apps || []).forEach((app) => {
let appInfoVdf = app.buffer.toString('utf8');
// It seems that Steam appends a NUL byte. Unsure if this is universal or not, but to make sure
// that things work regardless of whether there's a NUL byte at the end, just remove it if it's there.
appInfoVdf = appInfoVdf.replace(/\0$/, '');
let data = {
changenumber: app.change_number,
missingToken: !!app.missing_token,
appinfo: VDF.parse(appInfoVdf).appinfo
};
if ((!cache.apps[app.appid] && requestType == PICSRequestType.Changelist) || (cache.apps[app.appid] && cache.apps[app.appid].changenumber != data.changenumber)) {
// Only emit the event if we previously didn't have the appinfo, or if the changenumber changed
this.emit('appUpdate', app.appid, data);
}
cache.apps[app.appid] = data;
app._parsedData = data;
});
(body.packages || []).forEach((pkg) => {
let data = {
changenumber: pkg.change_number,
missingToken: !!pkg.missing_token,
packageinfo: pkg.buffer ? BinaryKVParser.parse(pkg.buffer)[pkg.packageid] : null
};
if ((!cache.packages[pkg.packageid] && requestType == PICSRequestType.Changelist) || (cache.packages[pkg.packageid] && cache.packages[pkg.packageid].changenumber != data.changenumber)) {
this.emit('packageUpdate', pkg.packageid, data);
}
cache.packages[pkg.packageid] = data;
pkg._parsedData = data;
// Request info for all the apps in this package, if this request didn't originate from the license list
if (requestType != PICSRequestType.Licenses) {
let appids = (pkg.packageinfo || {}).appids || [];
this.getProductInfo(appids, [], false, null, PICSRequestType.PackageContents).catch(() => {});
}
});
}
(body.unknown_appids || []).forEach((appid) => {
response.unknownApps.push(appid);
let index = appids.indexOf(appid);
if (index != -1) {
appids.splice(index, 1);
}
});
(body.unknown_packageids || []).forEach((packageid) => {
response.unknownPackages.push(packageid);
let index = packageids.indexOf(packageid);
if (index != -1) {
packageids.splice(index, 1);
}
});
(body.apps || []).forEach((app) => {
// _parsedData will be populated if we have the PICS cache enabled.
// If we don't, we need to parse the data here.
let appInfoVdf = app.buffer.toString('utf8');
// It seems that Steam appends a NUL byte. Unsure if this is universal or not, but to make sure
// that things work regardless of whether there's a NUL byte at the end, just remove it if it's there.
appInfoVdf = appInfoVdf.replace(/\0$/, '');
response.apps[app.appid] = app._parsedData || {
changenumber: app.change_number,
missingToken: !!app.missing_token,
appinfo: VDF.parse(appInfoVdf).appinfo
};
let index = appids.indexOf(app.appid);
if (index != -1) {
appids.splice(index, 1);
}
});
(body.packages || []).forEach((pkg) => {
response.packages[pkg.packageid] = pkg._parsedData || {
changenumber: pkg.change_number,
missingToken: !!pkg.missing_token,
packageinfo: pkg.buffer ? BinaryKVParser.parse(pkg.buffer)[pkg.packageid] : null
};
let index = packageids.indexOf(pkg.packageid);
if (index != -1) {
packageids.splice(index, 1);
}
});
// appids and packageids contain the list of IDs that we're still waiting on data for
if (appids.length === 0 && packageids.length === 0) {
if (!inclTokens) {
return resolve(response);
}
// We want tokens
let tokenlessAppids = [];
let tokenlessPackages = [];
for (let appid in response.apps) {
if (response.apps[appid].missingToken) {
tokenlessAppids.push(parseInt(appid, 10));
}
}
for (let packageid in response.packages) {
if (response.packages[packageid].missingToken) {
tokenlessPackages.push(parseInt(packageid, 10));
}
}
if (tokenlessAppids.length == 0 && tokenlessPackages.length == 0) {
// No tokens needed
return resolve(response);
}
try {
let {
appTokens,
packageTokens
} = await this.getProductAccessToken(tokenlessAppids, tokenlessPackages);
let tokenApps = [];
let tokenPackages = [];
for (let appid in appTokens) {
tokenApps.push({appid: parseInt(appid, 10), access_token: appTokens[appid]});
}
for (let packageid in packageTokens) {
tokenPackages.push({
packageid: parseInt(packageid, 10),
access_token: packageTokens[packageid]
});
}
// Now we have the tokens. Request the data.
let {apps, packages} = await this.getProductInfo(tokenApps, tokenPackages, false);
for (let appid in apps) {
response.apps[appid] = apps[appid];
let index = response.unknownApps.indexOf(parseInt(appid, 10));
if (index != -1) {
response.unknownApps.splice(index, 1);
}
}
for (let packageid in packages) {
response.packages[packageid] = packages[packageid];
let index = response.unknownPackages.indexOf(parseInt(packageid, 10));
if (index != -1) {
response.unknownPackages.splice(index, 1);
}
}
resolve(response);
} catch (ex) {
return reject(ex);
}
}
});
});
}
/**
* Get access tokens for some apps and/or packages
* @param {int[]} apps - Array of appids
* @param {int[]} packages - Array of packageids
* @param {function} [callback] - First arg is an object of (appid => access token), second is the same for packages, third is array of appids for which tokens are denied, fourth is the same for packages
* @returns {Promise<{appTokens: Object<string, string>, packageTokens: Object<string, string>, appDeniedTokens: number[], packageDeniedTokens: number[]}>}
*/
getProductAccessToken(apps, packages, callback) {
let args = ['appTokens', 'packageTokens', 'appDeniedTokens', 'packageDeniedTokens'];
return StdLib.Promises.timeoutCallbackPromise(10000, args, callback, (resolve, reject) => {
this._send(EMsg.ClientPICSAccessTokenRequest, {
packageids: packages,
appids: apps
}, (body) => {
let appTokens = {};
let packageTokens = {};
(body.app_access_tokens || []).forEach((app) => {
appTokens[app.appid] = app.access_token;
});
(body.package_access_tokens || []).forEach((pkg) => {
packageTokens[pkg.packageid] = pkg.access_token;
});
resolve({
appTokens,
packageTokens,
appDeniedTokens: body.app_denied_tokens || [],
packageDeniedTokens: body.package_denied_tokens || []
});
});
});
}
/**
* @protected
*/
_resetChangelistUpdateTimer() {
this._clearChangelistUpdateTimer();
if (this.options.enablePicsCache && this.options.changelistUpdateInterval) {
if (this.options.changelistUpdateInterval < 1000) {
this._warn(`Option changelistUpdateInterval with value '${this.options.changelistUpdateInterval}' is below the required minimum of '1000'. Clamping to '1000'.`);
this.options.changelistUpdateInterval = 1000;
}
this._changelistUpdateTimer = setTimeout(this._getChangelistUpdate.bind(this), this.options.changelistUpdateInterval);
}
}
/**
* @protected
*/
_clearChangelistUpdateTimer() {
if (this._changelistUpdateTimer) {
clearTimeout(this._changelistUpdateTimer);
this._changelistUpdateTimer = null;
}
}
/**
* @protected
*/
async _getChangelistUpdate() {
this._clearChangelistUpdateTimer();
if (!this.options.enablePicsCache || !this.options.changelistUpdateInterval) {
return;
}
let result = null;
try {
result = await this.getProductChanges(this.picsCache.changenumber);
} catch (ex) {
this.emit('debug', `Error getting changelist update: ${ex.message}`);
this._resetChangelistUpdateTimer();
return;
}
let cache = this.picsCache;
let {appChanges, packageChanges, currentChangeNumber} = result;
cache.apps = cache.apps || {};
cache.packages = cache.packages || {};
appChanges = appChanges.map(app => app.appid);
packageChanges = packageChanges.map(pkg => pkg.packageid);
let ourApps = appChanges.filter(appid => this.options.picsCacheAll || cache.apps[appid]);
let ourPackages = packageChanges.filter(pkgid => this.options.picsCacheAll || cache.packages[pkgid]);
if (ourApps.length + ourPackages.length === 0) {
// We're done here
if (currentChangeNumber != cache.changenumber && cache.changenumber != 0) {
this.emit('changelist', currentChangeNumber, appChanges, packageChanges);
}
cache.changenumber = currentChangeNumber;
this._resetChangelistUpdateTimer();
return;
}
try {
// Get any access tokens we may need
result = await this.getProductAccessToken(ourApps, ourPackages);
} catch (ex) {
this.emit('debug', `Error getting tokens for changelist: ${ex.message}`);
this._resetChangelistUpdateTimer();
return;
}
this.emit('changelist', currentChangeNumber, appChanges, packageChanges);
let {appTokens, packageTokens} = result;
cache.changenumber = currentChangeNumber;
this._resetChangelistUpdateTimer();
let index = -1;
for (let appid in appTokens) {
if (Object.hasOwnProperty.call(appTokens, appid) && (index = ourApps.indexOf(parseInt(appid, 10))) != -1) {
ourApps[index] = {appid: parseInt(appid, 10), access_token: appTokens[appid]};
}
}
for (let packageid in packageTokens) {
if (Object.hasOwnProperty.call(packageTokens, packageid) && (index = ourPackages.indexOf(parseInt(packageid, 10))) != -1) {
ourPackages[index] = {packageid: parseInt(packageid, 10), access_token: packageTokens[packageid]};
}
}
// Add a no-op catch in case there's some kind of error
this.getProductInfo(ourApps, ourPackages, false, null, PICSRequestType.Changelist).catch(() => {
});
}
/**
* @param {int} appid
* @protected
*/
_addAppToCache(appid) {
if (!this.options.enablePicsCache || appid == 0) {
return;
}
let apps = this.picsCache.apps || {};
if (apps[appid]) {
return;
}
this.getProductInfo([appid], [], false, null, PICSRequestType.AddToCache).catch(() => {
});
}
/**
* Throws error if enablePicsCache option is not enabled or ownershipCached event has not been emitted.
* @protected
*/
_ensurePicsCache() {
if (!this.options.enablePicsCache) {
throw new Error('PICS cache is not enabled.');
}
if (!this.picsCache.ownershipModified) {
throw new Error('No data in PICS package cache yet.');
}
}
/**
* @protected
*/
async _getLicenseInfo() {
if (!this.options.enablePicsCache || !this.steamID) {
return;
}
// Get all owned license id's
let packageids = this.steamID.type == SteamID.Type.ANON_USER
? [ANONYMOUS_DEDICATED_SERVER_COMP]
: this.licenses.map(license => license.package_id);
let result;
try {
result = await this.getProductInfo([], packageids, true, undefined, PICSRequestType.Licenses);
} catch (ex) {
this.emit('debug', `Error retrieving package info for licenses: ${ex.message}`);
return;
}
let {packages} = result;
// Request info for all the apps in these packages
let appids = [];
for (let pkgid in packages) {
((packages[pkgid].packageinfo || {}).appids || []).filter(appid => !appids.includes(appid)).forEach(appid => appids.push(appid));
}
try {
await this.getProductInfo(appids, [], true, undefined, PICSRequestType.PackageContents);
this.picsCache.ownershipModified = Date.now();
/**
* @event SteamUser#appOwnershipCached
* @deprecated Use {@link SteamUser#event:ownershipCached} instead
*/
/**
* Emitted when ownership status of packages and apps is loaded and cached.
* @event SteamUser#ownershipCached
*/
this.emit('appOwnershipCached'); // legacy event name
this.emit('ownershipCached');
} catch (ex) {
this.emit('debug', `Error retrieving app info for licenses: ${ex.message}`);
}
}
/**
* Get list of appids this account owns. Only works if enablePicsCache option is enabled and ownershipCached event
* has been emitted.
* @param {PackageFilter|PackageFilterFunction} filter - Options for what counts for ownership, or a custom filter function
* @returns {number[]}
*/
getOwnedApps(filter) {
this._ensurePicsCache();
let ownedPackages = this.getOwnedPackages(filter);
let appids = {};
ownedPackages.forEach((pkg) => {
if (!this.picsCache.packages[pkg]) {
this._warn(`Failed to get owned apps for package ${pkg}`);
return;
}
pkg = this.picsCache.packages[pkg];
if (!pkg.packageinfo) {
this._warn(`Failed to get owned apps for package ${pkg}`);
return;
}
pkg = pkg.packageinfo;
(pkg.appids || []).forEach((appid) => {
if (!appids[appid]) {
appids[appid] = true;
}
});
});
appids = Object.keys(appids).map(appid => parseInt(appid, 10));
appids.sort(sortNumeric);
return appids;
}
/**
* Check if this account owns an app. Only works if enablePicsCache option is enabled and ownershipCached event
* has been emitted.
* @param {int} appid
* @param {PackageFilter|PackageFilterFunction} filter - Options for what counts for ownership, or a custom filter function
* @returns {boolean}
*/
ownsApp(appid, filter) {
return this.getOwnedApps(filter).indexOf(parseInt(appid, 10)) != -1;
}
/**
* Returns an array of depot IDs this account owns. Only works if enablePicsCache option is enabled and ownershipCached event
* has been emitted.
* @param {PackageFilter|PackageFilterFunction} filter - Options for what counts for ownership, or a custom filter function
* @returns {number[]}
*/
getOwnedDepots(filter) {
this._ensurePicsCache();
let ownedPackages = this.getOwnedPackages(filter);
let depotids = {};
ownedPackages.forEach((pkg) => {
if (!this.picsCache.packages[pkg]) {
this._warn(`Failed to get owned depots for package ${pkg}`);
return;
}
pkg = this.picsCache.packages[pkg];
if (!pkg.packageinfo) {
this._warn(`Failed to get owned depots for package ${pkg}`);
return;
}
pkg = pkg.packageinfo;
(pkg.depotids || []).forEach(function(depotid) {
if (!depotids[depotid]) {
depotids[depotid] = true;
}
});
});
depotids = Object.keys(depotids).map(depotid => parseInt(depotid, 10));
depotids.sort(sortNumeric);
return depotids;
}
/**
* Check if this account owns a depot. Only works if enablePicsCache option is enabled and ownershipCached event
* has been emitted.
* @param {int} depotid
* @param {PackageFilter|PackageFilterFunction} filter - Options for what counts for ownership, or a custom filter function
* @returns {boolean}
*/
ownsDepot(depotid, filter) {
return this.getOwnedDepots(filter).indexOf(parseInt(depotid, 10)) != -1;
}
/**
* Returns an array of licenses this account owns.
* @returns {Proto_CMsgClientLicenseList_License[]}
* @protected
*/
_getOwnedLicenses() {
if (this.steamID.type == SteamID.Type.ANON_USER) {
throw new Error('Anonymous user accounts cannot own licenses');
}
if (!this.licenses) {
throw new Error('We don\'t have our license list yet.');
}
return this.licenses;
}
/**
* Returns an array of package IDs this account owns (different from owned licenses). The filter only
* works, if enablePicsCache option is enabled and ownershipCached event has been emitted.
* @param {PackageFilter|PackageFilterFunction} filter - Options for what counts for ownership, or a custom filter function
* @returns {number[]}
*/
getOwnedPackages(filter) {
// We're anonymous
if (this.steamID.type == SteamID.Type.ANON_USER) {
return [ANONYMOUS_DEDICATED_SERVER_COMP];
}
// We're an individual user
let packages = this._getOwnedLicenses();
// If passed filter argument is empty, and global ownership filter is set, use it
if (!filter && this.options.ownershipFilter) {
filter = this.options.ownershipFilter;
}
// Support for the old syntax and behavior (<= v4.22.0)
if (!filter) {
// Slight deviation of old behavior: don't return expired licenses
return this._returnPackages(packages);
}
// If filter options is a boolean, we asssume it's excludeSharedLicenses
if (typeof filter === 'boolean') {
filter = {excludeShared: filter};
}
// New behavior from this point on
// Handle filter options
if (typeof filter === 'object') {
const defaults = {
excludeFree: false, // By default, include free licenses (FreeOnDemand, NoCost, etc.)
excludeShared: false, // By default, include shared licenses
excludeExpiring: false // By default, include licenses that are going to expire in the future (free weekends)
};
if (typeof this.options.ownershipFilter === 'object') {
filter = Object.assign(defaults, this.options.ownershipFilter, filter);
} else {
filter = Object.assign(defaults, filter);
}
// No PICS cache needed: {F=0,S=1,E=0}, {F=0,S=0,E=0}
if (!filter.excludeFree && !filter.excludeExpiring) {
if (filter.excludeShared) {
packages = packages.filter(license => license.owner_id == this.steamID.accountid);
}
return this._returnPackages(packages);
}
}
// From this point, we need PICS cache to be enabled
this._ensurePicsCache();
// Determine the filter function
let packageFilter = null;
if (typeof filter === 'function') {
packageFilter = filter;
} else if (typeof filter === 'object') {
// Possible options: {F=1,S=1,E=1}, {F=1,S=0,E=1}, {F=0,S=1,E=1}, {F=0,S=0,E=1}, {F=1,S=1,E=0}, {F=1,S=0,E=0}
const freeLicenseBillingTypes = [
EBillingType.NoCost,
EBillingType.GuestPass,
EBillingType.FreeOnDemand,
EBillingType.FreeCommercialLicense
];
packageFilter = (license) => {
// If expired, filter it out, regardless of the filter options
// Assumes: license expired flag <=> package expirytime passed
if (license.flags & ELicenseFlags.Expired) {
return false;
}
// If exclude shared licenses
if (filter.excludeShared && license.owner_id !== this.steamID.accountid) {
return false;
}
let id = license.package_id;
if (!this.picsCache.packages[id] || !this.picsCache.packages[id].packageinfo) {
this._warn(`Failed to filter package ${id} (no PICS cache info available)`);
return false;
}
let pkg = this.picsCache.packages[id].packageinfo;
// If exclude all free (sub 0 is covered by NoCost)
if (filter.excludeFree && freeLicenseBillingTypes.includes(pkg.billingtype)) {
return false;
}
// If exclude all expiring licenses (not free promotions, which are yours to keep permanently)
// noinspection RedundantIfStatementJS
if (
filter.excludeExpiring &&
(
pkg.billingtype == EBillingType.GuestPass ||
(pkg.extended && pkg.extended.expirytime && !pkg.extended.freepromotion)
)
) {
// return false, since this license is temporary (but not expired, as expired has been filtered out already)
return false;
}
return true;
};
}
if (packageFilter === null) {
this._warn('Somehow neither a function, or a filter object, is provided for the ownership filter function');
}
return this._returnPackages(packages, packageFilter);
}
/**
* Note: The reason why ELicenseFlags.Expired licenses are not filtered out from the beginning of getOwnedPackages(),
* is so people who provide a filter function as argument can choose to keep them included.
* @param {Proto_CMsgClientLicenseList_License[]} packages - this.licenses
* @param {PackageFilterFunction} packageFilter - A filter function
* @protected
*/
_returnPackages(packages, packageFilter) {
// If no packageFilter is provided, only keep non-expired licenses
packageFilter = packageFilter || (license => !(license.flags & ELicenseFlags.Expired));
packages = packages.filter(packageFilter);
let packageIds = packages.map(license => license.package_id);
packageIds.sort(sortNumeric);
return packageIds;
}
/**
* Check if this account owns a package. Only works if enablePicsCache option is enabled and ownershipCached event
* has been emitted.
* @param {int|string} packageid
* @param {PackageFilter|PackageFilterFunction} filter - Options for what counts for ownership, or a custom filter function
* @returns {boolean}
*/
ownsPackage(packageid, filter) {
return this.getOwnedPackages(filter).indexOf(parseInt(packageid, 10)) != -1;
}
/**
* Redeem a product code on this account.
* @param {string} key
* @param {function} [callback] - Args (eresult value, SteamUser.EPurchaseResult value, object of (packageid => package names)
* @returns {Promise<{purchaseResultDetails: EPurchaseResult, packageList: Object<string, string>}>}
*/
redeemKey(key, callback) {
return StdLib.Promises.timeoutCallbackPromise(90000, ['purchaseResultDetails', 'packageList'], callback, (resolve, reject) => {
this._send(EMsg.ClientRegisterKey, {key: key}, (body) => {
let packageList = {};
let receiptDetails = BinaryKVParser.parse(body.purchase_receipt_info).MessageObject;
if (receiptDetails.LineItemCount > 0) {
receiptDetails.lineitems.forEach((pkg) => {
let packageID = pkg.PackageID || pkg.packageID || pkg.packageid;
packageList[packageID] = pkg.ItemDescription;
});
}
let err = Helpers.eresultError(body.eresult);
if (err) {
err.purchaseResultDetails = body.purchase_result_details;
err.packageList = packageList;
reject(err);
} else {
resolve({
purchaseResultDetails: body.purchase_result_details,
packageList
});
}
});
});
}
/**
* Request licenses for one or more free-on-demand apps.
* @param {int[]} appIDs
* @param {function} [callback] - Args (err, array of granted packageids, array of granted appids)
* @returns {Promise<{grantedPackageIds: number[], grantedAppIds: number[]}>}
*/
requestFreeLicense(appIDs, callback) {
if (!Array.isArray(appIDs)) {
appIDs = [appIDs];
}
return StdLib.Promises.timeoutCallbackPromise(10000, ['grantedPackageIds', 'grantedAppIds'], callback, (resolve, reject) => {
this._send(EMsg.ClientRequestFreeLicense, {appids: appIDs}, (body) => {
if (body.eresult != EResult.OK) {
reject(Helpers.eresultError(body.eresult));
} else {
resolve({
grantedPackageIds: body.granted_packageids,
grantedAppIds: body.granted_appids
});
}
});
});
}
/**
* Gets your legacy CD key for a game in your library which uses CD keys
* @param {number} appid
* @param {function} [callback]
* @returns {Promise<{key: string}>}
*/
getLegacyGameKey(appid, callback) {
return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, (resolve, reject) => {
let request = Buffer.alloc(4);
request.writeUInt32LE(appid);
this._send(EMsg.ClientGetLegacyGameKey, request, (body) => {
let responseAppId = body.readUint32();
if (responseAppId != appid) {
// Is this even possible?
return reject(new Error(`Received response for wrong appid ${responseAppId}`));
}
let eresult = body.readUint32();
let err = Helpers.eresultError(eresult);
if (err) {
return reject(err);
}
let keyLength = body.readUint32();
if (keyLength == 0) {
// Unsure if this is possible
return reject(new Error('No key returned'));
}
let key = body.readCString();
if (key.length != keyLength - 1) {
// keyLength includes the null terminator
return reject(new Error(`Incorrect key length: expected ${keyLength - 1} but got ${key.length}`));
}
return resolve({key});
});
});
}
}
SteamUserBase.prototype._handlerManager.add(EMsg.ClientLicenseList, function(body) {
this.emit('licenses', body.licenses);
this.licenses = body.licenses;
// Request info for our licenses
if (this.options.enablePicsCache) {
this._getLicenseInfo();
}
});
SteamUserBase.prototype._handlerManager.add(EMsg.ClientPlayingSessionState, function(body) {
this._playingBlocked = body.playing_blocked;
this.emit('playingState', body.playing_blocked, body.playing_app);
this.playingState = {blocked: body.playing_blocked, appid: body.playing_app};
});
function sortNumeric(a, b) {
if (a < b) {
return -1;
} else if (a > b) {
return 1;
}
return 0;
}
module.exports = SteamUserApps;