UNPKG

steam-user

Version:

Steam client for Individual and AnonUser Steam account types

1,326 lines (1,144 loc) 41.6 kB
const BinaryKVParser = require('binarykvparser'); const ByteBuffer = require('bytebuffer'); const StdLib = require('@doctormckay/stdlib'); const SteamID = require('steamid'); const Helpers = require('./helpers.js'); const EClientPersonaStateFlag = require('../enums/EClientPersonaStateFlag.js'); const EFriendRelationship = require('../enums/EFriendRelationship.js'); const EMsg = require('../enums/EMsg.js'); const EResult = require('../enums/EResult.js'); const SteamUserBase = require('./00-base.js'); const SteamUserFamilySharing = require('./familysharing.js'); let g_ProcessPersonaSemaphore = new StdLib.Concurrency.Semaphore(); /** * @typedef {object} UserPersona * @property {EPersonaState} persona_state * @property {number|null} game_played_app_id * @property {number|null} game_server_ip * @property {number|null} game_server_port * @property {number} persona_state_flags * @property {number} online_session_instances * @property {boolean} persona_set_by_user * @property {string} player_name * @property {number|null} query_port * @property {number} steamid_source * @property {Buffer} avatar_hash * @property {string} avatar_url_icon * @property {string} avatar_url_medium * @property {string} avatar_url_full * @property {Date} last_logoff * @property {Date} last_logon * @property {Date} last_seen_online * @property {EClanRank} clank_rank * @property {string|null} game_name * @property {number|null} gameid * @property {Buffer} game_data_blob * @property {Proto_CMsgClientPersonaState_Friend_ClanData} clan_data * @property {string} clan_tag * @property {Proto_CMsgClientPersonaState_Friend_KV} rich_presence * @property {string} [rich_presence_string] * @property {number|null} broadcast_id * @property {number|null} game_lobby_id * @property {number|null} watching_broadcast_accountid * @property {number|null} watching_broadcast_appid * @property {number|null} watching_broadcast_viewers * @property {string|null} watching_broadcast_title * @property {boolean} is_community_banned * @property {boolean} player_name_pending_review * @property {boolean} avatar_pending_review */ class SteamUserFriends extends SteamUserFamilySharing { /** * Set your persona online state and optionally name. * @memberOf SteamUser * @param {EPersonaState} state - Your new online state * @param {string} [name] - Optional. Set a new profile name. */ setPersona(state, name) { this._send(EMsg.ClientChangeStatus, { persona_state: state, player_name: name }); } /** * Set your current UI mode (displays next to your Steam online status in friends) * @param {EClientUIMode} mode - Your new UI mode */ setUIMode(mode) { this._send(EMsg.ClientCurrentUIMode, {uimode: mode}); } /** * Send (or accept) a friend invitiation. * @param {(SteamID|string)} steamID - Either a SteamID object of the user to add, or a string which can parse into one. * @param {function} [callback] - Optional. Called with `err` and `name` parameters on completion. */ addFriend(steamID, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, ['personaName'], callback, true, (resolve, reject) => { this._send(EMsg.ClientAddFriend, {steamid_to_add: Helpers.steamID(steamID).getSteamID64()}, (body) => { if (body.eresult != EResult.OK) { return reject(Helpers.eresultError(body.eresult)); } resolve({ personaName: body.persona_name_added }); }); }); } /** * Remove a friend from your friends list (or decline an invitiation) * @param {(SteamID|string)} steamID - Either a SteamID object of the user to remove, or a string which can parse into one. */ removeFriend(steamID) { if (typeof steamID === 'string') { steamID = new SteamID(steamID); } this._send(EMsg.ClientRemoveFriend, {friendid: steamID.getSteamID64()}); } /** * Block all communication with a user. * @param {(SteamID|string)} steamID - Either a SteamID object of the user to block, or a string which can parse into one. * @param {SteamUser~genericEResultCallback} [callback] - Optional. Called with an `err` parameter on completion. * @return {Promise} */ blockUser(steamID, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, true, (resolve, reject) => { if (typeof steamID === 'string') { steamID = new SteamID(steamID); } let buffer = ByteBuffer.allocate(17, ByteBuffer.LITTLE_ENDIAN); buffer.writeUint64(this.steamID.getSteamID64()); buffer.writeUint64(steamID.getSteamID64()); buffer.writeUint8(1); this._send(EMsg.ClientSetIgnoreFriend, buffer.flip(), (body) => { body.readUint64(); // unknown let err = Helpers.eresultError(body.readUint32()); return err ? reject(err) : resolve(); }); }); } /** * Unblock all communication with a user. * @param {(SteamID|string)} steamID - Either a SteamID object of the user to unblock, or a string which can parse into one. * @param {SteamUser~genericEResultCallback} [callback] - Optional. Called with an `err` parameter on completion. * @return {Promise} */ unblockUser(steamID, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, true, (resolve, reject) => { if (typeof steamID === 'string') { steamID = new SteamID(steamID); } let buffer = ByteBuffer.allocate(17, ByteBuffer.LITTLE_ENDIAN); buffer.writeUint64(this.steamID.getSteamID64()); buffer.writeUint64(steamID.getSteamID64()); buffer.writeUint8(0); this._send(EMsg.ClientSetIgnoreFriend, buffer.flip(), (body) => { body.readUint64(); // unknown let err = Helpers.eresultError(body.readUint32()); return err ? reject(err) : resolve(); }); }); } /** * Create a new quick-invite link that can be used by any Steam user to directly add you as a friend. * @param {{inviteLimit?: int, inviteDuration?: int}} [options] * @param {function} [callback] * @returns {Promise} */ createQuickInviteLink(options, callback) { if (typeof options == 'function') { callback = options; options = {}; } options = options || {}; return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, false, (resolve, reject) => { this._sendUnified('UserAccount.CreateFriendInviteToken#1', { // Accept both camelCase and snake_case for backwards compatibility invite_limit: options.inviteLimit || options.invite_limit || 1, invite_duration: options.inviteDuration || options.invite_duration || null // there's also invite_note, but this doesn't appear to be used anywhere so we don't support it }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } processInviteToken(this.steamID, body); resolve({token: body}); }); }); } /** * Get a list of friend quick-invite links for your account. * @param {function} [callback] * @returns {Promise} */ listQuickInviteLinks(callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, false, (resolve, reject) => { this._sendUnified('UserAccount.GetFriendInviteTokens#1', {}, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } body.tokens.forEach((token) => processInviteToken(this.steamID, token)); resolve(body); }); }); } /** * Revoke an active quick-invite link. * @param {string} linkOrToken - Either the full link, or just the token from the link * @param {function} [callback] * @returns {Promise} */ revokeQuickInviteLink(linkOrToken, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, true, (resolve, reject) => { if (linkOrToken.includes('/')) { // It's a link let parts = linkOrToken.split('/'); parts = parts.filter(part => !!part); // remove any trailing slash linkOrToken = parts[parts.length - 1]; } this._sendUnified('UserAccount.RevokeFriendInviteToken#1', { invite_token: linkOrToken }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } // No data resolve(); }); }); } /** * Get the SteamID to whom a quick-invite link belongs. * @param {string} link * @returns {SteamID|null} - null if the link isn't well-formed */ getQuickInviteLinkSteamID(link) { let match = link.match(/^https?:\/\/s\.team\/p\/([^/]+)\/([^/]+)/); if (!match) { return null; } return Helpers.parseFriendCode(match[1]); } /** * Check whether a given quick-invite link is valid. * @param {string} link * @param {function} [callback] * @returns {Promise<{valid: boolean, steamid: SteamID, invite_duration?: int}>} */ checkQuickInviteLinkValidity(link, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, false, (resolve, reject) => { let match = link.match(/^https?:\/\/s\.team\/p\/([^/]+)\/([^/]+)/); if (!match) { return reject(new Error('Malformed quick-invite link')); } let steamID = Helpers.parseFriendCode(match[1]); let token = match[2]; this._sendUnified('UserAccount.ViewFriendInviteToken#1', { steamid: steamID.toString(), invite_token: token }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } body.steamid = Helpers.steamID(body.steamid); body.invite_duration = body.invite_duration ? parseInt(body.invite_duration, 10) : null; resolve(body); }); }); } /** * Redeem a quick-invite link and add the sender to your friends list. * @param {string} link * @param {function} [callback] * @returns {Promise} */ redeemQuickInviteLink(link, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, true, (resolve, reject) => { let match = link.match(/^https?:\/\/s\.team\/p\/([^/]+)\/([^/]+)/); if (!match) { return reject(new Error('Malformed quick-invite link')); } let steamID = Helpers.parseFriendCode(match[1]); let token = match[2]; this._sendUnified('UserAccount.RedeemFriendInviteToken#1', { steamid: steamID.toString(), invite_token: token }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } // No response data resolve(); }); }); } /** * Requests information about one or more user profiles. * @param {(SteamID[]|string[])} steamids - An array of SteamID objects or strings which can parse into them. * @param {function} [callback] - Optional. Called with `err`, and an object whose keys are 64-bit SteamIDs as strings, and whose values are persona objects. * @return {Promise} */ getPersonas(steamids, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, ['personas'], callback, true, (resolve, reject) => { const Flags = EClientPersonaStateFlag; let flags = Flags.Status | Flags.PlayerName | Flags.QueryPort | Flags.SourceID | Flags.Presence | Flags.Metadata | Flags.LastSeen | Flags.UserClanRank | Flags.GameExtraInfo | Flags.GameDataBlob | Flags.ClanData | Flags.Facebook | Flags.RichPresence | Flags.Broadcast | Flags.Watching; let ids = steamids.map((id) => { if (typeof id === 'string') { return (new SteamID(id)).getSteamID64(); } return id.toString(); }); this._send(EMsg.ClientRequestFriendData, { friends: ids, persona_state_requested: flags }); // Handle response let output = {}; ids.forEach((id) => { Helpers.onceTimeout(10000, this, 'user#' + id, receive); }); function receive(err, sid, user) { if (err) { return reject(err); } let sid64 = sid.getSteamID64(); output[sid64] = user; let index = ids.indexOf(sid64); if (index != -1) { ids.splice(index, 1); } if (ids.length === 0) { resolve({personas: output}); } } }); } /** * Gets the Steam Level of one or more Steam users. * @param {(SteamID[]|string[])} steamids - An array of SteamID objects, or strings which can parse into one. * @param {function} [callback] - Called on completion with `err`, and an object whose keys are 64-bit SteamIDs as strings, and whose values are Steam Level numbers. * @return {Promise} */ getSteamLevels(steamids, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, ['users'], callback, (resolve, reject) => { let accountids = steamids.map((steamID) => { if (typeof steamID === 'string') { return (new SteamID(steamID)).accountid; } else { return steamID.accountid; } }); this._send(EMsg.ClientFSGetFriendsSteamLevels, {accountids}, (body) => { let output = {}; let sid = new SteamID(); sid.universe = SteamID.Universe.PUBLIC; sid.type = SteamID.Type.INDIVIDUAL; sid.instance = SteamID.Instance.DESKTOP; (body.friends || []).forEach((user) => { sid.accountid = user.accountid; output[sid.getSteamID64()] = user.level; }); resolve({users: output}); }); }); } /** * Get the level of your game badge (and also your Steam level). * @param {int} appid - AppID of game in question * @param {function} [callback] * @returns {Promise} */ getGameBadgeLevel(appid, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, ['steamLevel', 'regularBadgeLevel', 'foilBadgeLevel'], callback, (resolve, reject) => { this._sendUnified('Player.GetGameBadgeLevels#1', {appid}, (body) => { let regular = 0; let foil = 0; (body.badges || []).forEach((badge) => { if (badge.series != 1) { return; } if (badge.border_color == 0) { regular = badge.level; } else if (badge.border_color == 1) { foil = badge.level; } }); resolve({ // these two level properties exist because we were using playerLevel while the docs said steamLevel steamLevel: body.player_level, playerLevel: body.player_level, regularBadgeLevel: regular, foilBadgeLevel: foil }); }); }); } /** * Invites a user to a Steam group. Only send group invites in response to a user's request; sending automated group * invites is a violation of the Steam Subscriber Agreement and can get you banned. * @param {(SteamID|string)} userSteamID - The SteamID of the user you're inviting as a SteamID object, or a string that can parse into one * @param {(SteamID|string)} groupSteamID - The SteamID of the group you're inviting the user to as a SteamID object, or a string that can parse into one */ inviteToGroup(userSteamID, groupSteamID) { let buffer = ByteBuffer.allocate(17, ByteBuffer.LITTLE_ENDIAN); buffer.writeUint64(Helpers.steamID(userSteamID).toString()); buffer.writeUint64(Helpers.steamID(groupSteamID).toString()); buffer.writeUint8(1); // unknown this._send(EMsg.ClientInviteUserToClan, buffer.flip()); } /** * Respond to an incoming group invite. * @param {(SteamID|string)} groupSteamID - The group you were invited to, as a SteamID object or a string which can parse into one * @param {boolean} accept - true to join the group, false to ignore invitation */ respondToGroupInvite(groupSteamID, accept) { let buffer = ByteBuffer.allocate(9, ByteBuffer.LITTLE_ENDIAN); buffer.writeUint64(Helpers.steamID(groupSteamID).toString()); buffer.writeUint8(accept ? 1 : 0); this._send(EMsg.ClientAcknowledgeClanInvite, buffer.flip()); } /** * Creates a friends group (or tag) * @param {string} groupName - The name to create the friends group with * @param {function} [callback] * @return {Promise} */ createFriendsGroup(groupName, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, ['groupID'], callback, true, (resolve, reject) => { this._send(EMsg.AMClientCreateFriendsGroup, { groupname: groupName }, (body) => { if (body.eresult != EResult.OK) { return reject(Helpers.eresultError(body.eresult)); } this.myFriendGroups[body.groupid] = { name: groupName, members: [] }; return resolve({groupID: body.groupid}); }); }); } /** * Deletes a friends group (or tag) * @param {int} groupID - The friends group id * @param {function} [callback] * @return {Promise} */ deleteFriendsGroup(groupID, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, true, (resolve, reject) => { this._send(EMsg.AMClientDeleteFriendsGroup, { groupid: groupID }, (body) => { if (body.eresult != EResult.OK) { return reject(Helpers.eresultError(body.eresult)); } delete this.myFriendGroups[groupID]; return resolve(); }); }); } /** * Rename a friends group (tag) * @param {int} groupID - The friends group id * @param {string} newName - The new name to update the friends group with * @param {function} [callback] * @return {Promise} */ renameFriendsGroup(groupID, newName, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, true, (resolve, reject) => { this._send(EMsg.AMClientRenameFriendsGroup, { groupid: groupID, groupname: newName }, (body) => { if (body.eresult != EResult.OK) { return reject(Helpers.eresultError(body.eresult)); } this.myFriendGroups[groupID].name = newName; return resolve(); }); }); } /** * Add an user to friends group (tag) * @param {int} groupID - The friends group * @param {(SteamID|string)} userSteamID - The user to invite to the friends group with, as a SteamID object or a string which can parse into one * @param {function} [callback] * @return {Promise} */ addFriendToGroup(groupID, userSteamID, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, true, (resolve, reject) => { let sid = Helpers.steamID(userSteamID); this._send(EMsg.AMClientAddFriendToGroup, { groupid: groupID, steamiduser: sid.getSteamID64() }, (body) => { if (body.eresult != EResult.OK) { return reject(Helpers.eresultError(body.eresult)); } this.myFriendGroups[groupID].members.push(sid); return resolve(); }); }); } /** * Remove an user to friends group (tag) * @param {int} groupID - The friends group * @param {(SteamID|string)} userSteamID - The user to remove from the friends group with, as a SteamID object or a string which can parse into one * @param {function} [callback] * @return {Promise} */ removeFriendFromGroup(groupID, userSteamID, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, true, (resolve, reject) => { let sid = Helpers.steamID(userSteamID); this._send(EMsg.AMClientRemoveFriendFromGroup, { groupid: groupID, steamiduser: sid.getSteamID64() }, (body) => { if (body.eresult != EResult.OK) { return reject(Helpers.eresultError(body.eresult)); } let index = this.myFriendGroups[groupID].members.findIndex((element) => { return element.getSteamID64() === sid.getSteamID64(); }); if (index > -1) { this.myFriendGroups[groupID].members.splice(index, 1); } return resolve(); }); }); } /** * Get persona name history for one or more users. * @param {SteamID[]|string[]|SteamID|string} userSteamIDs - SteamIDs of users to request aliases for * @param {function} [callback] * @return {Promise} */ getAliases(userSteamIDs, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, ['users'], callback, (resolve, reject) => { if (!(userSteamIDs instanceof Array)) { userSteamIDs = [userSteamIDs]; } userSteamIDs = userSteamIDs.map(Helpers.steamID).map((id) => { return {steamid: id.getSteamID64()}; }); this._send(EMsg.ClientAMGetPersonaNameHistory, { id_count: userSteamIDs.length, Ids: userSteamIDs }, (body) => { let ids = {}; body.responses = body.responses || []; for (let i = 0; i < body.responses.length; i++) { if (body.responses[i].eresult != EResult.OK) { return reject(Helpers.eresultError(body.responses[i].eresult)); } ids[body.responses[i].steamid.toString()] = (body.responses[i].names || []).map((name) => { name.name_since = new Date(name.name_since * 1000); return name; }); } return resolve({users: ids}); }); }); } /** * Set a friend's private nickname. * @param {(SteamID|string)} steamID * @param {string} nickname * @param {function} [callback] * @return {Promise} */ setNickname(steamID, nickname, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, true, (resolve, reject) => { steamID = Helpers.steamID(steamID); this._send(EMsg.AMClientSetPlayerNickname, { steamid: steamID.toString(), nickname }, (body) => { if (body.eresult != EResult.OK) { return reject(Helpers.eresultError(body.eresult)); } // Worked! if (nickname.length == 0) { delete this.myNicknames[steamID.toString()]; } else { this.myNicknames[steamID.toString()] = nickname; } return resolve(); }); }); } /** * Get the list of nicknames you've given to other users. * @param {function} [callback] * @return {Promise} */ getNicknames(callback) { return StdLib.Promises.timeoutCallbackPromise(10000, ['nicknames'], callback, true, (resolve, reject) => { this._sendUnified('Player.GetNicknameList#1', {}, (body) => { let nicks = {}; body.nicknames.forEach(player => nicks[SteamID.fromIndividualAccountID(player.accountid).getSteamID64()] = player.nickname); resolve({nicknames: nicks}); this.emit('nicknameList', nicks); this.myNicknames = nicks; }); }); } /** * Get the localization keys for rich presence for an app on Steam. * @param {int} appID - The app you want rich presence localizations for * @param {string} [language] - The full name of the language you want localizations for (e.g. "english" or "spanish"); defaults to language passed to constructor * @param {function} [callback] * @returns {Promise} */ getAppRichPresenceLocalization(appID, language, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, (resolve, reject) => { let cacheKey = `${appID}_${language}`; let cache = this._richPresenceLocalization[cacheKey]; if (cache && Date.now() - cache.timestamp < (1000 * 60 * 60)) { // Cache for 1 hour return resolve({tokens: cache.tokens}); } if (typeof language == 'function') { callback = language; language = null; } language = language || this.options.language || 'english'; this._sendUnified('Community.GetAppRichPresenceLocalization#1', { appid: appID, language }, (body) => { if (body.appid != appID) { return reject(new Error('Did not get localizations for requested app ' + appID + ' (' + body.appID + ')')); } let tokens = {}; let foundLanguage = false; body.token_lists.forEach((list) => { if (list.language == language) { foundLanguage = true; list.tokens.forEach((token) => { tokens[token.name] = token.value; }); } }); if (!foundLanguage) { return reject(new Error('Did not get localizations for requested language ' + language)); } if (Object.keys(tokens).length > 0) { this._richPresenceLocalization[cacheKey] = { timestamp: Date.now(), tokens }; } return resolve({tokens}); }); }); } /** * Upload some rich presence data to Steam. * @param {int} appid * @param {{steam_display?, connect?}} richPresence */ uploadRichPresence(appid, richPresence) { // Maybe someday in the future we'll have a proper binary KV encoder. For now, just do it by hand. let buf = new ByteBuffer(1024, ByteBuffer.LITTLE_ENDIAN); buf.writeByte(0); buf.writeCString('RP'); for (let i in richPresence) { if (!Object.hasOwnProperty.call(richPresence, i)) { continue; } buf.writeByte(1); // type string buf.writeCString(i); buf.writeCString(richPresence[i]); } buf.writeByte(8); // end buf.writeByte(8); // end again this._send({ // Header msg: EMsg.ClientRichPresenceUpload, proto: {routing_appid: appid} }, { // Request rich_presence_kv: buf.flip().toBuffer() }); } /** * Request rich presence data of one or more users for an appid. * @param {int} appid - The appid to get rich presence data for * @param {SteamID[]|string[]|SteamID|string} steamIDs - SteamIDs of users to request rich presence data for * @param {string} [language] - Language to get localized strings in. Defaults to language passed to constructor. * @param {function} [callback] - Called or resolved with 'users' property with each key being a SteamID and value being the rich presence response if received * @return Promise */ requestRichPresence(appid, steamIDs, language, callback) { if (!Array.isArray(steamIDs)) { steamIDs = [steamIDs]; } if (typeof language == 'function') { callback = language; language = null; } return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, (resolve, reject) => { this._send({ // Header msg: EMsg.ClientRichPresenceRequest, proto: {routing_appid: appid}, }, { // Request steamid_request: steamIDs.map(sid => Helpers.steamID(sid).getSteamID64()) }, async (body) => { // Response let response = {}; body.rich_presence = body.rich_presence || []; for (let rp of body.rich_presence) { let kv = rp.rich_presence_kv; if (!kv || !rp.steamid_user) { continue; } try { let kvObj = BinaryKVParser.parse(kv); // This will throw in the event of there being no RP data (e.g. user not in game) if (kvObj && kvObj.RP) { response[rp.steamid_user] = { richPresence: kvObj.RP, localizedString: null, }; // Do this separately as it will reject if it cannot localize response[rp.steamid_user].localizedString = await this._getRPLocalizedString(appid, kvObj.RP, language); } } catch (e) { // don't care, there's nothing here } } resolve({users: response}); }); }); } /** * Get the list of a user's owned apps. * @param {SteamID|string} steamID - Either a SteamID object or a string that can parse into one * @param {{includePlayedFreeGames?: boolean, filterAppids?: number[], includeFreeSub?: boolean, includeAppInfo?: boolean, skipUnvettedApps?: boolean}} [options] * @param {function} [callback] * @returns {Promise} */ getUserOwnedApps(steamID, options, callback) { if (typeof options == 'function') { callback = options; options = {}; } options = options || {}; return new StdLib.Promises.timeoutCallbackPromise(10000, null, callback, false, (resolve, reject) => { steamID = Helpers.steamID(steamID); this._sendUnified('Player.GetOwnedGames#1', { steamid: steamID.toString(), include_appinfo: options.includeAppInfo ?? true, include_played_free_games: options.includePlayedFreeGames || false, appids_filter: options.filterAppids || undefined, include_free_sub: options.includeFreeSub || false, skip_unvetted_apps: options.skipUnvettedApps ?? true }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } let response = { app_count: body.game_count, apps: body.games.map((app) => { if (app.img_icon_url) { app.img_icon_url = `https://steamcdn-a.akamaihd.net/steamcommunity/public/images/apps/${app.appid}/${app.img_icon_url}.jpg`; } if (app.img_logo_url) { app.img_logo_url = `https://steamcdn-a.akamaihd.net/steamcommunity/public/images/apps/${app.appid}/${app.img_logo_url}.jpg`; } return app; }) }; resolve(response); }); }); } /** * Get a list of friends that play a specific app. * @param {int} appID * @param {function} [callback] * @returns {Promise} */ getFriendsThatPlay(appID, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, (resolve, reject) => { let buf = ByteBuffer.allocate(8, ByteBuffer.LITTLE_ENDIAN); buf.writeUint64(appID); this._send(EMsg.ClientGetFriendsWhoPlayGame, buf.flip(), (body) => { let eresult = body.readUint32(); let err = Helpers.eresultError(eresult); if (err) { return reject(err); } let steamIds = []; let responseAppid = body.readUint64().toString(); let countFriends = body.readUint32().toString(); if (responseAppid != appID) { return reject(new Error('AppID in response does not match request')); } for (let i = 0; i < countFriends; i++) { steamIds.push(new SteamID(body.readUint64().toString())); } return resolve({friends: steamIds}); }); }); } /** * * @param {number} appid * @param {object} tokens * @param {string} [language] * @returns {Promise} * @protected */ _getRPLocalizedString(appid, tokens, language) { return new Promise(async (resolve, reject) => { if (!tokens.steam_display) { // Nothing to do here return reject(); } let localizationTokens; try { localizationTokens = (await this.getAppRichPresenceLocalization(appid, language || this.options.language)).tokens; // Normalize all localization tokens to lowercase for (let i in localizationTokens) { localizationTokens[i.toLowerCase()] = localizationTokens[i]; } } catch (ex) { // Oh well return reject(ex); } let rpTokens = JSON.parse(JSON.stringify(tokens)); // So we don't modify the original objects for (let i in rpTokens) { if (Object.hasOwnProperty.call(rpTokens, i) && localizationTokens[rpTokens[i].toLowerCase()]) { rpTokens[i] = localizationTokens[rpTokens[i].toLowerCase()]; } } let rpString = rpTokens.steam_display; // eslint-disable-next-line while (true) { let newRpString = rpString; for (let i in rpTokens) { if (Object.hasOwnProperty.call(rpTokens, i)) { newRpString = newRpString.replace(new RegExp('%' + i + '%', 'gi'), rpTokens[i]); } } (newRpString.match(/{#[^}]+}/g) || []).forEach((token) => { token = token.substring(1, token.length - 1); if (localizationTokens[token.toLowerCase()]) { newRpString = newRpString.replace(new RegExp('{' + token + '}', 'gi'), localizationTokens[token.toLowerCase()]); } }); if (newRpString == rpString) { break; } else { rpString = newRpString; } } return resolve(rpString); }); } /** * @param {object} user * @returns {Promise<UserPersona>} * @protected */ _processUserPersona(user) { return new Promise((resolve) => { g_ProcessPersonaSemaphore.wait(async (release) => { try { if (typeof user.last_logoff === 'number') { user.last_logoff = new Date(user.last_logoff * 1000); } if (typeof user.last_logon === 'number') { user.last_logon = new Date(user.last_logon * 1000); } if (typeof user.last_seen_online === 'number') { user.last_seen_online = new Date(user.last_seen_online * 1000); } if (typeof user.avatar_hash === 'object' && (Buffer.isBuffer(user.avatar_hash) || ByteBuffer.isByteBuffer(user.avatar_hash))) { let hash = user.avatar_hash.toString('hex'); // handle default avatar if (hash === '0000000000000000000000000000000000000000') { hash = 'fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb'; } user.avatar_url_icon = `https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/${hash.substring(0, 2)}/${hash}`; user.avatar_url_medium = `${user.avatar_url_icon}_medium.jpg`; user.avatar_url_full = `${user.avatar_url_icon}_full.jpg`; user.avatar_url_icon += '.jpg'; } // only delete rich_presence_string if we have confirmation that the user isn't in-game if ((user.rich_presence && user.rich_presence.length == 0) || user.gameid === '0') { delete user.rich_presence_string; return resolve(user); } if (!user.rich_presence) { // if we don't have rich_presence data right now, there's nothing to parse return resolve(user); } let rpTokens = {}; user.rich_presence.forEach((token) => { rpTokens[token.key] = token.value; }); if (!rpTokens.steam_display) { // Nothing to do here return resolve(user); } try { user.rich_presence_string = await this._getRPLocalizedString(user.gameid, rpTokens); } catch (ex) { delete user.rich_presence_string; } return resolve(user); } finally { // always release the lock release(); } }); }); } } function processInviteToken(userSteamId, token) { let friendCode = Helpers.createFriendCode(userSteamId); token.invite_link = `https://s.team/p/${friendCode}/${token.invite_token}`; token.time_created = token.time_created ? new Date(token.time_created * 1000) : null; token.invite_limit = token.invite_limit ? parseInt(token.invite_limit, 10) : null; token.invite_duration = token.invite_duration ? parseInt(token.invite_duration, 10) : null; } // Handlers SteamUserBase.prototype._handlerManager.add(EMsg.ClientPersonaState, function(body) { body.friends.forEach((u) => { /** * @type {Proto_CMsgClientPersonaState_Friend} */ let user = u; let sid = new SteamID(user.friendid.toString()); let sid64 = sid.getSteamID64(); delete user.friendid; if (!this.users[sid64]) { this.users[sid64] = {}; } else { // Replace unknown data in the received object with already-known data for (let i in this.users[sid64]) { if (Object.hasOwnProperty.call(this.users[sid64], i) && Object.hasOwnProperty.call(user, i) && user[i] === null) { user[i] = this.users[sid64][i]; } } } this._processUserPersona(user).then((processedUser) => { if (!this.users[sid64]) { // We must have logged off or disconnected between then and now return; } /** * Emitted when we receive persona info about a user. * You can also listen for user#steamid64 to get info only for a specific user. * * @event SteamUser#user * @param {SteamID} steamID - The SteamID of the user * @param {UserPersona} user - An object containing the user's persona info */ this._emitIdEvent('user', sid, processedUser); if (processedUser.gameid && processedUser.gameid != 0) { this._addAppToCache(processedUser.gameid); } for (let i in processedUser) { if (Object.hasOwnProperty.call(processedUser, i) && processedUser[i] !== null) { this.users[sid64][i] = processedUser[i]; } } }); }); }); SteamUserBase.prototype._handlerManager.add(EMsg.ClientClanState, function(body) { let sid = new SteamID(body.steamid_clan.toString()); let sid64 = sid.getSteamID64(); delete body.steamid_clan; let i; if (!this.groups[sid64]) { this.groups[sid64] = body; } else { // Replace unknown data in the received object with already-known data for (i in this.groups[sid64]) { if (Object.hasOwnProperty.call(this.groups[sid64], i) && Object.hasOwnProperty.call(body, i) && body[i] === null) { body[i] = this.groups[sid64][i]; } } } /** * @typedef {object} GroupPersona * @property {number} clan_account_flags * @property {{clan_name: string, sha_avatar: Buffer}} name_info * @property {{members: number, online: number, chatting: number, in_game: number, chat_room_members: number}} user_counts * @property {{gid: string, event_time: number, headline: string, game_id: string, just_posted: boolean}[]} events * @property {{gid: string, event_time: number, headline: string, game_id: string, just_posted: boolean}[]} announcements * @property {boolean} chat_room_private */ /** * Emitted when we receive info about a Steam group. * You can also listen for group#steamid64 to get info only for a specific group. * * @event SteamUser#group * @param {SteamID} steamID - The SteamID of the group * @param {GroupPersona} group - An object containing the group's info */ this._emitIdEvent('group', sid, body); for (i in body) { if (Object.hasOwnProperty.call(body, i) && body[i] !== null) { this.groups[sid64][i] = body[i]; } } (body.events || []).forEach((event) => { if (!event.just_posted) { return; } /** * Emitted when a new event is posted to a Steam group. * You can also listen for groupEvent#steamid64 to get events only for a specific group. * * @event SteamUser#groupEvent * @param {SteamID} steamID - The SteamID of the group * @param {string} headline - The title of the event * @param {Date} timestamp - The time when the event will start * @param {string} gid - The event's GID * @param {number} gameID - If this is an event for a game, this is the game's appid */ this._emitIdEvent('groupEvent', sid, event.headline, new Date(event.event_time * 1000), event.gid, event.game_id); }); (body.announcements || []).forEach((announcement) => { if (!announcement.just_posted) { return; } /** * Emitted when a new announcement is posted to a Steam group. * You can also listen for groupAnnouncement#steamid64 to get announcements only for a specific group. * * @event SteamUser#groupAnnouncement * @param {SteamID} steamID - The SteamID of the group * @param {string} headline - The title of the announcement * @param {string} gid - The announcement's GID */ this._emitIdEvent('groupAnnouncement', sid, announcement.headline, announcement.gid.toString()); }); }); SteamUserBase.prototype._handlerManager.add(EMsg.ClientFriendsList, function(body) { (body.friends || []).forEach((relationship) => { let sid = new SteamID(relationship.ulfriendid.toString()); let key = sid.type == SteamID.Type.CLAN ? 'myGroups' : 'myFriends'; if (body.bincremental) { /** * Emitted when a relationship with a Steam group changes. The relationship in myGroups is updated after this is emitted. * * @event SteamUser#groupRelationship * @param {SteamID} steamID - The SteamID of the group * @param {EFriendRelationship} relationship - Your new relationship with the group */ /** * Emitted when a relationship with a Steam user changes. The relationship in myFriends is updated after this is emitted. * * @event SteamUser#friendRelationship * @param {SteamID} steamID - The SteamID of the group * @param {EFriendRelationship} relationship - Your new relationship with the user */ // This isn't an initial download of the friends list; something changed let previousRelationship = this[key][sid.getSteamID64()]; if (typeof previousRelationship == 'undefined') { previousRelationship = EFriendRelationship.None; } if (relationship.efriendrelationship != previousRelationship) { this._emitIdEvent( key == 'myGroups' ? 'groupRelationship' : 'friendRelationship', sid, relationship.efriendrelationship, previousRelationship ); } } // EFriendRelationship.None and EClanRelationship.None are both 0. if (relationship.efriendrelationship == EFriendRelationship.None) { delete this[key][sid.getSteamID64()]; } else { this[key][sid.getSteamID64()] = relationship.efriendrelationship; } }); if (!body.bincremental) { /** * Emitted when our entire friends list is loaded. * * @event SteamUser#friendsList */ /** * Emitted when our entire group list is loaded. * * @event SteamUser#groupList */ this.emit('friendsList'); this.emit('groupList'); // Request persona info for all our friends let friends = Object.keys(this.myFriends).filter(steamID => this.myFriends[steamID] == EFriendRelationship.Friend); this.getPersonas(friends, () => { process.nextTick(() => { this.emit('friendPersonasLoaded'); }); }); } }); SteamUserBase.prototype._handlerManager.add(EMsg.ClientFriendsGroupsList, function(body) { let groupList = {}; body.friendGroups.forEach(function(group) { groupList[group.nGroupID] = { name: group.strGroupName, members: [] }; }); body.memberships.forEach(function(friend) { let sid = new SteamID(friend.ulSteamID.toString()); groupList[friend.nGroupID].members.push(sid); if (body.bincremental) { // For now it doesn't really fire, so can't really check on how to do remove / add stuff with an emit. } }); if (!body.bincremental) { /** * Emitted when our entire friends group list is loaded. * * @event SteamUser#friendsGroupList */ this.emit('friendsGroupList', groupList); } this.myFriendGroups = groupList; }); SteamUserBase.prototype._handlerManager.add(EMsg.ClientPlayerNicknameList, function(body) { let myNicknames = JSON.parse(JSON.stringify(this.myNicknames)); // clone body.nicknames.forEach(function(user) { if (body.removal) { delete myNicknames[user.steamid]; } else { myNicknames[user.steamid] = user.nickname; } }); if (!body.incremental) { this.emit('nicknameList', myNicknames); } this.myNicknames = myNicknames; }); SteamUserBase.prototype._handlerManager.add('PlayerClient.NotifyFriendNicknameChanged#1', function(body) { let sid = SteamID.fromIndividualAccountID(body.accountid); this.emit('nickname', sid, body.nickname || null); if (!body.nickname) { // removal delete this.myNicknames[sid.getSteamID64()]; } else { this.myNicknames[sid.getSteamID64()] = body.nickname; } }); module.exports = SteamUserFriends;