UNPKG

steam-user

Version:

Steam client for Individual and AnonUser Steam account types

1,501 lines (1,317 loc) 46.1 kB
const BBCode = require('@bbob/parser'); const Crypto = require('crypto'); const {EventEmitter} = require('events'); const StdLib = require('@doctormckay/stdlib'); const SteamID = require('steamid'); const Helpers = require('./helpers.js'); const EChatEntryType = require('../enums/EChatEntryType.js'); const EResult = require('../enums/EResult.js'); const SteamUserBase = require('./00-base.js'); /** * @typedef {object} BBCodeNode * @property {string} tag * @property {object} attrs * @property {(string|BBCodeNode)[]} content */ /** * @typedef {object} ChatRoomGroupState * @property {ChatRoomMember[]} members * @property {ChatRoomState[]} chat_rooms * @property {ChatRoomMember[]} kicked * @property {string} default_chat_id * @property {ChatRoomGroupHeaderState} header_state */ /** * @typedef {object} UserChatRoomGroupState * @property {string} chat_group_id * @property {Date} time_joined * @property {UserChatRoomState[]} user_chat_room_state * @property {EChatRoomNotificationLevel} desktop_notification_level * @property {EChatRoomNotificationLevel} mobile_notification_level * @property {Date|null} time_last_group_ack * @property {boolean} unread_indicator_muted */ /** * @typedef {object} UserChatRoomState * @property {string} chat_id * @property {Date} time_joined * @property {Date|null} time_last_ack * @property {EChatRoomNotificationLevel} desktop_notification_level * @property {EChatRoomNotificationLevel} mobile_notification_level * @property {Date|null} time_last_mention * @property {boolean} unread_indicator_muted * @property {Date} time_first_unread */ /** * @typedef {object} ChatRoomGroupSummary * @property {ChatRoomState[]} chat_rooms * @property {SteamID[]} top_members * @property {string} chat_group_id * @property {string} chat_group_name * @property {number} active_member_count * @property {number} active_voice_member_count * @property {string} default_chat_id * @property {string} chat_group_tagline * @property {number|null} appid * @property {SteamID} steamid_owner * @property {SteamID|null} watching_broadcast_steamid * @property {Buffer|null} chat_group_avatar_sha * @property {string|null} chat_group_avatar_url */ /** * @typedef {object} ChatRoomState * @property {string} chat_id * @property {string} chat_name * @property {boolean} voice_allowed * @property {SteamID[]} members_in_voice * @property {Date} time_last_message * @property {number} sort_order * @property {string} last_message * @property {SteamID} steamid_last_message */ /** * @typedef {object} ChatRoomMember * @property {SteamID} steamid * @property {EChatRoomJoinState} state * @property {EChatRoomGroupRank} rank * @property {Date|null} time_kick_expire * @property {string[]} role_ids */ /** * @typedef {object} ChatRoomGroupHeaderState * @property {string} chat_group_id * @property {string} chat_name * @property {SteamID|null} clanid * @property {SteamID} steamid_owner * @property {number|null} appid * @property {string} tagline * @property {Buffer|null} avatar_sha * @property {string|null} avatar_url * @property {string} default_role_id * @property {{role_id: string, name: string, ordinal: number}[]} roles * @property {ChatRoleActions[]} role_actions * @property {SteamID|null} watching_broadcast_steamid */ /** * @typedef {object} ChatRoleActions * @property {string} role_id * @property {boolean} can_create_rename_delete_channel * @property {boolean} can_kick * @property {boolean} can_ban * @property {boolean} can_invite * @property {boolean} can_change_tagline_avatar_name * @property {boolean} can_chat * @property {boolean} can_view_history * @property {boolean} can_change_group_roles * @property {boolean} can_change_user_roles * @property {boolean} can_mention_all * @property {boolean} can_set_watching_broadcast */ /** * @param {SteamUser} user * @constructor * @extends EventEmitter */ class SteamChatRoomClient extends EventEmitter { constructor(user) { super(); this.user = user; } /** * Creates a new chat room group and invites people to join it. * @param {SteamID[]|string[]|string} [inviteeSteamIds=[]] * @param {string} [name=''] - If omitted, this creates an "ad-hoc" group chat. If named, this creates a saved chat room group. * @param {function} [callback] * @returns {Promise<{chat_group_id: string, state: ChatRoomGroupState, user_chat_state: UserChatRoomGroupState}>} */ createGroup(inviteeSteamIds, name, callback) { // Is inviteeSteamIds a single valid steamid? If so, turn it into an array if (typeof inviteeSteamIds == 'string' || (typeof inviteeSteamIds == 'object' && inviteeSteamIds !== null)) { try { Helpers.steamID(inviteeSteamIds); // throws if not valid steamid inviteeSteamIds = [inviteeSteamIds]; } catch (ex) { // ignore } } if (typeof inviteeSteamIds == 'function') { callback = inviteeSteamIds; inviteeSteamIds = []; name = ''; } if (typeof inviteeSteamIds == 'string') { name = inviteeSteamIds; inviteeSteamIds = []; } if (typeof name == 'function') { callback = name; name = ''; } return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, (resolve, reject) => { this.user._sendUnified('ChatRoom.CreateChatRoomGroup#1', { name: name || '', steamid_invitees: (inviteeSteamIds || []).map(Helpers.steamID).map(sid => sid.toString()) }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } processChatGroupState(body.state); processUserChatGroupState(body.user_chat_state); resolve(body); }); }); } /** * Saves an unnamed "ad-hoc" group chat and converts it into a full-fledged chat room group. * @param {int} groupId * @param {string} name * @param {function} [callback] * @returns {Promise} */ saveGroup(groupId, name, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, true, (resolve, reject) => { this.user._sendUnified('ChatRoom.SaveChatRoomGroup#1', { chat_group_id: groupId, name }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } resolve(); }); }); } /** * Get a list of the chat room groups you're in. * @param {function} [callback] * @returns {Promise<{chat_room_groups: Object<string, {group_summary: ChatRoomGroupSummary, group_state: UserChatRoomGroupState}>}>} */ getGroups(callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, (resolve, reject) => { this.user._sendUnified('ChatRoom.GetMyChatRoomGroups#1', {}, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } body.chat_room_groups = body.chat_room_groups.map(v => processChatRoomSummaryPair(v)); let groups = {}; body.chat_room_groups.forEach((group) => { groups[group.group_summary.chat_group_id] = group; }); body.chat_room_groups = groups; resolve(body); }); }); } /** * Set which groups are actively being chatted in by this session. Only active group chats will receive some events, * like {@link SteamChatRoomClient#event:chatRoomGroupMemberStateChange} * @param {int[]|string[]|int|string} groupIDs - Array of group IDs you want data for * @param {function} [callback] * @returns {Promise<{chat_room_groups: Object<string, ChatRoomGroupState>}>} */ setSessionActiveGroups(groupIDs, callback) { if (!Array.isArray(groupIDs)) { groupIDs = [groupIDs]; } return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, (resolve, reject) => { this.user._sendUnified('ChatRoom.SetSessionActiveChatRoomGroups#1', { chat_group_ids: groupIDs, chat_groups_data_requested: groupIDs }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } let groups = {}; body.chat_states.forEach((group) => { groups[group.header_state.chat_group_id] = processChatGroupState(group); }); resolve({chat_room_groups: groups}); }); }); } /** * Get details from a chat group invite link. * @param {string} linkUrl * @param {function} [callback] * @returns {Promise<{invite_code: string, steamid_sender: SteamID, time_expires: Date|null, group_summary: ChatRoomGroupSummary, time_kick_expire: Date|null, banned: boolean}>} */ getInviteLinkInfo(linkUrl, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, (resolve, reject) => { let match = linkUrl.match(/^https?:\/\/s\.team\/chat\/([^/]+)$/); if (!match) { return reject(new Error('Invalid invite link')); } this.user._sendUnified('ChatRoom.GetInviteLinkInfo#1', {invite_code: match[1]}, (body, hdr) => { if (hdr.proto.eresult == EResult.InvalidParam) { let err = new Error('Invalid invite link'); err.eresult = hdr.proto.eresult; return reject(err); } let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } body = preProcessObject(body); if (Math.floor(body.time_expires / 1000) == Math.pow(2, 31) - 1) { body.time_expires = null; } body.group_summary = processChatGroupSummary(body.group_summary, true); body.user_chat_group_state = body.user_chat_group_state ? processUserChatGroupState(body.user_chat_group_state, true) : null; body.banned = !!body.banned; body.invite_code = match[1]; resolve(body); }); }); } /** * Get the chat room group info for a clan (Steam group). Allows you to join a group chat. * @param {SteamID|string} clanSteamID - The group's SteamID or a string that can parse into one * @param {function} [callback] * @returns {Promise<{chat_group_summary: ChatRoomGroupSummary}>} */ getClanChatGroupInfo(clanSteamID, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, (resolve, reject) => { clanSteamID = Helpers.steamID(clanSteamID); if (clanSteamID.type != SteamID.Type.CLAN) { return reject(new Error('SteamID is not for a clan')); } // just set these to what they should be clanSteamID.universe = SteamID.Universe.PUBLIC; clanSteamID.instance = SteamID.Instance.ALL; this.user._sendUnified('ClanChatRooms.GetClanChatRoomInfo#1', { steamid: clanSteamID.toString(), autocreate: true }, (body, hdr) => { if (hdr.proto.eresult == EResult.Busy) { // Why "Busy"? Because Valve. let err = new Error('Invalid clan ID'); err.eresult = hdr.proto.eresult; return reject(err); } let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } body.chat_group_summary = processChatGroupSummary(body.chat_group_summary); resolve(body); }); }); } /** * Join a chat room group. * @param {int|string} groupId - The group's ID * @param {string} [inviteCode] - An invite code to join this chat. Not necessary for public Steam groups. * @param {function} [callback] * @returns {Promise<{state: ChatRoomGroupState, user_chat_state: UserChatRoomGroupState}>} */ joinGroup(groupId, inviteCode, callback) { if (typeof inviteCode === 'function') { callback = inviteCode; inviteCode = undefined; } return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, (resolve, reject) => { this.user._sendUnified('ChatRoom.JoinChatRoomGroup#1', { chat_group_id: groupId, invite_code: inviteCode }, (body, hdr) => { if (hdr.proto.eresult == EResult.InvalidParam) { let err = new Error('Invalid group ID or invite code'); err.eresult = hdr.proto.eresult; return reject(err); } let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } body = preProcessObject(body); body.state = processChatGroupState(body.state, true); body.user_chat_state = processUserChatGroupState(body.user_chat_state, true); resolve(body); }); }); } /** * Leave a chat room group * @param {int} groupId * @param {function} [callback] * @returns {Promise} */ leaveGroup(groupId, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, true, (resolve, reject) => { this.user._sendUnified('ChatRoom.LeaveChatRoomGroup#1', { chat_group_id: groupId }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } resolve(); }); }); } /** * Invite a friend to a chat room group. * @param {int} groupId * @param {SteamID|string} steamId * @param {function} [callback] * @returns {Promise} */ inviteUserToGroup(groupId, steamId, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, true, (resolve, reject) => { this.user._sendUnified('ChatRoom.InviteFriendToChatRoomGroup#1', { chat_group_id: groupId, steamid: Helpers.steamID(steamId).toString() }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } resolve(); }); }); } /** * Create an invite link for a given chat group. * @param {int} groupId * @param {{secondsValid?: int, voiceChatId?: int}} [options] * @param {function} [callback] * @returns {Promise<{invite_code: string, invite_url: string, seconds_valid: number}>} */ createInviteLink(groupId, options, callback) { if (typeof options == 'function') { callback = options; options = {}; } options = options || {}; return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, (resolve, reject) => { this.user._sendUnified('ChatRoom.CreateInviteLink#1', { chat_group_id: groupId, seconds_valid: options.secondsValid || 60 * 60, chat_id: options.voiceChatId }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } body.invite_url = 'https://s.team/chat/' + body.invite_code; resolve(body); }); }); } /** * Get all active invite links for a given chat group. * @param {int} groupId * @param {function} [callback] * @returns {Promise<{invite_links: {invite_code: string, invite_url: string, steamid_creator: SteamID, time_expires: Date|null, chat_id: string}[]}>} */ getGroupInviteLinks(groupId, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, (resolve, reject) => { this.user._sendUnified('ChatRoom.GetInviteLinksForGroup#1', { chat_group_id: groupId }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } body.invite_links = body.invite_links.map(v => preProcessObject(v)).map((link) => { if (Math.floor(link.time_expires / 1000) == Math.pow(2, 31) - 1) { link.time_expires = null; } if (link.chat_id == 0) { link.chat_id = null; } link.invite_url = 'https://s.team/chat/' + link.invite_code; return link; }); resolve(body); }); }); } /** * Revoke and delete an active invite link. * @param {string} linkUrl * @param {function} [callback] * @returns {Promise} */ deleteInviteLink(linkUrl, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, true, async (resolve, reject) => { let details = await this.getInviteLinkInfo(linkUrl); this.user._sendUnified('ChatRoom.DeleteInviteLink#1', { chat_group_id: details.group_summary.chat_group_id, invite_code: details.invite_code }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } resolve(); }); }); } /** * Send a direct chat message to a friend. * @param {SteamID|string} steamId * @param {string} message * @param {{[chatEntryType], [containsBbCode]}} [options] * @param {function} [callback] * @returns {Promise<{modified_message: string, server_timestamp: Date, ordinal: number}>} */ sendFriendMessage(steamId, message, options, callback) { if (typeof options === 'function') { callback = options; options = {}; } else if (!options) { options = {}; } if (!options.chatEntryType) { options.chatEntryType = EChatEntryType.ChatMsg; } if (options.chatEntryType && typeof options.containsBbCode === 'undefined') { options.containsBbCode = true; } if (options.containsBbCode) { message = message.replace(/\[/g, '\\['); } return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, true, (resolve, reject) => { this.user._sendUnified('FriendMessages.SendMessage#1', { steamid: Helpers.steamID(steamId).toString(), chat_entry_type: options.chatEntryType, message, contains_bbcode: options.containsBbCode }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } body = preProcessObject(body); body.ordinal = body.ordinal || 0; body.modified_message = body.modified_message || message; body.message_bbcode_parsed = parseBbCode(body.modified_message); resolve(body); }); }); } /** * Inform a friend that you're typing a message to them. * @param {SteamID|string} steamId * @param {function} [callback] * @returns {Promise} */ sendFriendTyping(steamId, callback) { return this.sendFriendMessage(steamId, '', {chatEntryType: EChatEntryType.Typing}, callback); } /** * Send a message to a chat room. * @param {int|string} groupId * @param {int|string} chatId * @param {string} message * @param {function} [callback] * @returns {Promise<{modified_message: string, server_timestamp: Date, ordinal: number}>} */ sendChatMessage(groupId, chatId, message, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, (resolve, reject) => { this.user._sendUnified('ChatRoom.SendChatMessage#1', { chat_group_id: groupId, chat_id: chatId, message }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } body = preProcessObject(body); body.ordinal = body.ordinal || 0; body.modified_message = body.modified_message || message; body.message_bbcode_parsed = parseBbCode(body.modified_message); resolve(body); }); }); } /** * Get a list of which friends we have "active" (recent) message sessions with. * @param {{conversationsSince?: Date|int}} [options] * @param {function} [callback] * @returns {Promise<{sessions: {steamid_friend: SteamID, time_last_message: Date, time_last_view: Date, unread_message_count: int}[], timestamp: Date}>} */ getActiveFriendMessageSessions(options, callback) { if (typeof options === 'function') { callback = options; options = {}; } options = options || {}; return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, (resolve, reject) => { let lastmessage_since = options.conversationsSince ? convertDateToUnix(options.conversationsSince) : undefined; this.user._sendUnified('FriendMessages.GetActiveMessageSessions#1', { lastmessage_since }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } let output = { sessions: body.message_sessions || [], timestamp: body.timestamp ? new Date(body.timestamp * 1000) : null }; output.sessions = output.sessions.map((session) => { return { steamid_friend: SteamID.fromIndividualAccountID(session.accountid_friend), time_last_message: session.last_message ? new Date(session.last_message * 1000) : null, time_last_view: session.last_view ? new Date(session.last_view * 1000) : null, unread_message_count: session.unread_message_count }; }); resolve(output); }); }); } /** * Get your chat message history with a Steam friend. * @param {SteamID|string} friendSteamId * @param {{maxCount?: int, wantBbcode?: boolean, startTime?: Date|int, startOrdinal?: int, lastTime?: Date|int, lastOrdinal?: int}} [options] * @param {function} [callback] * @returns {Promise<{messages: {sender: SteamID, server_timestamp: Date, ordinal: int, message: string, message_bbcode_parsed: null|Array<(BBCodeNode|string)>}[], more_available: boolean}>} */ getFriendMessageHistory(friendSteamId, options, callback) { if (typeof options == 'function') { callback = options; options = {}; } options = options || {}; return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, async (resolve, reject) => { let steamid2 = Helpers.steamID(friendSteamId).toString(); let count = options.maxCount || 100; let bbcode_format = options.wantBbcode !== false; let rtime32_start_time = options.startTime ? convertDateToUnix(options.startTime) : undefined; let start_ordinal = rtime32_start_time ? options.startOrdinal : undefined; let time_last = options.lastTime ? convertDateToUnix(options.lastTime) : Math.pow(2, 31) - 1; let ordinal_last = time_last ? options.lastOrdinal : undefined; let userLastViewed = 0; try { let activeSessions = await this.getActiveFriendMessageSessions(); let friendSess; if ( activeSessions.sessions && (friendSess = activeSessions.sessions.find(sess => sess.steamid_friend.toString() == steamid2)) ) { userLastViewed = friendSess.time_last_view; } } catch (ex) { this.user.emit('debug', `Exception reported calling getActiveFriendMessageSessions() inside of getFriendMessageHistory(): ${ex.message}`); } this.user._sendUnified('FriendMessages.GetRecentMessages#1', { steamid1: this.user.steamID.toString(), steamid2, count, most_recent_conversation: false, rtime32_start_time, bbcode_format, start_ordinal, time_last, ordinal_last }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } body.messages = body.messages.map(msg => ({ sender: SteamID.fromIndividualAccountID(msg.accountid), server_timestamp: new Date(msg.timestamp * 1000), ordinal: msg.ordinal || 0, message: msg.message, message_bbcode_parsed: bbcode_format ? parseBbCode(msg.message) : null, unread: msg.accountid != this.user.steamID.accountid && (msg.timestamp * 1000) > userLastViewed })); body.more_available = !!body.more_available; resolve(body); }); }); } /** * Get message history for a chat (channel). * @param {int|string} groupId * @param {int|string} chatId * @param {{[maxCount], [lastTime], [lastOrdinal], [startTime], [startOrdinal]}} [options] * @param {function} [callback] * @returns {Promise<{messages: {sender: SteamID, server_timestamp: Date, ordinal: number, message: string, server_message?: {message: EChatRoomServerMessage, string_param?: string, steamid_param?: SteamID}, deleted: boolean}[], more_available: boolean}>} */ getChatMessageHistory(groupId, chatId, options, callback) { if (typeof options === 'function') { callback = options; options = {}; } return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, (resolve, reject) => { let max_count = options.maxCount || 100; let last_time = options.lastTime ? convertDateToUnix(options.lastTime) : undefined; let last_ordinal = options.lastOrdinal; let start_time = options.startTime ? convertDateToUnix(options.startTime) : undefined; let start_ordinal = options.startOrdinal; this.user._sendUnified('ChatRoom.GetMessageHistory#1', { chat_group_id: groupId, chat_id: chatId, last_time, last_ordinal, start_time, start_ordinal, max_count }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } body.messages = body.messages.map((msg) => { msg.sender = SteamID.fromIndividualAccountID(msg.sender); msg.server_timestamp = new Date(msg.server_timestamp * 1000); msg.ordinal = msg.ordinal || 0; msg.message_bbcode_parsed = parseBbCode(msg.message); msg.deleted = !!msg.deleted; if (msg.server_message) { msg.server_message.steamid_param = msg.server_message.accountid_param ? SteamID.fromIndividualAccountID(msg.server_message.accountid_param) : null; delete msg.server_message.accountid_param; } return msg; }); body.more_available = !!body.more_available; resolve(body); }); }); } /** * Acknowledge (mark as read) a friend message * @param {SteamID|string} friendSteamId - The SteamID of the friend whose message(s) you want to acknowledge * @param {Date|int} timestamp - The timestamp of the newest message you're acknowledging (will ack all older messages) */ ackFriendMessage(friendSteamId, timestamp) { this.user._sendUnified('FriendMessages.AckMessage#1', { steamid_partner: Helpers.steamID(friendSteamId).toString(), timestamp: convertDateToUnix(timestamp) }); } /** * Acknowledge (mark as read) a chat room. * @param {int} chatGroupId * @param {int} chatId * @param {Date|int} timestamp - The timestamp of the newest message you're acknowledging (will ack all older messages) */ ackChatMessage(chatGroupId, chatId, timestamp) { this.user._sendUnified('ChatRoom.AckChatMessage#1', { chat_group_id: chatGroupId, chat_id: chatId, timestamp: convertDateToUnix(timestamp) }); } /** * Delete one or more messages from a chat channel. * @param {int|string} groupId * @param {int|string} chatId * @param {{server_timestamp, ordinal}[]} messages * @param {function} [callback] * @returns {Promise} */ deleteChatMessages(groupId, chatId, messages, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, true, (resolve, reject) => { if (!Array.isArray(messages)) { return reject(new Error('The \'messages\' argument must be an array')); } for (let i = 0; i < messages.length; i++) { if (!messages[i] || typeof messages[i] !== 'object' || (!messages[i].server_timestamp && !messages[i].timestamp)) { return reject(new Error('The \'messages\' argument is malformed: must be an array of objects with properties {(server_timestamp|timestamp), ordinal}')); } } messages = messages.map((msg) => { let out = {}; msg.ordinal = msg.ordinal || 0; if (msg.timestamp && !msg.server_timestamp) { msg.server_timestamp = msg.timestamp; } out.server_timestamp = convertDateToUnix(msg.server_timestamp); if (msg.ordinal) { out.ordinal = msg.ordinal; } return out; }); this.user._sendUnified('ChatRoom.DeleteChatMessages#1', { chat_group_id: groupId, chat_id: chatId, messages }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } resolve(); }); }); } /** * Create a text/voice chat room in a group, provided you have permissions to do so. * @param {int|string} groupId - The ID of the group in which you want to create the channel * @param {string} name - The name of your new channel * @param {{isVoiceRoom?: boolean}} [options] - Options for your new room * @param {function} [callback] * @returns {Promise<{chat_room: ChatRoomState}>} */ createChatRoom(groupId, name, options, callback) { if (typeof options == 'function') { callback = options; options = {}; } options = options || {}; return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, true, (resolve, reject) => { this.user._sendUnified('ChatRoom.CreateChatRoom#1', { chat_group_id: groupId, name, allow_voice: !!options.isVoiceRoom }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } processChatRoomState(body.chat_room, false); resolve({chat_room: body.chat_room}); }); }); } /** * Rename a text/voice chat room in a group, provided you have permissions to do so. * @param {int|string} groupId - The ID of the group in which you want to rename the room * @param {int|string} chatId - The ID of the chat room you want to rename * @param {string} newChatRoomName - The new name for the room * @param {function} [callback] * @returns {Promise} */ renameChatRoom(groupId, chatId, newChatRoomName, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, true, (resolve, reject) => { this.user._sendUnified('ChatRoom.RenameChatRoom#1', { chat_group_id: groupId, chat_id: chatId, name: newChatRoomName }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } resolve(); }); }); } /** * Delete a text/voice chat room in a group (and all the messages it contains), provided you have permissions to do so. * @param {int|string} groupId - The ID of the group in which you want to delete a room * @param {int|string} chatId - The ID of the room you want to delete * @param {function} [callback] * @returns {Promise} */ deleteChatRoom(groupId, chatId, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, true, (resolve, reject) => { this.user._sendUnified('ChatRoom.DeleteChatRoom#1', { chat_group_id: groupId, chat_id: chatId }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } resolve(); }); }); } /** * Kick a user from a chat room group. * @param {int|string} groupId * @param {SteamID|string} steamId * @param {Date|int} [expireTime] - Time when they should be allowed to join again. Omit for immediate. * @param {function} [callback] * @returns {Promise} */ kickUserFromGroup(groupId, steamId, expireTime, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, true, (resolve, reject) => { this.user._sendUnified('ChatRoom.KickUserFromGroup#1', { chat_group_id: groupId, steamid: Helpers.steamID(steamId).toString(), expiration: expireTime ? convertDateToUnix(expireTime) : Math.floor(Date.now() / 1000) }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } resolve(); }); }); } /** * Get the ban list for a chat room group, provided you have the appropriate permissions. * @param {int|string} groupId * @param {function} [callback] * @returns {Promise<{bans: {steamid: SteamID, steamid_actor: SteamID, time_banned: Date, ban_reason: string}[]}>} */ getGroupBanList(groupId, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, false, (resolve, reject) => { this.user._sendUnified('ChatRoom.GetBanList#1', { chat_group_id: groupId }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } preProcessObject(body); resolve(body); }); }); } /** * Ban or unban a user from a chat room group, provided you have the appropriate permissions. * @param {int|string} groupId * @param {string|SteamID} userSteamId * @param {boolean} banState - True to ban, false to unban * @param {function} [callback] * @returns {Promise} */ setGroupUserBanState(groupId, userSteamId, banState, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, true, (resolve, reject) => { this.user._sendUnified('ChatRoom.SetUserBanState#1', { chat_group_id: groupId, steamid: Helpers.steamID(userSteamId).toString(), ban_state: banState }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } // No data in the response resolve(); }); }); } /** * Add or remove a role to a group user, provided you have the appropriate permissions. * @param {int} groupId * @param {SteamID|string} userSteamId * @param {int} roleId * @param {boolean} roleState * @param {function} [callback] * @returns {Promise} */ setGroupUserRoleState(groupId, userSteamId, roleId, roleState, callback) { return StdLib.Promises.timeoutCallbackPromise(10000, null, callback, true, (resolve, reject) => { this.user._sendUnified(roleState ? 'ChatRoom.AddRoleToUser#1' : 'ChatRoom.DeleteRoleFromUser#1', { chat_group_id: groupId, role_id: roleId, steamid: Helpers.steamID(userSteamId).toString() }, (body, hdr) => { let err = Helpers.eresultError(hdr.proto); if (err) { return reject(err); } resolve(); }); }); } } // Handlers SteamUserBase.prototype._handlerManager.add('FriendMessagesClient.IncomingMessage#1', function(body) { body = preProcessObject(body); body.local_echo = body.local_echo || false; // coerce null to false body.from_limited_account = body.from_limited_account || false; body.low_priority = body.low_priority || false; body.ordinal = body.ordinal || 0; body.server_timestamp = body.rtime32_server_timestamp; body.message_no_bbcode = body.message_no_bbcode || body.message; body.message_bbcode_parsed = parseBbCode(body.message); delete body.rtime32_server_timestamp; let eventName = ''; switch (body.chat_entry_type) { case EChatEntryType.ChatMsg: eventName = 'friendMessage'; break; case EChatEntryType.Typing: eventName = 'friendTyping'; break; case EChatEntryType.LeftConversation: eventName = 'friendLeftConversation'; break; default: this.emit('debug', 'Got unknown chat entry type ' + body.chat_entry_type + ' from ' + body.steamid_friend); } if (body.local_echo) { eventName += 'Echo'; } /** * @typedef {object} IncomingFriendMessage * @property {SteamID} steamid_friend * @property {EChatEntryType} chat_entry_type * @property {boolean} from_limited_account * @property {string} message * @property {string} message_no_bbcode * @property {(string|BBCodeNode)[]} message_bbcode_parsed * @property {Date} server_timestamp * @property {number} ordinal * @property {boolean} local_echo * @property {boolean} low_priority */ /** * @event SteamChatRoomClient#friendMessage * @type {IncomingFriendMessage} */ /** * @event SteamChatRoomClient#friendMessageEcho * @type {IncomingFriendMessage} */ /** * @event SteamChatRoomClient#friendTyping * @type {IncomingFriendMessage} */ /** * @event SteamChatRoomClient#friendTypingEcho * @type {IncomingFriendMessage} */ /** * @event SteamChatRoomClient#friendLeftConversation * @type {IncomingFriendMessage} */ /** * @event SteamChatRoomClient#friendLeftConversationEcho * @type {IncomingFriendMessage} */ this.chat.emit(eventName, body); // backwards compatibility this._emitIdEvent(eventName, body.steamid_friend, body.message_no_bbcode); if (body.chat_entry_type == EChatEntryType.ChatMsg) { this._emitIdEvent('friendOrChatMessage', body.steamid_friend, body.message_no_bbcode, body.steamid_friend); } }); SteamUserBase.prototype._handlerManager.add('ChatRoomClient.NotifyIncomingChatMessage#1', function(body) { body = preProcessObject(body); body.server_timestamp = body.timestamp; delete body.timestamp; body.ordinal = body.ordinal || 0; body.message_no_bbcode = body.message_no_bbcode || body.message; body.message_bbcode_parsed = parseBbCode(body.message); if (body.mentions) { body.mentions = processChatMentions(body.mentions); } /** * @event SteamChatRoomClient#chatMessage * @type {object} * @property {string} chat_group_id * @property {string} chat_id * @property {SteamID} steamid_sender * @property {string} message * @property {string} message_no_bbcode * @property {Date} server_timestamp * @property {number} ordinal * @property {{mention_all: boolean, mention_here: boolean, mention_steamids: SteamID[]}|null} mentions * @property {{message: EChatRoomServerMessage, string_param?: string, steamid_param?: SteamID}|null} server_message * @property {string} chat_name */ this.chat.emit('chatMessage', body); }); SteamUserBase.prototype._handlerManager.add('ChatRoomClient.NotifyChatMessageModified#1', function(body) { body = preProcessObject(body); body.messages = body.messages.map((msg) => { msg.ordinal = msg.ordinal || 0; return msg; }); /** * @event SteamChatRoomClient#chatMessagesModified * @type {object} * @property {string} chat_group_id * @property {string} chat_id * @property {{server_timestamp: Date, ordinal: number, deleted: boolean}[]} messages */ this.chat.emit('chatMessagesModified', body); }); SteamUserBase.prototype._handlerManager.add('ChatRoomClient.NotifyChatGroupUserStateChanged#1', function(body) { processChatGroupState(body.user_chat_group_state); processChatGroupSummary(body.group_summary); /** * @event SteamChatRoomClient#chatRoomGroupSelfStateChange * @type {object} * @property {string} chat_group_id * @property {EChatRoomMemberStateChange} user_action * @property {UserChatRoomGroupState} user_chat_group_state * @property {ChatRoomGroupSummary} group_summary */ this.chat.emit('chatRoomGroupSelfStateChange', body); }); SteamUserBase.prototype._handlerManager.add('ChatRoomClient.NotifyMemberStateChange#1', function(body) { preProcessObject(body); /** * @event SteamChatRoomClient#chatRoomGroupMemberStateChange * @type {object} * @property {string} chat_group_id * @property {ChatRoomMember} member * @property {EChatRoomMemberStateChange} change */ this.chat.emit('chatRoomGroupMemberStateChange', body); }); SteamUserBase.prototype._handlerManager.add('ChatRoomClient.NotifyChatRoomHeaderStateChange#1', function(body) { preProcessObject(body); body.chat_group_id = body.header_state.chat_group_id; /** * @event SteamChatRoomClient#chatRoomGroupHeaderStateChange * @type {object} * @property {string} chat_group_id * @property {ChatRoomGroupHeaderState} header_state */ this.chat.emit('chatRoomGroupHeaderStateChange', body); }); SteamUserBase.prototype._handlerManager.add('ChatRoomClient.NotifyChatRoomGroupRoomsChange#1', function(body) { body = preProcessObject(body); body.chat_rooms.map(room => processChatRoomState(room, true)); /** * @event SteamChatRoomClient#chatRoomGroupRoomsChange * @type {object} * @property {string} chat_group_id * @property {string} default_chat_id * @property {ChatRoomState[]} chat_rooms */ this.chat.emit('chatRoomGroupRoomsChange', body); }); // Private functions /** * Process a chat room summary pair. * @param {object} summaryPair * @param {boolean} [preProcessed=false] * @returns {{group_state: ChatRoomGroupState, group_summary: ChatRoomGroupSummary}} */ function processChatRoomSummaryPair(summaryPair, preProcessed) { if (!preProcessed) { summaryPair = preProcessObject(summaryPair); } summaryPair.group_state = processUserChatGroupState(summaryPair.user_chat_group_state, true); summaryPair.group_summary = processChatGroupSummary(summaryPair.group_summary, true); delete summaryPair.user_chat_group_state; return summaryPair; } /** * Process a chat group summary. * @param {object} groupSummary * @param {boolean} [preProcessed=false] * @returns {ChatRoomGroupSummary} */ function processChatGroupSummary(groupSummary, preProcessed) { if (groupSummary === null) { return groupSummary; } if (!preProcessed) { groupSummary = preProcessObject(groupSummary); } if (groupSummary.top_members) { groupSummary.top_members = groupSummary.top_members.map(accountid => SteamID.fromIndividualAccountID(accountid)); } return groupSummary; } /** * @param {object} state * @param {boolean} [preProcessed=false] * @returns {ChatRoomGroupState} */ function processChatGroupState(state, preProcessed) { if (state === null) { return state; } if (!preProcessed) { state = preProcessObject(state); } state.chat_rooms = (state.chat_rooms || []).map(v => processChatRoomState(v, true)); return state; } /** * @param {object} state * @param {boolean} [preProcessed=false] * @returns {UserChatRoomGroupState} */ function processUserChatGroupState(state, preProcessed) { if (!preProcessed) { state = preProcessObject(state); } state.user_chat_room_state = processUserChatRoomState(state.user_chat_room_state, true); state.unread_indicator_muted = !!state.unread_indicator_muted; return state; } /** * @param {object} state * @param {boolean} [preProcessed=false] * @returns {UserChatRoomState} */ function processUserChatRoomState(state, preProcessed) { if (!preProcessed) { state = preProcessObject(state); } state.unread_indicator_muted = !!state.unread_indicator_muted; return state; } /** * @param {object} state * @param {boolean} [preProcessed=false] * @returns {object} */ function processChatRoomState(state, preProcessed) { if (!preProcessed) { state = preProcessObject(state); } state.voice_allowed = !!state.voice_allowed; state.members_in_voice = state.members_in_voice.map(m => SteamID.fromIndividualAccountID(m)); return state; } /** * @param {object} mentions * @returns {{mention_all: boolean, mention_here: boolean, mention_steamids: SteamID[]}} */ function processChatMentions(mentions) { if (!mentions) { return mentions; } if (mentions.mention_accountids) { mentions.mention_steamids = mentions.mention_accountids.map(acctid => SteamID.fromIndividualAccountID(acctid)); delete mentions.mention_accountids; } return mentions; } /** * Pre-process a generic chat object. * @param {object} obj * @returns {object} */ function preProcessObject(obj) { for (let key in obj) { if (!Object.hasOwnProperty.call(obj, key)) { continue; } let val = obj[key]; if (key.match(/^steamid_/) && typeof val === 'string' && val != '0') { obj[key] = new SteamID(val.toString()); } else if (key == 'timestamp' || key.match(/^time_/) || key.match(/_timestamp$/)) { if (val === 0) { obj[key] = null; } else if (val !== null) { obj[key] = new Date(val * 1000); } } else if (key == 'clanid' && typeof val === 'number') { let id = new SteamID(); id.universe = SteamID.Universe.PUBLIC; id.type = SteamID.Type.CLAN; id.instance = SteamID.Instance.ALL; id.accountid = val; obj[key] = id; } else if ((key == 'accountid' || key.match(/^accountid_/) || key.match(/_accountid$/)) && (typeof val === 'number' || val === null)) { let newKey = key == 'accountid' ? 'steamid' : key.replace('accountid_', 'steamid_').replace('_accountid', '_steamid'); obj[newKey] = val === 0 || val === null ? null : SteamID.fromIndividualAccountID(val); delete obj[key]; } else if (key.includes('avatar_sha')) { let url = null; if (obj[key] && obj[key].length) { url = 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/chaticons/'; url += obj[key][0].toString(16) + '/'; url += obj[key][1].toString(16) + '/'; url += obj[key][2].toString(16) + '/'; url += obj[key].toString('hex') + '_256.jpg'; } obj[key.replace('avatar_sha', 'avatar_url')] = url; } else if (key.match(/^can_/) && obj[key] === null) { obj[key] = false; } else if (isDataObject(val)) { obj[key] = preProcessObject(val); } else if (Array.isArray(val) && val.every(isDataObject)) { obj[key] = val.map(v => preProcessObject(v)); } } return obj; } function isDataObject(val) { return val !== null && typeof val === 'object' && (val.constructor.name == 'Object' || val.constructor.name == ''); } function convertDateToUnix(date) { if (date instanceof Date) { return Math.floor(date.getTime() / 1000); } else if (typeof date !== 'number') { throw new Error('Timestamp must be a Date object or a numeric Unix timestamp'); } else if (date > 1420088400000) { return Math.floor(date / 1000); } else { return date; } } /** * @param {string} str * @returns {(string|BBCodeNode)[]} */ function parseBbCode(str) { if (typeof str != 'string') { // Don't try to process non-string values, e.g. null return str; } // Steam will put a backslash in front of a bracket for a BBCode tag that shouldn't be parsed as BBCode, but our // parser doesn't ignore those. Let's just replace "\\[" with some string that's very improbable to exist in a Steam // chat message, then replace it again later. let replacement = Crypto.randomBytes(32).toString('hex'); str = str.replace(/\\\[/g, replacement); let parsed = BBCode.parse(str, { onlyAllowTags: [ 'emoticon', 'code', 'pre', 'img', 'url', 'spoiler', 'quote', 'random', 'flip', 'tradeofferlink', 'tradeoffer', 'sticker', 'gameinvite', 'og', 'roomeffect' ] }); return collapseStrings(parsed.map(processTagNode)); function processTagNode(node) { if (node.tag == 'url') { // we only need to post-process attributes in url tags for (let i in node.attrs) { if (node.attrs[i] == i) { // The URL argument gets parsed with the name as its value node.attrs.url = node.attrs[i]; delete node.attrs[i]; } } } if (node.content) { node.content = collapseStrings(node.content.map(processTagNode)); } return node; } function collapseStrings(arr) { // Turn sequences of strings into single strings let strStart = null; let newContent = []; for (let i = 0; i < arr.length; i++) { if (typeof arr[i] === 'string') { arr[i] = arr[i].replace(new RegExp(replacement, 'g'), '['); // only put in the bracket without the backslash because this is now "parsed" if (strStart === null) { // This is a string item and we haven't found the start of a string yet strStart = i; } } if (typeof arr[i] !== 'string') { // This is not a string item if (strStart !== null) { // We found the end of a string newContent.push(arr.slice(strStart, i).join('')); } newContent.push(arr[i]); // push this item (probably a TagNode) strStart = null; } } if (strStart !== null) { newContent.push(arr.slice(strStart, arr.length).join('')); } return newContent; } } module.exports = SteamChatRoomClient;