UNPKG

discord-ghost

Version:

A headless Discord-RPC client for Destiny 2

372 lines (324 loc) 13.8 kB
/* istanbul ignore file: techdebt, todo */ import { Database } from "./Database"; import { DiscordRPC } from "./DiscordRPC"; import { Presence } from "discord-rpc"; import { DestinyComponentType, DestinyCharacterActivitiesComponent, DestinyCharacterComponent, DestinyActivityDefinition, DestinyActivityModeDefinition, DestinyClassDefinition, DestinyLinkedProfilesResponse, DestinyActivityModeType, DestinyDestinationDefinition } from "bungie-api-ts/destiny2/interfaces"; import { getFromBungie } from "./Utils"; import { ServerResponse, PlatformErrorCodes, BungieMembershipType } from "bungie-api-ts/common"; import { createHash } from "crypto"; import { System, DefaultSystem } from "./System"; import { clearInterval } from "timers"; import { OAuthClient } from "./OAuth"; interface CharacterComponentsData<T> { [characterId: string]: T; } interface CharacterComponentsDataWrapper<T> { data: CharacterComponentsData<T>; } interface DestinyCharacterActivitiesComponentResponse { characterActivities: CharacterComponentsDataWrapper<DestinyCharacterActivitiesComponent>; characters: CharacterComponentsDataWrapper<DestinyCharacterComponent>; } export class Client { public static System: System = DefaultSystem; private static readonly PLACE_ORBIT: number = 2961497387; private static readonly ACTIVITY_TYPE_FORGE: number = 838603889; private static readonly ACTIVITY_TYPE_MENAGERIE: number = 400075666; private static readonly PLAYLIST_PRIVATE_GAMBIT: number = 2624692004; private interval: NodeJS.Timeout; private database: Database; private discordRpc: DiscordRPC; private refreshRate: number; private oAuthClient: OAuthClient; private membershipPerPlatform: Map<BungieMembershipType, string>; private lastActivity: string; public constructor( database: Database, discordRpc: DiscordRPC, oAuthClient: OAuthClient, refreshRate: number = 15e3, startNow: boolean = true ) { this.database = database; this.oAuthClient = oAuthClient; this.discordRpc = discordRpc; this.refreshRate = refreshRate; this.membershipPerPlatform = new Map<BungieMembershipType, string>(); if (startNow) { this.start(); } } private async getCharacterInformation(): Promise<DestinyCharacterActivitiesComponentResponse> { const agreggatedResponse: DestinyCharacterActivitiesComponentResponse = { characterActivities: { data: {} }, characters: { data: {} } }; const iterator = await this.membershipPerPlatform.entries(); while (true) { const it = iterator.next(); if (it.done) { break; } const response = await getFromBungie<ServerResponse<DestinyCharacterActivitiesComponentResponse>>( { uri: `Destiny2/${it.value[0]}/Profile/${it.value[1]}`, components: [DestinyComponentType.CharacterActivities, DestinyComponentType.Characters] }, await this.oAuthClient.getAccessToken() ); if (response.ErrorCode !== PlatformErrorCodes.Success) { // TODO debugger; } else { Object.assign(agreggatedResponse.characterActivities.data, response.Response.characterActivities.data); Object.assign(agreggatedResponse.characters.data, response.Response.characters.data); } } return agreggatedResponse; } private async getLinkedProfiles(): Promise<DestinyLinkedProfilesResponse> { const response = await getFromBungie<ServerResponse<DestinyLinkedProfilesResponse>>( { uri: `Destiny2/-1/Profile/${await this.oAuthClient.getMembershipId()}/LinkedProfiles` }, await this.oAuthClient.getAccessToken() ); if (response.ErrorCode !== PlatformErrorCodes.Success) { // TODO } return response.Response; } public async start(): Promise<void> { if (!this.interval) { console.log("Starting service"); const linkedProfileResponse = await this.getLinkedProfiles(); linkedProfileResponse.profiles.forEach(profile => this.membershipPerPlatform.set(profile.membershipType, profile.membershipId) ); this.interval = setInterval(async () => { let response; try { response = await this.getCharacterInformation(); } catch (error) { throw error; } const currentCharacterId = this.getCurrentCharacterId(response); if (!currentCharacterId) { return this.stop(); } const currentActivityData = response.characterActivities.data[currentCharacterId]; const currentCharacterData = response.characters.data[currentCharacterId]; if (this.getActivityHash(currentActivityData) != this.lastActivity) { const activityInfo = this.getActivityInfo(currentActivityData, currentCharacterData); activityInfo.largeImageKey = this.sanitizeLargeImageKey(activityInfo.largeImageKey); if (activityInfo.smallImageKey) { activityInfo.smallImageKey = this.sanitizeSmallImageKey(activityInfo.smallImageKey); } console.log(`\nNew activity:\n${activityInfo.details || ""}\n${activityInfo.state}`); this.discordRpc.setActivity(activityInfo); this.lastActivity = this.getActivityHash(currentActivityData); } }, this.refreshRate); } } private getActivityHash(currentActivityData: DestinyCharacterActivitiesComponent): string { return Buffer.from(currentActivityData.currentActivityHash + currentActivityData.dateActivityStarted).toString( "base64" ); } private getCurrentCharacterId(response: DestinyCharacterActivitiesComponentResponse): string { let currentCharacterId: string; if (!response.characterActivities || !response.characterActivities.data) { console.warn( "No character activities found, try to modify your privacy settings (see https://github.com/brakacai/discord-ghost/docs/PrivacySettings.md)" ); return null; } Object.keys(response.characterActivities.data).forEach(characterId => { if (this.isCharacterMoreRecent(currentCharacterId, response, characterId)) { currentCharacterId = characterId; } }); return currentCharacterId; } private isCharacterMoreRecent( currentCharacterId: string, response: DestinyCharacterActivitiesComponentResponse, characterId: string ): boolean { return ( response.characterActivities.data[characterId].currentActivityHash && (!currentCharacterId || new Date(response.characterActivities.data[characterId].dateActivityStarted) > new Date(response.characterActivities.data[currentCharacterId].dateActivityStarted)) ); } public isRunning(): boolean { return !!this.interval; } public stop(): void { if (this.interval) { clearInterval(this.interval); console.log("Service stopped"); } } private getActivityInfo( currentActivityData: DestinyCharacterActivitiesComponent, currentCharacterData: DestinyCharacterComponent ): Presence { const currentActivity = this.database.getFromDatabase<DestinyActivityDefinition>( "DestinyActivityDefinition", currentActivityData.currentActivityHash ); if (this.isActivityOrbit(currentActivity)) { // Orbit has no additional data to show return { state: "In Orbit", largeImageKey: "default_large", largeImageText: "In Orbit", startTimestamp: Date.now() // Bungie does not restart the timer... }; } const currentActivityMode = this.database.getFromDatabase<DestinyActivityModeDefinition>( "DestinyActivityModeDefinition", currentActivityData.currentActivityModeHash ); const currentPlaylist = this.database.getFromDatabase<DestinyActivityDefinition>( "DestinyActivityDefinition", currentActivityData.currentPlaylistActivityHash ); const currentCharacterRace = this.database.getFromDatabase<DestinyClassDefinition>( "DestinyClassDefinition", currentCharacterData.classHash ); const currentPlaylistDestination = this.database.getFromDatabase<DestinyDestinationDefinition>( "DestinyDestinationDefinition", currentPlaylist.destinationHash ); const currentActivityDestination = this.database.getFromDatabase<DestinyDestinationDefinition>( "DestinyDestinationDefinition", currentActivity.destinationHash ); const activityInfo: Presence = { smallImageText: `${currentCharacterRace.displayProperties.name} \u2013 ${currentCharacterData.light}`, startTimestamp: Date.parse(currentActivityData.dateActivityStarted) }; if (this.isActivityPvp(currentActivity)) { return { ...activityInfo, state: `${currentPlaylist.displayProperties.name} - ${currentActivityMode.displayProperties.name}`, smallImageKey: currentActivityMode.displayProperties.icon, largeImageKey: currentActivity.pgcrImage, largeImageText: `${currentActivity.displayProperties.name} - ${currentActivity.displayProperties.description}`, startTimestamp: Date.parse(currentActivityData.dateActivityStarted) }; } if (this.isActivityForge(currentActivity)) { return { ...activityInfo, state: `${currentActivity.displayProperties.name} - ${currentPlaylist.displayProperties.name}`, smallImageKey: currentPlaylist.displayProperties.icon, largeImageKey: currentPlaylist.pgcrImage, largeImageText: `${currentPlaylistDestination.displayProperties.name} - ${currentPlaylistDestination.displayProperties.description}`, startTimestamp: Date.parse(currentActivityData.dateActivityStarted) }; } if (this.isActivityGambit(currentActivity)) { return { ...activityInfo, state: `${currentPlaylist.hash === Client.PLAYLIST_PRIVATE_GAMBIT ? "Gambit - " : ""}${ currentPlaylist.displayProperties.name }`, smallImageKey: currentActivityMode.displayProperties.icon, largeImageKey: currentActivity.pgcrImage, largeImageText: `${currentActivityDestination.displayProperties.name} - ${currentActivityDestination.displayProperties.description}` }; } if (this.isMenagerie(currentActivity)) { return { ...activityInfo, state: currentActivity.displayProperties.name, smallImageKey: currentActivity.displayProperties.icon, largeImageKey: currentActivity.pgcrImage, largeImageText: `${currentPlaylistDestination.displayProperties.name}` }; } if (this.isNightfall(currentActivity)) { return { ...activityInfo, state: `Strike - ${currentActivity.displayProperties.name}`, smallImageKey: currentActivity.displayProperties.icon, largeImageKey: currentActivity.pgcrImage, largeImageText: `${currentPlaylistDestination.displayProperties.name} - ${currentPlaylistDestination.displayProperties.description}` }; } if (this.isStrike(currentActivity)) { return { ...activityInfo, state: currentActivity.displayProperties.name.replace(":", " -"), smallImageKey: currentActivity.displayProperties.icon, largeImageKey: currentActivity.pgcrImage, largeImageText: `${currentPlaylistDestination.displayProperties.name} - ${currentPlaylistDestination.displayProperties.description}` }; } return { ...activityInfo, state: `${currentActivityMode.displayProperties.name} - ${ currentPlaylist.displayProperties.name ? currentPlaylist.displayProperties.name : currentActivity.displayProperties.name }`, smallImageKey: currentActivityMode.displayProperties.icon, largeImageText: `${currentPlaylistDestination.displayProperties.name} - ${currentPlaylistDestination.displayProperties.description}`, largeImageKey: currentActivity.pgcrImage }; } private isStrike(currentActivity: DestinyActivityDefinition): boolean { return currentActivity.activityModeTypes.includes(DestinyActivityModeType.AllStrikes); } private isNightfall(currentActivity: DestinyActivityDefinition): boolean { const nightfallTypeIds = [16, 17, 46, 47]; return currentActivity.activityModeTypes.some(activityType => nightfallTypeIds.some(nightfallType => activityType === nightfallType) ); } private isMenagerie(currentActivity: DestinyActivityDefinition): boolean { return currentActivity.activityTypeHash == Client.ACTIVITY_TYPE_MENAGERIE; } private isActivityGambit(currentActivity: DestinyActivityDefinition): boolean { return ( currentActivity.activityModeTypes.includes(DestinyActivityModeType.Gambit) || currentActivity.activityModeTypes.includes(DestinyActivityModeType.GambitPrime) ); } private isActivityForge(currentActivity: DestinyActivityDefinition): boolean { return currentActivity.activityTypeHash == Client.ACTIVITY_TYPE_FORGE; } private isActivityPvp(currentActivity: DestinyActivityDefinition): boolean { return currentActivity.activityModeTypes.includes(DestinyActivityModeType.AllPvP); } private isActivityOrbit(currentActivity: DestinyActivityDefinition): boolean { return currentActivity.placeHash == Client.PLACE_ORBIT; } private sanitizeSmallImageKey(smallImageKey: string): string { const smallImage = Client.System.path.parse(smallImageKey).name; return smallImage.substr(smallImage.indexOf("_") + 1); } private sanitizeLargeImageKey(largeImageKey: string): string { return largeImageKey !== "default_large" ? createHash("md5") .update(Client.System.path.parse(largeImageKey).name) .digest("hex") : "default_large"; } }