From d19b0160020dcabba70e716be7efee773d468d74 Mon Sep 17 00:00:00 2001 From: Ed Morgan Date: Thu, 7 Aug 2025 23:21:05 -0400 Subject: [PATCH 1/2] Initial commit - authentication - handle raids, adjustments, items on listener actions --- src/config.ts | 27 + .../auctions/auction-finished-reaction.ts | 9 + src/features/dkp-records/raid-report.ts | 3 + .../request-bonus/add-adjustment-bonus.ts | 3 + src/index.ts | 21 +- src/services/castledkp.ts | 524 +++++++++--------- src/services/openDkpService.ts | 305 ++++++++++ yarn.lock | 64 +-- 8 files changed, 655 insertions(+), 301 deletions(-) create mode 100644 src/services/openDkpService.ts diff --git a/src/config.ts b/src/config.ts index d085149..d302259 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,6 +12,11 @@ export const { castleDkp2TokenRW, castleDkpAuctionRaidId, castleDkpBonusesCharId, + openDkpUsername, + openDkpPassword, + openDkpDomain, + openDkpSiteClientId, + openDkpAuthClientId, sharedCharactersGoogleSheetId, publicCharactersGoogleSheetId, commandSuffix, @@ -95,6 +100,28 @@ export const { * castledkp.vercel.app read/write token */ castleDkp2TokenRW?: string; + /* + * castle.opendkp.com username + */ + openDkpUsername?: string; + /* + * castle.opendkp.com password + */ + openDkpPassword?: string; + /* + * castle.opendkp.com subdomain + */ + openDkpDomain?: string; + + /* + * castle.opendkp.com site client id + */ + openDkpSiteClientId?: string; + + /* + * castle.opendkp.com auth client id + */ + openDkpAuthClientId?: string; /** * CastleDKP.com Raid ID for DKP auctions. diff --git a/src/features/auctions/auction-finished-reaction.ts b/src/features/auctions/auction-finished-reaction.ts index c379779..f45dcc6 100644 --- a/src/features/auctions/auction-finished-reaction.ts +++ b/src/features/auctions/auction-finished-reaction.ts @@ -18,6 +18,7 @@ import { } from "../../shared/action/reaction-action"; import { castledkp } from "../../services/castledkp"; import { some } from "lodash"; +import { openDkpService } from "../../services/openDkpService"; const code = "```"; const emojis = ["✅", "🏦"]; @@ -97,6 +98,14 @@ class AuctionFinishedReactionAction extends ReactionAction { } // add item to raid + await openDkpService.addItem( + character.name, + item, + `Auction - ${ + this.message.thread?.name || item + } - ${this.message.createdAt.toDateString()}`, + price + ); await castledkp.addItem(Number(castleDkpAuctionRaidId), { item, buyer: character.name, diff --git a/src/features/dkp-records/raid-report.ts b/src/features/dkp-records/raid-report.ts index c7123f1..8509f2d 100644 --- a/src/features/dkp-records/raid-report.ts +++ b/src/features/dkp-records/raid-report.ts @@ -11,6 +11,7 @@ import { CreateRaidResponse, RaidEventData } from "../../services/castledkp"; import { DAYS } from "../../shared/time"; import { code } from "../../shared/util"; import { AdjustmentData, EVERYONE, RaidTick, RaidTickData } from "./raid-tick"; +import { openDkpService } from "../../services/openDkpService"; export interface LootData { item: string; @@ -282,6 +283,8 @@ ${p}${code}`, } }); + openDkpService.createRaid(this.ticks); + return { created, failed }; } diff --git a/src/features/dkp-records/request-bonus/add-adjustment-bonus.ts b/src/features/dkp-records/request-bonus/add-adjustment-bonus.ts index ef8bfb4..9285d6f 100644 --- a/src/features/dkp-records/request-bonus/add-adjustment-bonus.ts +++ b/src/features/dkp-records/request-bonus/add-adjustment-bonus.ts @@ -1,10 +1,13 @@ import { capitalize } from "lodash"; import { castledkp } from "../../../services/castledkp"; import { RaidBonusRequest } from "./raid-bonus-request"; +import { openDkpService } from "../../../services/openDkpService"; +import moment from "moment"; export class AddAdjustmentBonus extends RaidBonusRequest { protected async execute(raidId: number) { const adjustment = await this.validateArgs(); + await openDkpService.addAdjustment(adjustment); await castledkp.addAdjustment(raidId, adjustment); } diff --git a/src/index.ts b/src/index.ts index 4c708a6..f226b5d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,12 @@ import { ChannelType, Client, GatewayIntentBits, Partials } from "discord.js"; import { interactionCreateListener } from "./listeners/interaction-create-listener"; -import { guildId, token } from "./config"; +import { + guildId, + openDkpAuthClientId, + openDkpPassword, + openDkpUsername, + token, +} from "./config"; import { readyListener } from "./listeners/ready-listener"; import { messageReactionAddListener } from "./listeners/message-reaction-add-listener"; import { registerSlashCommands } from "./listeners/register-commands"; @@ -17,7 +23,8 @@ import { updateRaidReport } from "./features/dkp-records/update/update-raid-repo import { guildMemberUpdateListener } from "./listeners/guild-member-update-listener"; import "reflect-metadata"; import { PrismaClient } from "@prisma/client"; -import { log } from "./shared/logger" +import { log } from "./shared/logger"; +import { openDkpService } from "./services/openDkpService"; // Global https.globalAgent.maxSockets = 5; @@ -96,4 +103,14 @@ redisListener.pSubscribe(redisChannels.raidReportChange(), updateRaidReport); export const prismaClient = new PrismaClient(); prismaClient.$connect(); +if (openDkpUsername && openDkpPassword && openDkpAuthClientId) { + openDkpService + .doUserPasswordAuth(openDkpUsername, openDkpPassword, openDkpAuthClientId) + .then(() => { + console.log("Authenticated with OpenDKP"); + }) + .catch((reason) => { + console.log("Failed to authenticate with OpenDKP: " + reason); + }); +} log("Listening..."); diff --git a/src/services/castledkp.ts b/src/services/castledkp.ts index fed0bee..49a70c0 100644 --- a/src/services/castledkp.ts +++ b/src/services/castledkp.ts @@ -5,9 +5,9 @@ import LRUCache from "lru-cache"; import moment from "moment"; import { castleDkpTokenRO } from "../config"; import { - AdjustmentData, - RaidTick, - UPLOAD_DATE_FORMAT, + AdjustmentData, + RaidTick, + UPLOAD_DATE_FORMAT, } from "../features/dkp-records/raid-tick"; import { MINUTES, MONTHS } from "../shared/time"; import { betaDkpService } from "./betaDkpService"; @@ -15,310 +15,310 @@ import { betaDkpService } from "./betaDkpService"; const route = (f: string) => `api.php?function=${f}`; const client = axios.create({ - baseURL: "https://castledkp.com", + baseURL: "https://castledkp.com", }); axiosRetry(client, { retries: 5, retryDelay: axiosRetry.exponentialDelay }); client.interceptors.request.use((config) => { - if (!castleDkpTokenRO) { - throw new Error("Cannot query CastleDKP without an RO token."); - } - config.params = { - ...config.params, - atoken: castleDkpTokenRO, - atype: "api", - format: "json", - }; - return config; + if (!castleDkpTokenRO) { + throw new Error("Cannot query CastleDKP without an RO token."); + } + config.params = { + ...config.params, + atoken: castleDkpTokenRO, + atype: "api", + format: "json", + }; + return config; }); interface Character { - id: number; - name: string; - main: boolean; - classname: string; + id: number; + name: string; + main: boolean; + classname: string; } export interface RaidEventData { - name: string; - shortName: string; - abreviation: string; - value: number; - id: number; + name: string; + shortName: string; + abreviation: string; + value: number; + id: number; } const characters = new LRUCache({ - max: 1000, - ttl: 3 * MONTHS, - updateAgeOnGet: true, + max: 1000, + ttl: 3 * MONTHS, + updateAgeOnGet: true, }); const events = new LRUCache({ - max: 1000, - ttl: 10 * MINUTES, + max: 1000, + ttl: 10 * MINUTES, }); const CASTLE_DKP_EVENT_URL_STRIP = /[-()'\s]/g; const getEvents = async () => { - events.purgeStale(); - if (events.size) { - return events; + events.purgeStale(); + if (events.size) { + return events; + } + const { data } = await client.get<{ + [_: string]: { name: string; value: number; id: number }; + }>(route("events")); + delete data.status; + Object.values(data).forEach(({ id, name, value }) => { + if (name.includes("legacy")) { + return; } - const { data } = await client.get<{ - [_: string]: { name: string; value: number; id: number }; - }>(route("events")); - delete data.status; - Object.values(data).forEach(({ id, name, value }) => { - if (name.includes("legacy")) { - return; - } - const abreviation = name.substring( - name.indexOf("[") + 1, - name.indexOf("]") - ); - const shortName = name.replace(`[${abreviation}]`, "").trim(); - events.set(name.trim(), { - id, - value, - name, - abreviation, - shortName, - }); + const abreviation = name.substring( + name.indexOf("[") + 1, + name.indexOf("]") + ); + const shortName = name.replace(`[${abreviation}]`, "").trim(); + events.set(name.trim(), { + id, + value, + name, + abreviation, + shortName, }); - return events; + }); + return events; }; export interface CreateRaidResponse { - eventUrlSlug: string; - id: number; - tick: RaidTick; - invalidNames: string[]; + eventUrlSlug: string; + id: number; + tick: RaidTick; + invalidNames: string[]; } const getCharacter = async (name: string) => { - const character = characters.get(name); - if (character) { - return character; - } - const result = await client - .get<{ direct?: { [key: string]: Character } }>(route("search"), { - params: { - in: "charname", - for: name, - }, - }) - .then(({ data }) => { - if (!data.direct) { - return undefined; - } - return Object.values(data.direct)[0]; - }); - if (result) { - characters.set(name, result); - } - return result; + const character = characters.get(name); + if (character) { + return character; + } + const result = await client + .get<{ direct?: { [key: string]: Character } }>(route("search"), { + params: { + in: "charname", + for: name, + }, + }) + .then(({ data }) => { + if (!data.direct) { + return undefined; + } + return Object.values(data.direct)[0]; + }); + if (result) { + characters.set(name, result); + } + return result; }; export const castledkp = { - getPointsByCharacter: async (characterId: number) => { - const { data } = await client.get(route("points"), { - params: { - filter: "character", - filterid: characterId, - }, - }); - const character = data?.players?.[`player:${characterId}`]; - const dkp = character?.points?.[`multidkp_points:1`]; - return { - characterId: characterId, - characterName: character.name as string, - class: character.class_name as string, - currentDkp: Number(dkp.points_current), - lifetimeDkp: Number(dkp.points_earned) + Number(dkp.points_adjustment), - spentDkp: Number(dkp.points_spent), - }; - }, + getPointsByCharacter: async (characterId: number) => { + const { data } = await client.get(route("points"), { + params: { + filter: "character", + filterid: characterId, + }, + }); + const character = data?.players?.[`player:${characterId}`]; + const dkp = character?.points?.[`multidkp_points:1`]; + return { + characterId: characterId, + characterName: character.name as string, + class: character.class_name as string, + currentDkp: Number(dkp.points_current), + lifetimeDkp: Number(dkp.points_earned) + Number(dkp.points_adjustment), + spentDkp: Number(dkp.points_spent), + }; + }, - getEvent: async (label: string) => { - const events = await getEvents(); - return events.get(label); - }, + getEvent: async (label: string) => { + const events = await getEvents(); + return events.get(label); + }, - getEvents: async () => { - const events = await getEvents(); - return [...events.values()]; - }, + getEvents: async () => { + const events = await getEvents(); + return [...events.values()]; + }, - createRaid: async ( - name: string, - event: RaidEventData, - characterId: string, - threadUrl: string - ) => { - const payload = { - raid_date: moment().format(UPLOAD_DATE_FORMAT), - raid_attendees: { member: [Number(characterId)] }, - raid_value: 0, - raid_event_id: event.id, - raid_note: `${name} ${threadUrl}`, - }; - console.log("Creating new raid:", payload); - const { data } = await client.post<{ raid_id: number }>( - route("add_raid"), - payload - ); + createRaid: async ( + name: string, + event: RaidEventData, + characterId: string, + threadUrl: string + ) => { + const payload = { + raid_date: moment().format(UPLOAD_DATE_FORMAT), + raid_attendees: { member: [Number(characterId)] }, + raid_value: 0, + raid_event_id: event.id, + raid_note: `${name} ${threadUrl}`, + }; + console.log("Creating new raid:", payload); + const { data } = await client.post<{ raid_id: number }>( + route("add_raid"), + payload + ); - return { - eventUrlSlug: event.name - .toLowerCase() - .replace(CASTLE_DKP_EVENT_URL_STRIP, "-"), - id: data.raid_id, - }; - }, + return { + eventUrlSlug: event.name + .toLowerCase() + .replace(CASTLE_DKP_EVENT_URL_STRIP, "-"), + id: data.raid_id, + }; + }, - createRaidFromTick: async ( - tick: RaidTick, - threadUrl: string - ): Promise => { - // validate ticks - if (tick.data.event === undefined) { - throw new Error(`Tick is missing an event type.`); - } - if (tick.data.value === undefined) { - throw new Error(`Tick is missing a value.`); - } + createRaidFromTick: async ( + tick: RaidTick, + threadUrl: string + ): Promise => { + // validate ticks + if (tick.data.event === undefined) { + throw new Error(`Tick is missing an event type.`); + } + if (tick.data.value === undefined) { + throw new Error(`Tick is missing a value.`); + } - // get character ids - const { characters, invalidNames } = await castledkp.getCharacters([ - ...tick.data.attendees, - ]); - const characterIds = characters.map((v) => v.id); - if (!characterIds.length) { - throw new Error(`Tick has no valid characters.`); - } + // get character ids + const { characters, invalidNames } = await castledkp.getCharacters([ + ...tick.data.attendees, + ]); + const characterIds = characters.map((v) => v.id); + if (!characterIds.length) { + throw new Error(`Tick has no valid characters.`); + } - // create raid - const payload = { - raid_date: tick.uploadDate, - raid_attendees: { member: characterIds }, - raid_value: tick.data.value, - raid_event_id: tick.data.event.id, - raid_note: `${tick.uploadNote} ${threadUrl}`, - }; - console.log("Creating raid tick", payload); - const { data } = await client.post<{ raid_id: number }>( - route("add_raid"), - payload - ); + // create raid + const payload = { + raid_date: tick.uploadDate, + raid_attendees: { member: characterIds }, + raid_value: tick.data.value, + raid_event_id: tick.data.event.id, + raid_note: `${tick.uploadNote} ${threadUrl}`, + }; + console.log("Creating raid tick", payload); + const { data } = await client.post<{ raid_id: number }>( + route("add_raid"), + payload + ); - // add items to raid - console.log("Adding items to raid", tick.data.loot); - await Promise.all( - tick.data.loot.map((l) => castledkp.addItem(data.raid_id, l)) - ); + // add items to raid + console.log("Adding items to raid", tick.data.loot); + await Promise.all( + tick.data.loot.map((l) => castledkp.addItem(data.raid_id, l)) + ); - // add adjustments to raid - console.log("Adding adjustments to raid", tick.data.adjustments); - await Promise.all( - tick.data.adjustments?.map((a) => - castledkp.addAdjustment(data.raid_id, a) - ) || [] - ); + // add adjustments to raid + console.log("Adding adjustments to raid", tick.data.adjustments); + await Promise.all( + tick.data.adjustments?.map((a) => + castledkp.addAdjustment(data.raid_id, a) + ) || [] + ); - // Temporarily create data using the beta service as well; this is not async and is fault tolerant. - // Primarily, this is being used to test the beta service by ingesting real data. - betaDkpService - .createRaid({ - raidTick: tick, - raidActivityType: { - name: tick.data.event.name, - defaultPayout: tick.data.event.value, - }, - }) - .catch((error) => { - console.error(`Failed to create raid in beta service:`, { - message: error.message, - statusCode: error.response ? error.response.status : "N/A", - data: error.response ? error.response.data : "N/A", - config: error.config, - }); - }); + // Temporarily create data using the beta service as well; this is not async and is fault tolerant. + // Primarily, this is being used to test the beta service by ingesting real data. + betaDkpService + .createRaid({ + raidTick: tick, + raidActivityType: { + name: tick.data.event.name, + defaultPayout: tick.data.event.value, + }, + }) + .catch((error) => { + console.error(`Failed to create raid in beta service:`, { + message: error.message, + statusCode: error.response ? error.response.status : "N/A", + data: error.response ? error.response.data : "N/A", + config: error.config, + }); + }); - return { - eventUrlSlug: tick.data.event.name - .toLowerCase() - .replace(CASTLE_DKP_EVENT_URL_STRIP, "-"), - id: data.raid_id, - tick: tick, - invalidNames, - }; - }, + return { + eventUrlSlug: tick.data.event.name + .toLowerCase() + .replace(CASTLE_DKP_EVENT_URL_STRIP, "-"), + id: data.raid_id, + tick: tick, + invalidNames, + }; + }, - getCharacters: async (names: string[]) => { - const [characters, invalidNames] = partition( - await Promise.all( - names.map(async (n) => ({ - name: n, - character: await getCharacter(n), - })) - ), - (c) => !!c.character - ); - return { - characters: characters.map((v) => v.character as unknown as Character), - invalidNames: invalidNames.map((v) => v.name), - }; - }, + getCharacters: async (names: string[]) => { + const [characters, invalidNames] = partition( + await Promise.all( + names.map(async (n) => ({ + name: n, + character: await getCharacter(n), + })) + ), + (c) => !!c.character + ); + return { + characters: characters.map((v) => v.character as unknown as Character), + invalidNames: invalidNames.map((v) => v.name), + }; + }, - getCharacter: async (name: string, requireExists = true) => { - const character = await getCharacter(name); - if (!character && requireExists) { - throw new Error( - `Character named '${name}' does not exist on CastleDKP.com` - ); - } - return character; - }, + getCharacter: async (name: string, requireExists = true) => { + const character = await getCharacter(name); + if (!character && requireExists) { + throw new Error( + `Character named '${name}' does not exist on CastleDKP.com` + ); + } + return character; + }, - addItem: async ( - raidId: number, - loot: { - item: string; - buyer: string; - price: number; - } - ) => { - const character = await castledkp.getCharacter(loot.buyer); - if (!character) { - throw new Error( - `Cannot add item to non-existent character ${loot.buyer}` - ); - } - return client.post(route("add_item"), { - item_date: moment().format(UPLOAD_DATE_FORMAT), - item_name: loot.item, - item_buyers: { member: [character.id] }, - item_raid_id: raidId, - item_value: loot.price, - item_itempool_id: 1, - }); - }, + addItem: async ( + raidId: number, + loot: { + item: string; + buyer: string; + price: number; + } + ) => { + const character = await castledkp.getCharacter(loot.buyer); + if (!character) { + throw new Error( + `Cannot add item to non-existent character ${loot.buyer}` + ); + } + return client.post(route("add_item"), { + item_date: moment().format(UPLOAD_DATE_FORMAT), + item_name: loot.item, + item_buyers: { member: [character.id] }, + item_raid_id: raidId, + item_value: loot.price, + item_itempool_id: 1, + }); + }, - addAdjustment: async (raidId: number, adjustment: AdjustmentData) => { - const character = await castledkp.getCharacter(adjustment.player); - if (!character) { - throw new Error( - `Cannot add adjustment to non-existent character ${adjustment.player}` - ); - } - return client.post(route("add_adjustment"), { - adjustment_date: moment().format(UPLOAD_DATE_FORMAT), - adjustment_reason: adjustment.reason, - adjustment_members: { member: [character.id] }, - adjustment_value: adjustment.value, - adjustment_raid_id: raidId, - adjustment_event_id: 20, - }); - }, + addAdjustment: async (raidId: number, adjustment: AdjustmentData) => { + const character = await castledkp.getCharacter(adjustment.player); + if (!character) { + throw new Error( + `Cannot add adjustment to non-existent character ${adjustment.player}` + ); + } + return client.post(route("add_adjustment"), { + adjustment_date: moment().format(UPLOAD_DATE_FORMAT), + adjustment_reason: adjustment.reason, + adjustment_members: { member: [character.id] }, + adjustment_value: adjustment.value, + adjustment_raid_id: raidId, + adjustment_event_id: 20, + }); + }, }; diff --git a/src/services/openDkpService.ts b/src/services/openDkpService.ts new file mode 100644 index 0000000..a206164 --- /dev/null +++ b/src/services/openDkpService.ts @@ -0,0 +1,305 @@ +import axios from "axios"; +import { AdjustmentData, RaidTick } from "../features/dkp-records/raid-tick"; +import moment from "moment"; +import LRUCache from "lru-cache"; +import { MINUTES } from "../shared/time"; + +// Client for OpenDKP + +interface IAccessTokenResult { + AccessToken: string; + ExpiresIn: number; + IdToken: string; + RefreshToken: string; + TokenType: string; +} + +export interface ODKPRaidItem { + CharacterName: string; + Dkp: number; + GameItemId: number; + ItemId: number; + ItemName: string; + Notes: string; +} + +export interface ODKPRaidTickCharacter { + Name: string; +} + +export interface ODKPRaidTick { + Characters: ODKPRaidTickCharacter[]; + Description: string; + Value: number; +} + +export interface ODKPRaidPool { + Description: string; + Name: string; + PoolId: number; +} + +export interface ODKPRaidData { + Attendance: number; + Items: ODKPRaidItem[]; + Name: string; + Pool: ODKPRaidPool; + Ticks: ODKPRaidTick[]; + Timestamp: string; +} + +export interface ODKPItemResponse { + ItemID: number; + ItemName: string; + GameItemId: number; +} + +export interface ODKPAdjustment { + Name: string; + Description: string; + Value: number; + Character: ODKPRaidTickCharacter; + Timestamp: string; +} + +interface ODKPCharacterData { + ClientId: string; + CharacterId: number; + AssociatedId: number; + Active: number; + Name: string; + Rank: string; + Class: string; + Level: number; + Race: string; + Gender: string; + Guild: string; + MainChange: string; // ISO date string + Deleted: number; + User: string; + CreatedDate: string; // ISO date string +} + +const characterCache = new LRUCache({ + ttl: 60 * MINUTES, +}); + +let accessTokens: IAccessTokenResult; + +export const openDkpService = { + doUserPasswordAuth: async ( + username: string, + password: string, + clientId: string + ) => { + const data = { + AuthParameters: { + USERNAME: username, + PASSWORD: password, + }, + AuthFlow: "USER_PASSWORD_AUTH", + ClientId: clientId, + }; + + const config = { + method: "post", + url: "https://cognito-idp.us-east-2.amazonaws.com/", + headers: { + "Content-Type": "application/x-amz-json-1.1", + "X-Amz-Target": "AWSCognitoIdentityProviderService.InitiateAuth", + }, + data: JSON.stringify(data), + }; + try { + const response = await axios(config); + accessTokens = response.data?.AuthenticationResult; + openDkpService.loadCharacters(); + } catch (error) { + console.log(JSON.stringify(error, null, 2)); + } + }, + loadCharacters: async (): Promise => { + const config = { + method: "get", + url: `https://api.opendkp.com/clients/castle/characters?IncludeInactives=true`, + headers: { + Authorization: `${accessTokens.TokenType} ${accessTokens.IdToken}`, + }, + }; + + try { + const response = await axios(config); + if (response.status === 200) { + (response.data as ODKPCharacterData[]).forEach((char) => { + characterCache.set(char.Name, char); + }); + console.log(`Loaded ${characterCache.size} characters`); + } + } catch (error) { + console.log(error); + } + return; + }, + + getItemId: async (itemName: string): Promise => { + const config = { + method: "get", + url: `https://api.opendkp.com/items/autocomplete?item=${itemName}`, + headers: { + Authorization: `${accessTokens.TokenType} ${accessTokens.IdToken}`, + }, + }; + try { + const response = await axios(config); + return response.data[0] || undefined; + } catch (error) { + console.log(error); + } + return { GameItemId: -1, ItemID: -1, ItemName: "" }; + }, + + createRaid: async (ticks: RaidTick[]) => { + const items = await Promise.all( + ticks.flatMap((tick) => + tick.data.loot.map(async (item) => { + const odkpItem = await openDkpService.getItemId(item.item); + return { + CharacterName: item.buyer, + Dkp: item.price, + ItemName: odkpItem.ItemName, + GameItemId: odkpItem.GameItemId, + ItemId: odkpItem.ItemID, + } as ODKPRaidItem; + }) + ) + ); + const name = ticks + .flatMap((tick) => { + return tick.name; + }) + .join(", "); + const odkpTicks = ticks.flatMap((tick) => { + return { + Characters: tick.data.attendees.map((char) => { + return { + Name: char, + }; + }), + Description: tick.data.event?.name, + Value: tick.data.value, + } as ODKPRaidTick; + }); + + const raidTick = { + Attendance: 1, + Items: items, + Pool: { + Description: "Scars of Velious", + Name: "SoV", + PoolId: 4, + }, + Name: name, + Ticks: odkpTicks, + Timestamp: moment.utc(ticks[0].uploadDate).toISOString(), + } as ODKPRaidData; + + const config = { + method: "put", + url: "https://api.opendkp.com/clients/castle/raids", + headers: { + Authorization: `${accessTokens.TokenType} ${accessTokens.IdToken}`, + }, + data: JSON.stringify(raidTick), + }; + + axios(config) + .then(function (response) { + console.log(JSON.stringify(response.data)); + openDkpService.doTickAdjustments(ticks); + }) + .catch(function (error) { + console.log(error); + }); + }, + doTickAdjustments: async (ticks: RaidTick[]) => { + ticks.forEach((tick) => { + tick.data.adjustments?.forEach(async (adj) => { + const adjustment: ODKPAdjustment = { + Character: { Name: adj.player }, + Name: adj.reason, + Description: tick.name, + Value: adj.value, + Timestamp: moment.utc(ticks[0].uploadDate).toISOString(), + }; + await openDkpService.addAdjustment(adjustment); + }); + }); + }, + addItem: async ( + buyer: string, + itemName: string, + note: string, + price: number + ) => { + const character = characterCache.get(buyer); + if (!character) { + console.log(`Failed to find character ${buyer}`); + return; + } + const itemData = await openDkpService.getItemId(itemName); + if (itemData && itemData.ItemID !== -1) { + const item = { + CharacterId: character.CharacterId, + Dkp: price, + Notes: note, + ItemId: itemData.ItemID, + }; + const config = { + method: "put", + url: `https://api.opendkp.com/clients/castle/raids/70551/items`, + headers: { + Authorization: `${accessTokens.TokenType} ${accessTokens.IdToken}`, + }, + data: JSON.stringify(item), + }; + axios(config) + .then(function (response) { + console.log(JSON.stringify(response.data)); + }) + .catch(function (error) { + console.log(error); + }); + } + }, + addAdjustment: async (adjustment: { + player: string; + value: number; + reason: string; + }) => { + const odkpAdjustment = { + Character: { + Name: adjustment.player, + }, + Description: adjustment.reason, + Name: adjustment.reason, + Value: adjustment.value, + Timestamp: moment.utc().toISOString(), + }; + const config = { + method: "put", + url: "https://api.opendkp.com/clients/castle/adjustments", + headers: { + Authorization: `${accessTokens.TokenType} ${accessTokens.IdToken}`, + }, + data: JSON.stringify(odkpAdjustment), + }; + + axios(config) + .then(function (response) { + console.log(JSON.stringify(response.data)); + }) + .catch(function (error) { + console.log(error); + }); + }, +}; diff --git a/yarn.lock b/yarn.lock index e5cb647..805bcb3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -313,7 +313,7 @@ "@derhuerst/http-basic@^8.2.0": version "8.2.4" - resolved "https://registry.yarnpkg.com/@derhuerst/http-basic/-/http-basic-8.2.4.tgz#d021ebb8f65d54bea681ae6f4a8733ce89e7f59b" + resolved "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz" integrity sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw== dependencies: caseless "^0.12.0" @@ -401,7 +401,7 @@ "@discordjs/voice@^0.17.0": version "0.17.0" - resolved "https://registry.yarnpkg.com/@discordjs/voice/-/voice-0.17.0.tgz#37be97c20dc4144c4c4d27d8ad02f29373da7ea6" + resolved "https://registry.npmjs.org/@discordjs/voice/-/voice-0.17.0.tgz" integrity sha512-hArn9FF5ZYi1IkxdJEVnJi+OxlwLV0NJYWpKXsmNOojtGtAZHxmsELA+MZlu2KW1F/K1/nt7lFOfcMXNYweq9w== dependencies: "@types/ws" "^8.5.10" @@ -1038,7 +1038,7 @@ "@types/node@^10.0.3": version "10.17.60" - resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.60.tgz#35f3d6213daed95da7f0f73e75bcc6980e90597b" + resolved "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz" integrity sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw== "@types/pg@^8.6.5": @@ -1067,7 +1067,7 @@ "@types/ws@^8.5.10": version "8.5.10" - resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.10.tgz#4acfb517970853fa6574a3a6886791d04a396787" + resolved "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz" integrity sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A== dependencies: "@types/node" "*" @@ -1635,7 +1635,7 @@ caniuse-lite@^1.0.30001503: caseless@^0.12.0: version "0.12.0" - resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" + resolved "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz" integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw== cfb@~1.2.1: @@ -1806,7 +1806,7 @@ concat-map@0.0.1: concat-stream@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-2.0.0.tgz#414cf5af790a48c60ab9be4527d56d5e41133cb1" + resolved "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz" integrity sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A== dependencies: buffer-from "^1.0.0" @@ -1952,7 +1952,7 @@ dir-glob@^3.0.1: discord-api-types@0.37.83: version "0.37.83" - resolved "https://registry.yarnpkg.com/discord-api-types/-/discord-api-types-0.37.83.tgz#a22a799729ceded8176ea747157837ddf4708b1f" + resolved "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.83.tgz" integrity sha512-urGGYeWtWNYMKnYlZnOnDHm8fVRffQs3U0SpE8RHeiuLKb/u92APS8HoQnPTFbnXmY1vVnXjXO4dOxcAn3J+DA== discord-api-types@^0.26.1: @@ -2055,7 +2055,7 @@ emoji-regex@^8.0.0: env-paths@^2.2.0: version "2.2.1" - resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" + resolved "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz" integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== error-ex@^1.3.1: @@ -2411,7 +2411,7 @@ fb-watchman@^2.0.0: ffmpeg-static@^5.2.0: version "5.2.0" - resolved "https://registry.yarnpkg.com/ffmpeg-static/-/ffmpeg-static-5.2.0.tgz#6ca64a5ed6e69ec4896d175c1f69dd575db7c5ef" + resolved "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.2.0.tgz" integrity sha512-WrM7kLW+do9HLr+H6tk7LzQ7kPqbAgLjdzNE32+u3Ff11gXt9Kkkd2nusGFrlWMIe+XaA97t+I8JS7sZIrvRgA== dependencies: "@derhuerst/http-basic" "^8.2.0" @@ -2885,7 +2885,7 @@ http-proxy-agent@^4.0.1: http-response-object@^3.0.1: version "3.0.2" - resolved "https://registry.yarnpkg.com/http-response-object/-/http-response-object-3.0.2.tgz#7f435bb210454e4360d074ef1f989d5ea8aa9810" + resolved "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz" integrity sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA== dependencies: "@types/node" "^10.0.3" @@ -3737,14 +3737,14 @@ levn@^0.4.1: libsodium-wrappers@^0.7.13: version "0.7.13" - resolved "https://registry.yarnpkg.com/libsodium-wrappers/-/libsodium-wrappers-0.7.13.tgz#83299e06ee1466057ba0e64e532777d2929b90d3" + resolved "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.7.13.tgz" integrity sha512-kasvDsEi/r1fMzKouIDv7B8I6vNmknXwGiYodErGuESoFTohGSKZplFtVxZqHaoQ217AynyIFgnOVRitpHs0Qw== dependencies: libsodium "^0.7.13" libsodium@^0.7.13: version "0.7.13" - resolved "https://registry.yarnpkg.com/libsodium/-/libsodium-0.7.13.tgz#230712ec0b7447c57b39489c48a4af01985fb393" + resolved "https://registry.npmjs.org/libsodium/-/libsodium-0.7.13.tgz" integrity sha512-mK8ju0fnrKXXfleL53vtp9xiPq5hKM0zbDQtcxQIsSmxNgSxqCj6R7Hl9PkrNe2j29T4yoDaF7DJLK9/i5iWUw== lines-and-columns@^1.1.6: @@ -3828,7 +3828,7 @@ lru-cache@^7.17.0: m3u8stream@^0.8.6: version "0.8.6" - resolved "https://registry.yarnpkg.com/m3u8stream/-/m3u8stream-0.8.6.tgz#0d6de4ce8ee69731734e6b616e7b05dd9d9a55b1" + resolved "https://registry.npmjs.org/m3u8stream/-/m3u8stream-0.8.6.tgz" integrity sha512-LZj8kIVf9KCphiHmH7sbFQTVe4tOemb202fWwvJwR9W5ENW/1hxJN6ksAWGhQgSBSa3jyWhnjKU1Fw1GaOdbyA== dependencies: miniget "^4.2.2" @@ -3900,7 +3900,7 @@ mimic-fn@^2.1.0: miniget@^4.2.2: version "4.2.3" - resolved "https://registry.yarnpkg.com/miniget/-/miniget-4.2.3.tgz#3707a24c7c11c25d359473291638ab28aab349bd" + resolved "https://registry.npmjs.org/miniget/-/miniget-4.2.3.tgz" integrity sha512-SjbDPDICJ1zT+ZvQwK0hUcRY4wxlhhNpHL9nJOB2MEAXRGagTljsO8MEDzQMTFf0Q8g4QNi8P9lEm/g7e+qgzA== minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: @@ -4162,7 +4162,7 @@ parent-module@^1.0.0: parse-cache-control@^1.0.1: version "1.0.1" - resolved "https://registry.yarnpkg.com/parse-cache-control/-/parse-cache-control-1.0.1.tgz#8eeab3e54fa56920fe16ba38f77fa21aacc2d74e" + resolved "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz" integrity sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg== parse-json@^5.2.0: @@ -4454,7 +4454,7 @@ pretty-format@^27.0.0, pretty-format@^27.5.1: prism-media@^1.3.5: version "1.3.5" - resolved "https://registry.yarnpkg.com/prism-media/-/prism-media-1.3.5.tgz#ea1533229f304a1b774b158de40e98c765db0aa6" + resolved "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz" integrity sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA== prisma@^5.2.0: @@ -4466,7 +4466,7 @@ prisma@^5.2.0: progress@^2.0.3: version "2.0.3" - resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" + resolved "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== prompts@^2.0.1: @@ -4566,7 +4566,7 @@ reflect-metadata@^0.1.13: reflect-metadata@^0.2.2: version "0.2.2" - resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz#400c845b6cba87a21f2c65c4aeb158f4fa4d9c5b" + resolved "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz" integrity sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q== regenerator-runtime@^0.13.11: @@ -4686,7 +4686,7 @@ safe-regex-test@^1.0.0: sax@^1.1.3, sax@^1.2.4: version "1.4.1" - resolved "https://registry.yarnpkg.com/sax/-/sax-1.4.1.tgz#44cc8988377f126304d3b3fc1010c733b929ef0f" + resolved "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz" integrity sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg== saxes@^5.0.1: @@ -5127,20 +5127,10 @@ tslib@^1.8.1, tslib@^1.9.3: resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.1.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.5.0: - version "2.6.0" - resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz" - integrity sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA== - -tslib@^2.6.1: - version "2.6.1" - resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.1.tgz" - integrity sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig== - -tslib@^2.6.2: - version "2.6.3" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" - integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== +tslib@^2.1.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.5.0, tslib@^2.6.1, tslib@^2.6.2: + version "2.8.1" + resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== tsutils@^3.21.0: version "3.21.0" @@ -5151,7 +5141,7 @@ tsutils@^3.21.0: tsyringe@^4.8.0: version "4.8.0" - resolved "https://registry.yarnpkg.com/tsyringe/-/tsyringe-4.8.0.tgz#d599651b36793ba872870fee4f845bd484a5cac1" + resolved "https://registry.npmjs.org/tsyringe/-/tsyringe-4.8.0.tgz" integrity sha512-YB1FG+axdxADa3ncEtRnQCFq/M0lALGLxSZeVNbTU8NqhOVc51nnv2CISTcvc1kyv6EGPtXVr0v6lWeDxiijOA== dependencies: tslib "^1.9.3" @@ -5201,7 +5191,7 @@ typedarray-to-buffer@^3.1.5: typedarray@^0.0.6: version "0.0.6" - resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz" integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== typeorm@^0.3.6: @@ -5477,7 +5467,7 @@ ws@^8.13.0: ws@^8.16.0: version "8.17.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + resolved "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz" integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== xlsx@^0.18.5: @@ -5600,7 +5590,7 @@ yocto-queue@^0.1.0: ytdl-core@^4.11.5: version "4.11.5" - resolved "https://registry.yarnpkg.com/ytdl-core/-/ytdl-core-4.11.5.tgz#8cc3dc9e4884e24e8251250cfb56313a300811f0" + resolved "https://registry.npmjs.org/ytdl-core/-/ytdl-core-4.11.5.tgz" integrity sha512-27LwsW4n4nyNviRCO1hmr8Wr5J1wLLMawHCQvH8Fk0hiRqrxuIu028WzbJetiYH28K8XDbeinYW4/wcHQD1EXA== dependencies: m3u8stream "^0.8.6" From 9e45d6e4e27c64d2f2b91520d6e58cf79fe7c3af Mon Sep 17 00:00:00 2001 From: Ed Morgan Date: Wed, 13 Aug 2025 23:00:26 -0400 Subject: [PATCH 2/2] Add character importer --- package.json | 4 +- src/index.ts | 6 +- src/services/openDkpService.ts | 101 ++++++++++++++++++++++++++++++--- src/shared/classes.ts | 2 +- src/shared/util.ts | 80 ++++++++++++++++++++++++++ tsconfig.json | 3 +- 6 files changed, 183 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index 7cff7e8..a868c37 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,9 @@ "license": "MIT", "scripts": { "clean": "docker-compose down && rimraf postgres-db", - "start": "node_modules/.bin/tsc && nodemon", + "start": "tsc && nodemon", "format": "prettier-eslint --write \"src/**/*.ts\"", - "build": "node_modules/.bin/tsc --skipLibCheck", + "build": "tsc --skipLibCheck", "predev": "docker-compose up -d --wait", "dev": "nodemon", "test": "jest --watch", diff --git a/src/index.ts b/src/index.ts index f226b5d..5727371 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ import "reflect-metadata"; import { PrismaClient } from "@prisma/client"; import { log } from "./shared/logger"; import { openDkpService } from "./services/openDkpService"; +import { existsSync } from "fs"; // Global https.globalAgent.maxSockets = 5; @@ -106,8 +107,11 @@ prismaClient.$connect(); if (openDkpUsername && openDkpPassword && openDkpAuthClientId) { openDkpService .doUserPasswordAuth(openDkpUsername, openDkpPassword, openDkpAuthClientId) - .then(() => { + .then(async () => { console.log("Authenticated with OpenDKP"); + if (existsSync("./players.csv")) { + await openDkpService.importData("./players.csv"); + } }) .catch((reason) => { console.log("Failed to authenticate with OpenDKP: " + reason); diff --git a/src/services/openDkpService.ts b/src/services/openDkpService.ts index a206164..f1fc7b6 100644 --- a/src/services/openDkpService.ts +++ b/src/services/openDkpService.ts @@ -3,6 +3,13 @@ import { AdjustmentData, RaidTick } from "../features/dkp-records/raid-tick"; import moment from "moment"; import LRUCache from "lru-cache"; import { MINUTES } from "../shared/time"; +import { + convertClass, + convertRace, + processTsvFileWithHeaders, + RowObject, + toSentenceCase, +} from "../shared/util"; // Client for OpenDKP @@ -80,6 +87,16 @@ interface ODKPCharacterData { CreatedDate: string; // ISO date string } +export interface ODKPCharacterImportData { + Active: number; + Name: string; + Rank?: string; + Class: string; + Level: number; + Race: string; + Gender: string; +} + const characterCache = new LRUCache({ ttl: 60 * MINUTES, }); @@ -224,14 +241,18 @@ export const openDkpService = { doTickAdjustments: async (ticks: RaidTick[]) => { ticks.forEach((tick) => { tick.data.adjustments?.forEach(async (adj) => { - const adjustment: ODKPAdjustment = { - Character: { Name: adj.player }, - Name: adj.reason, - Description: tick.name, - Value: adj.value, - Timestamp: moment.utc(ticks[0].uploadDate).toISOString(), - }; - await openDkpService.addAdjustment(adjustment); + // const adjustment: ODKPAdjustment = { + // Character: { Name: adj.player }, + // Name: adj.reason, + // Description: tick.name, + // Value: adj.value, + // Timestamp: moment.utc(ticks[0].uploadDate).toISOString(), + // }; + await openDkpService.addAdjustment({ + player: adj.player, + reason: adj.reason, + value: adj.value, + }); }); }); }, @@ -302,4 +323,68 @@ export const openDkpService = { console.log(error); }); }, + addPlayer: async ( + name: string, + charClass: string, + race: string, + level: number, + gender: string, + active: number + ) => { + const odkpCharacter = { + Active: active, + Name: name, + Class: charClass, + Level: level, + Race: race, + Gender: gender, + } as ODKPCharacterImportData; + // const config = { + // method: "put", + // url: "https://api.opendkp.com/clients/castle/characters", + // headers: { + // Authorization: `${accessTokens.TokenType} ${accessTokens.IdToken}`, + // }, + // data: JSON.stringify(odkpCharacter), + // }; + console.log(odkpCharacter); + + // axios(config) + // .then(function (response) { + // console.log(JSON.stringify(response.data)); + // }) + // .catch(function (error) { + // console.log(error); + // }); + }, + importData: async (file: string) => { + await processTsvFileWithHeaders(file, async (row: RowObject) => { + if (file.includes("players.csv")) { + await openDkpService.handleCharacterImportRow(row); + } + }); + }, + handleCharacterImportRow: async (row: RowObject) => { + if (isNaN(Number.parseInt(row["member_id"]))) return; + const unescaped = row["profiledata"] + .replace(/""/g, '"') + .replace(/^"|"$/g, ""); + const characterDetails: { + race: string; + class: string; + guild: string; + gender: string; + level: string; + } = JSON.parse(unescaped); + //const status = row["member_status"] === "FALSE" ? 0 : 1; + // everyone is active for now? eqdkp data seems wrong + await openDkpService.addPlayer( + row["member_name"], + convertClass(characterDetails.class), + convertRace(characterDetails.race), + Number.parseInt(characterDetails.level), + toSentenceCase(characterDetails.gender), + 1 + ); + }, }; diff --git a/src/shared/classes.ts b/src/shared/classes.ts index 67cb08e..3ba1bf1 100644 --- a/src/shared/classes.ts +++ b/src/shared/classes.ts @@ -56,4 +56,4 @@ export const getClassAbreviation = (role?: string) => { default: return role; } -}; +}; \ No newline at end of file diff --git a/src/shared/util.ts b/src/shared/util.ts index 41a7e9d..d417bbb 100644 --- a/src/shared/util.ts +++ b/src/shared/util.ts @@ -1,4 +1,7 @@ import { truncate } from "lodash"; +import * as fs from "fs"; +import * as readline from "readline"; +import { Readable } from "stream"; // Node's Readable export const code = "```"; @@ -17,3 +20,80 @@ export const compactDescription = (description: string, length?: number) => { export const capitalize = (text: string): string => { return text.toLowerCase().charAt(0).toUpperCase() + text.slice(1); }; + +export const processTsvFileWithHeaders = async ( + filePath: string, + onRow: (row: RowObject) => void +): Promise => { + const fileStream: Readable = fs.createReadStream(filePath); + + const rl = readline.createInterface({ + input: fileStream, // fs.ReadStream, not web ReadableStream + crlfDelay: Infinity, + }); + + let headers: string[] | null = null; + + for await (const line of rl) { + if (!line.trim()) continue; + + const columns = line.split("\t"); + + if (!headers) { + headers = columns; + continue; + } + + const rowObj: RowObject = {}; + headers.forEach((header, i) => { + rowObj[header] = columns[i] ?? ""; + }); + + onRow(rowObj); + } +}; +export const convertRace = (raceCode: string) => { + return raceNames[raceCode] ?? "Unknown"; +}; +export const convertClass = (classCode: string) => { + return classNames[classCode] ?? "Unknown"; +}; +export const toSentenceCase = (str: string) => { + if (!str) return ""; + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); +}; + +export const raceNames: Record = { + "0": "Unknown", + "1": "Gnome", + "2": "Human", + "3": "Barbarian", + "4": "Dwarf", + "5": "High Elf", + "6": "Dark Elf", + "7": "Wood Elf", + "8": "Half Elf", + "10": "Troll", + "11": "Ogre", + "12": "Froglok", + "13": "Iksar", + "14": "Erudite", + "15": "Halfling", +}; +export const classNames: Record = { + "1": "Bard", + "15": "Cleric", + "16": "Druid", + "4": "Enchanter", + "5": "Magician", + "6": "Monk", + "7": "Necromancer", + "8": "Paladin", + "9": "Ranger", + "10": "Rogue", + "11": "Shadowknight", + "12": "Shaman", + "13": "Warrior", + "14": "Wizard", +}; +export type RowObject = Record; diff --git a/tsconfig.json b/tsconfig.json index 8e4f404..2256ce0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,8 @@ "moduleResolution": "node", "esModuleInterop": true, "emitDecoratorMetadata": true, - "experimentalDecorators": true + "experimentalDecorators": true, + "types": ["jest", "node"] }, "exclude": ["./node_modules"] }