Skip to content

Commit

Permalink
feat(market): add market module (#44)
Browse files Browse the repository at this point in the history
* feat(market): add market module

* chore: update market module metadata for toolchain

* chore: codacy style fixes

* chore: delete settings.json

* chore: move @types/table to devDependencies

* chore: use xiv prefix in module

* chore: update readme title

* chore: delete settings.json

* feat: move utility types into core
  • Loading branch information
karashiiro committed May 15, 2022
1 parent 3b9f9c4 commit cb9a8bf
Show file tree
Hide file tree
Showing 14 changed files with 302 additions and 7 deletions.
10 changes: 9 additions & 1 deletion core/src/module.ts
@@ -1,4 +1,12 @@
import { VenatModule } from './module-system/venat-module.decorator';
import { VenatModuleMetadata } from './module-system/venat-module.metadata';
import { LookupResult } from './util/io';
import { cleanText, TextParameter } from './util/text';

export { VenatModule, VenatModuleMetadata };
export {
cleanText,
LookupResult,
TextParameter,
VenatModule,
VenatModuleMetadata,
};
5 changes: 5 additions & 0 deletions core/src/util/io.ts
@@ -0,0 +1,5 @@
export interface LookupResult<TValue> {
value: TValue;
success: boolean;
err?: Error;
}
13 changes: 13 additions & 0 deletions core/src/util/text.ts
@@ -0,0 +1,13 @@
export interface TextParameter {
value: string;
}

/**
* Cleans a text value for use in commands.
* @param value - The value to transform.
* @returns The cleaned text.
*/
export function cleanText({ value }: TextParameter): string {
const duplicateWhitespace = /(\s\s+)*/gm;
return value.normalize().replaceAll(duplicateWhitespace, '').trim();
}
6 changes: 2 additions & 4 deletions modules/venat-module-example/tsconfig.json
Expand Up @@ -4,9 +4,7 @@
"baseUrl": ".",
"rootDir": ".",
"sourceRoot": "src",
"outDir": "./dist",
"outDir": "./dist"
},
"references": [
{"path": "../../core"}
]
"references": [{ "path": "../../core" }]
}
1 change: 1 addition & 0 deletions modules/venat-module-xiv-market/README.md
@@ -0,0 +1 @@
# venat-module-xiv-market
13 changes: 13 additions & 0 deletions modules/venat-module-xiv-market/package.json
@@ -0,0 +1,13 @@
{
"name": "@the-convocation/venat-module-xiv-market",
"version": "0.0.0-semantic-release",
"packageManager": "yarn@3.2.1",
"main": "dist/src/module.js",
"scripts": {
"clean": "rimraf dist",
"build": "yarn clean && tsc"
},
"dependencies": {
"@the-convocation/venat-core": "workspace:^"
}
}
88 changes: 88 additions & 0 deletions modules/venat-module-xiv-market/src/commands/market.command.ts
@@ -0,0 +1,88 @@
import { TransformPipe } from '@discord-nestjs/common';
import {
Command,
DiscordTransformedCommand,
Payload,
UsePipes,
} from '@discord-nestjs/core';
import { Logger } from '@nestjs/common';
import { InteractionReplyOptions, MessageEmbed } from 'discord.js';
import { getMarketListings } from '../data/universalis';
import { getItemIdByName } from '../data/xivapi';
import { table } from 'table';
import { MarketDto } from '../dto/market.dto';

@Command({
description: 'Look up prices for an item on the market board.',
name: 'market',
})
@UsePipes(TransformPipe)
export class MarketCommand implements DiscordTransformedCommand<MarketDto> {
private readonly logger: Logger = new Logger('MarketCommand');

public async handler(
@Payload() dto: MarketDto,
): Promise<InteractionReplyOptions> {
const itemLookup = await getItemIdByName(dto.item);
if (itemLookup.err != null) {
this.logger.error(itemLookup.err.message, itemLookup.err.stack);
return {
content: 'Failed to access XIVAPI; please try again later.',
};
}

if (!itemLookup.success) {
return {
content:
'The item could not be found; please check your spelling and try again.',
};
}

const item = itemLookup.value;
const marketLookup = await getMarketListings(item.ID, dto.server);
if (marketLookup.err != null) {
this.logger.error(marketLookup.err.message, marketLookup.err.stack);
return {
content:
'The item could not be found; please check your spelling of the server and try again.',
};
}

if (!marketLookup.success) {
return {
content:
'The item could not be found; please check your spelling and try again.',
};
}

const { lastUploadTime, listings, worldName, dcName } = marketLookup.value;
const listingsEmbed = new MessageEmbed()
.setTitle(`Cheapest listings for ${item.Name} on ${dcName ?? worldName}`)
.setDescription(
'```' +
table([
['HQ', 'Unit Price', 'Quantity', 'Total', 'World'],
...listings
.sort((a, b) => a.pricePerUnit - b.pricePerUnit)
.slice(0, 10)
.map((l) => [
l.hq ? 'Yes' : 'No',
l.pricePerUnit.toLocaleString('en'),
l.quantity.toLocaleString('en'),
l.total.toLocaleString('en'),
worldName ?? l.worldName,
]),
]) +
'```',
)
.setColor('#a58947')
.setFooter({
text: 'Last updated:',
})
.setTimestamp(lastUploadTime);

return {
embeds: [listingsEmbed],
};
}
}
32 changes: 32 additions & 0 deletions modules/venat-module-xiv-market/src/data/universalis.ts
@@ -0,0 +1,32 @@
import axios, { AxiosResponse } from 'axios';
import { LookupResult } from '@the-convocation/venat-core';

export interface UniversalisMarketInfo {
lastUploadTime: number;
listings: {
pricePerUnit: number;
quantity: number;
total: number;
hq: boolean;
worldName?: string;
}[];

worldName?: string;
dcName?: string;
}

export async function getMarketListings(
itemId: number,
server: string,
): Promise<LookupResult<UniversalisMarketInfo>> {
let res: AxiosResponse<UniversalisMarketInfo, any>;
try {
res = await axios.get<UniversalisMarketInfo>(
`https://universalis.app/api/${server}/${itemId}`,
);
} catch (err) {
return { value: null, success: false, err };
}

return { value: res.data, success: true };
}
33 changes: 33 additions & 0 deletions modules/venat-module-xiv-market/src/data/xivapi.ts
@@ -0,0 +1,33 @@
import axios, { AxiosResponse } from 'axios';
import { LookupResult } from '@the-convocation/venat-core';

export interface XIVAPIItem {
ID: number;
Name: string;
}

export interface XIVAPISearchResponse {
Results: XIVAPIItem[];
}

export async function getItemIdByName(
name: string,
): Promise<LookupResult<XIVAPIItem>> {
let res: AxiosResponse<XIVAPISearchResponse, any>;
try {
res = await axios.get<XIVAPISearchResponse>(
`https://xivapi.com/search?string=${name}&filters=ItemSearchCategory.ID>8&columns=ID,Name`,
);
} catch (err) {
return { value: null, success: false, err };
}

const nameLower = name.toLowerCase();
for (const item of res.data.Results) {
if (nameLower === item.Name.toLowerCase()) {
return { value: item, success: true };
}
}

return { value: null, success: false };
}
21 changes: 21 additions & 0 deletions modules/venat-module-xiv-market/src/dto/market.dto.ts
@@ -0,0 +1,21 @@
import { Param } from '@discord-nestjs/core';
import { Transform } from 'class-transformer';
import { cleanText } from '@the-convocation/venat-core';

export class MarketDto {
@Transform(cleanText)
@Param({
description: 'The name of the item to look up prices for.',
name: 'item',
required: true,
})
public item: string;

@Transform(cleanText)
@Param({
description: 'The server to look up prices on.',
name: 'server',
required: true,
})
public server: string;
}
20 changes: 20 additions & 0 deletions modules/venat-module-xiv-market/src/module.ts
@@ -0,0 +1,20 @@
import { DiscordModule } from '@discord-nestjs/core';
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { VenatModule } from '@the-convocation/venat-core';
import { MarketCommand } from './commands/market.command';

@VenatModule({
description: "A module for searching FFXIV's market data.",
name: 'Market',
})
@Module({
imports: [DiscordModule.forFeature()],
providers: [MarketCommand],
})
export class MarketModule implements OnModuleInit {
private readonly logger: Logger = new Logger('MarketModule');

public onModuleInit(): void {
this.logger.log('MarketModule loaded!');
}
}
10 changes: 10 additions & 0 deletions modules/venat-module-xiv-market/tsconfig.json
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"rootDir": ".",
"sourceRoot": "src",
"outDir": "./dist"
},
"references": [{ "path": "../../core" }]
}
3 changes: 3 additions & 0 deletions package.json
Expand Up @@ -25,11 +25,13 @@
"@nestjs/core": "^8.0.0",
"@nestjs/event-emitter": "^1.1.1",
"@nestjs/typeorm": "^9.0.0-next.2",
"axios": "^0.27.2",
"discord.js": "^13.6.0",
"pg": "^8.7.3",
"reflect-metadata": "^0.1.13",
"resolve-package-path": "^4.0.3",
"rxjs": "^7.2.0",
"table": "^6.8.0",
"typeorm": "^0.3.6"
},
"devDependencies": {
Expand All @@ -48,6 +50,7 @@
"@types/jest": "27.5.0",
"@types/node": "^16.0.0",
"@types/supertest": "^2.0.11",
"@types/table": "^6.3.2",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"@wrench/semantic-release-ws": "^0.0.9",
Expand Down

0 comments on commit cb9a8bf

Please sign in to comment.