discord-ghost
Version:
A headless Discord-RPC client for Destiny 2
372 lines (324 loc) • 13.8 kB
text/typescript
/* 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";
}
}