merged libraries into one
This commit is contained in:
5
packages/lib/.gitignore
vendored
Normal file
5
packages/lib/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.DS_Store
|
||||
.env.keys
|
||||
8
packages/lib/.prettierrc.yaml
Normal file
8
packages/lib/.prettierrc.yaml
Normal file
@@ -0,0 +1,8 @@
|
||||
trailingComma: all
|
||||
tabWidth: 2
|
||||
useTabs: false
|
||||
semi: true
|
||||
singleQuote: true
|
||||
printWidth: 140
|
||||
experimentalTernaries: true
|
||||
quoteProps: consistent
|
||||
23
packages/lib/README.md
Normal file
23
packages/lib/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# tsdown-starter
|
||||
|
||||
A starter for creating a TypeScript package.
|
||||
|
||||
## Development
|
||||
|
||||
- Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
- Run the unit tests:
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
|
||||
- Build the library:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
9
packages/lib/build.ts
Normal file
9
packages/lib/build.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
const bundle = await Bun.build({
|
||||
entrypoints: ['./src/***.ts', '!./src/**/*.test.ts'],
|
||||
outdir: 'dist',
|
||||
minify: true,
|
||||
});
|
||||
|
||||
if (!bundle.success) {
|
||||
throw new AggregateError(bundle.logs);
|
||||
}
|
||||
7
packages/lib/bunfig.toml
Normal file
7
packages/lib/bunfig.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
[test]
|
||||
coverage = true
|
||||
coverageSkipTestFiles = true
|
||||
coverageReporter = ["text", "lcov"]
|
||||
|
||||
[run]
|
||||
bun = true
|
||||
7
packages/lib/drizzle.config.ts
Normal file
7
packages/lib/drizzle.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "sqlite",
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
});
|
||||
8
packages/lib/fixtures/commands/test1.command.ts
Normal file
8
packages/lib/fixtures/commands/test1.command.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { CommandHandler } from '@/commands/command-handler.type';
|
||||
|
||||
const handler: CommandHandler<{ name: string; type: 1; description: string }> = {
|
||||
definition: { name: 'test1', type: 1, description: 'Test command 1' },
|
||||
execute: async () => {},
|
||||
};
|
||||
|
||||
export default handler;
|
||||
8
packages/lib/fixtures/commands/test2.command.ts
Normal file
8
packages/lib/fixtures/commands/test2.command.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { CommandHandler } from '@/commands/command-handler.type';
|
||||
|
||||
const handler: CommandHandler<{ name: string; type: 1; description: string }> = {
|
||||
definition: { name: 'test2', type: 1, description: 'Test command 2' },
|
||||
execute: async () => {},
|
||||
};
|
||||
|
||||
export default handler;
|
||||
30
packages/lib/fixtures/jsd/test.ts
Normal file
30
packages/lib/fixtures/jsd/test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as StarKitten from '@star-kitten/lib/discord';
|
||||
import type { ExecutableInteraction } from '@star-kitten/lib/discord';
|
||||
import { createActionRow, createButton, createContainer, createTextDisplay } from '@star-kitten/lib/discord/components';
|
||||
import type { PageContext } from '@star-kitten/lib/discord/pages';
|
||||
import { type Appraisal } from '@star-kitten/lib/eve/third-party/janice.js';
|
||||
import { formatNumberToShortForm } from '@star-kitten/lib/util/text.js';
|
||||
|
||||
export function renderAppraisal(appraisal: Appraisal, pageCtx: PageContext<any>, interaction: ExecutableInteraction) {
|
||||
const formatter = new Intl.NumberFormat(interaction.locale || 'en-US', {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 2,
|
||||
});
|
||||
const world = 'world';
|
||||
return StarKitten.createElement(
|
||||
'ActionRow',
|
||||
{},
|
||||
StarKitten.createElement(
|
||||
'Container',
|
||||
{ color: '0x1da57a' },
|
||||
StarKitten.createElement('TextDisplay', {}, '' + `Hello ${world}` + ''),
|
||||
pageCtx.state.currentPage !== 'share'
|
||||
? StarKitten.createElement(
|
||||
'ActionRow',
|
||||
{},
|
||||
StarKitten.createElement('Button', { key: 'share', disabled: '{!unknown}' }, 'Share in Channel'),
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
32
packages/lib/fixtures/jsonQuery/test-data-array.json
Normal file
32
packages/lib/fixtures/jsonQuery/test-data-array.json
Normal file
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Alice",
|
||||
"age": 30,
|
||||
"department": "Engineering"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Bob",
|
||||
"age": 25,
|
||||
"department": "Marketing"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Charlie",
|
||||
"age": 35,
|
||||
"department": "Engineering"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Diana",
|
||||
"age": 28,
|
||||
"department": "Sales"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Eve",
|
||||
"age": 32,
|
||||
"department": "Engineering"
|
||||
}
|
||||
]
|
||||
3
packages/lib/fixtures/jsonQuery/test-data-invalid.json
Normal file
3
packages/lib/fixtures/jsonQuery/test-data-invalid.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"invalid": "json",
|
||||
"missing": "closing brace"
|
||||
32
packages/lib/fixtures/jsonQuery/test-data-object.json
Normal file
32
packages/lib/fixtures/jsonQuery/test-data-object.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"users": {
|
||||
"alice": {
|
||||
"id": 1,
|
||||
"name": "Alice",
|
||||
"age": 30,
|
||||
"department": "Engineering"
|
||||
},
|
||||
"bob": {
|
||||
"id": 2,
|
||||
"name": "Bob",
|
||||
"age": 25,
|
||||
"department": "Marketing"
|
||||
}
|
||||
},
|
||||
"departments": {
|
||||
"engineering": {
|
||||
"name": "Engineering",
|
||||
"budget": 1000000,
|
||||
"headCount": 15
|
||||
},
|
||||
"marketing": {
|
||||
"name": "Marketing",
|
||||
"budget": 500000,
|
||||
"headCount": 8
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"version": "1.0.0",
|
||||
"environment": "test"
|
||||
}
|
||||
}
|
||||
29
packages/lib/fixtures/markdown/test-data-colors.json
Normal file
29
packages/lib/fixtures/markdown/test-data-colors.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"colors": ["red", "blue", "green", "yellow"],
|
||||
"testText": {
|
||||
"simple": "Hello World",
|
||||
"multiline": "Line 1\nLine 2\nLine 3",
|
||||
"withSpecialChars": "Text with !@#$%^&*()_+-=[]{}|;':\",./<>?",
|
||||
"empty": "",
|
||||
"unicode": "Unicode: 🌟 ❤️ 🔥",
|
||||
"code": "function test() { return 'hello'; }"
|
||||
},
|
||||
"expected": {
|
||||
"red": {
|
||||
"simple": "```ansi\n\u001b[2;31mHello World\u001b[0m```\n",
|
||||
"empty": "```ansi\n\u001b[2;31m\u001b[0m```\n"
|
||||
},
|
||||
"blue": {
|
||||
"simple": "```ansi\n\u001b[2;32m\u001b[2;36m\u001b[2;34mHello World\u001b[0m\u001b[2;36m\u001b[0m\u001b[2;32m\u001b[0m```\n",
|
||||
"empty": "```ansi\n\u001b[2;32m\u001b[2;36m\u001b[2;34m\u001b[0m\u001b[2;36m\u001b[0m\u001b[2;32m\u001b[0m```\n"
|
||||
},
|
||||
"green": {
|
||||
"simple": "```ansi\n\u001b[2;36mHello World\u001b[0m```\n",
|
||||
"empty": "```ansi\n\u001b[2;36m\u001b[0m```\n"
|
||||
},
|
||||
"yellow": {
|
||||
"simple": "```ansi\n\u001b[2;33mHello World\u001b[0m```\n",
|
||||
"empty": "```ansi\n\u001b[2;33m\u001b[0m```\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
41
packages/lib/fixtures/markdown/test-data-markup.json
Normal file
41
packages/lib/fixtures/markdown/test-data-markup.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"boldMarkup": {
|
||||
"complete": "<b>bold text</b>",
|
||||
"openOnly": "<b>bold text",
|
||||
"closeOnly": "bold text</b>",
|
||||
"nested": "<b>outer <b>inner</b> text</b>",
|
||||
"empty": "<b></b>",
|
||||
"multiple": "<b>first</b> and <b>second</b>",
|
||||
"mixed": "<b>bold</b> with <i>italic</i> text"
|
||||
},
|
||||
"italicMarkup": {
|
||||
"complete": "<i>italic text</i>",
|
||||
"openOnly": "<i>italic text",
|
||||
"closeOnly": "italic text</i>",
|
||||
"nested": "<i>outer <i>inner</i> text</i>",
|
||||
"empty": "<i></i>",
|
||||
"multiple": "<i>first</i> and <i>second</i>",
|
||||
"mixed": "<i>italic</i> with <b>bold</b> text"
|
||||
},
|
||||
"colorTags": {
|
||||
"hex6": "<color=0xFF5733>colored text</color>",
|
||||
"hex8": "<color=0xFF5733AA>colored text</color>",
|
||||
"hexWithoutPrefix": "<color=FF5733>colored text</color>",
|
||||
"namedColor": "<color=red>colored text</color>",
|
||||
"nested": "<color=blue>outer <color=red>inner</color> text</color>",
|
||||
"empty": "<color=green></color>",
|
||||
"multiple": "<color=red>first</color> and <color=blue>second</color>"
|
||||
},
|
||||
"eveLinks": {
|
||||
"simple": "<a href=showinfo:587>Rifter</a>",
|
||||
"withSpaces": "<a href=showinfo:12345>Ship Name With Spaces</a>",
|
||||
"multiple": "<a href=showinfo:587>Rifter</a> and <a href=showinfo:588>Merlin</a>",
|
||||
"nested": "Check out <a href=showinfo:587>Rifter</a> for PvP",
|
||||
"empty": "<a href=showinfo:587></a>"
|
||||
},
|
||||
"combined": {
|
||||
"allMarkup": "<b>Bold</b> <i>italic</i> <color=red>colored</color> <a href=showinfo:587>linked</a>",
|
||||
"nestedComplex": "<b><color=blue><a href=showinfo:587>Bold Blue Rifter</a></color></b>",
|
||||
"realWorldExample": "The <b><color=0xFF5733>Rifter</color></b> is a <i>fast</i> <a href=showinfo:587>frigate</a> used in PvP."
|
||||
}
|
||||
}
|
||||
34
packages/lib/fixtures/markdown/test-data-time.json
Normal file
34
packages/lib/fixtures/markdown/test-data-time.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"milliseconds": {
|
||||
"zero": 0,
|
||||
"oneSecond": 1000,
|
||||
"oneMinute": 60000,
|
||||
"oneHour": 3600000,
|
||||
"complex": 3661500,
|
||||
"daysWorthMs": 86400000,
|
||||
"fractionalSeconds": 1500,
|
||||
"smallFraction": 100
|
||||
},
|
||||
"seconds": {
|
||||
"zero": 0,
|
||||
"oneSecond": 1,
|
||||
"oneMinute": 60,
|
||||
"oneHour": 3600,
|
||||
"complex": 3661,
|
||||
"daysWorthSec": 86400,
|
||||
"fractionalInput": 3661.5
|
||||
},
|
||||
"expected": {
|
||||
"zero": "0.0s",
|
||||
"oneSecond": "1.0s",
|
||||
"oneMinute": "1m",
|
||||
"oneHour": "1h",
|
||||
"complexMs": "1h 1m 1.5s",
|
||||
"complexSec": "1h 1m 1s",
|
||||
"daysMs": "24h",
|
||||
"daysSec": "24h",
|
||||
"fractionalSeconds": "1.5s",
|
||||
"smallFraction": "0.1s",
|
||||
"fractionalInputSec": "1h 1m 1s"
|
||||
}
|
||||
}
|
||||
166
packages/lib/package.json
Normal file
166
packages/lib/package.json
Normal file
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"name": "@star-kitten/lib",
|
||||
"version": "0.0.0",
|
||||
"description": "Star Kitten Library.",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/author/library#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/author/library/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/author/library.git"
|
||||
},
|
||||
"author": "JB <j-b-3.deviate267@passmail.net>",
|
||||
"files": [
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index*.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.js",
|
||||
"types": "./dist/index*.d.ts"
|
||||
},
|
||||
"./package.json": "./package.json",
|
||||
"./util": {
|
||||
"types": "./src/util/index.d.ts",
|
||||
"require": "./src/util/index.js",
|
||||
"import": "./dist/util/index.js"
|
||||
},
|
||||
"./util/*": {
|
||||
"types": "./src/util/**/*.d.ts",
|
||||
"require": "./src/util/*",
|
||||
"import": "./dist/util/*"
|
||||
},
|
||||
"./eve": {
|
||||
"types": "./src/eve/index.d.ts",
|
||||
"require": "./src/eve/index.js",
|
||||
"import": "./dist/eve/index.js"
|
||||
},
|
||||
"./eve/*": {
|
||||
"types": "./src/eve/**/*.d.ts",
|
||||
"require": "./src/eve/*",
|
||||
"import": "./dist/eve/*"
|
||||
},
|
||||
"./eve/esi": {
|
||||
"import": "./dist/eve/esi/index.js",
|
||||
"types": "./src/eve/esi/index*.d.ts",
|
||||
"require": "./src/eve/esi/index.js"
|
||||
},
|
||||
"./eve/db": {
|
||||
"import": "./dist/eve/db/index.js",
|
||||
"types": "./src/eve/db/index*.d.ts",
|
||||
"require": "./src/eve/db/index.js"
|
||||
},
|
||||
"./eve/ref": {
|
||||
"import": "./dist/eve/ref/index.js",
|
||||
"types": "./src/eve/ref/index*.d.ts",
|
||||
"require": "./src/eve/ref/index.js"
|
||||
},
|
||||
"./eve/third-party": {
|
||||
"import": "./dist/eve/third-party/index.js",
|
||||
"types": "./src/eve/third-party/index*.d.ts",
|
||||
"require": "./src/eve/third-party/index.js"
|
||||
},
|
||||
"./eve/models": {
|
||||
"import": "./dist/eve/models/index.js",
|
||||
"types": "./src/eve/models/index*.d.ts",
|
||||
"require": "./src/eve/models/index.js"
|
||||
},
|
||||
"./eve/data/*": "./data/*",
|
||||
"./discord": {
|
||||
"import": "./dist/discord/index.js",
|
||||
"types": "./src/discord/index*.d.ts",
|
||||
"require": "./src/discord/index.js"
|
||||
},
|
||||
"./discord/commands": {
|
||||
"types": "./src/discord/commands/index*.d.ts",
|
||||
"require": "./src/discord/commands/index.js",
|
||||
"import": "./dist/discord/commands/index.js"
|
||||
},
|
||||
"./discord/components": {
|
||||
"types": "./src/discord/components/index*.d.ts",
|
||||
"require": "./src/discord/components/index.js",
|
||||
"import": "./dist/discord/components/index.js"
|
||||
},
|
||||
"./discord/pages": {
|
||||
"types": "./src/discord/pages/index*.d.ts",
|
||||
"require": "./src/discord/pages/index.js",
|
||||
"import": "./dist/discord/pages/index.js"
|
||||
},
|
||||
"./discord/common": {
|
||||
"types": "./src/discord/common/index*.d.ts",
|
||||
"require": "./src/discord/common/index.js",
|
||||
"import": "./dist/discord/common/index.js"
|
||||
},
|
||||
"./discord/jsx": {
|
||||
"types": "./src/discord/jsx/types.d.ts",
|
||||
"import": "./dist/discord/jsx/index.js"
|
||||
},
|
||||
"./discord/jsx-runtime": {
|
||||
"types": "./src/discord/jsx/types.d.ts",
|
||||
"default": "./dist/discord/jsx/jsx-runtime.js"
|
||||
},
|
||||
"./discord/jsx-dev-runtime": {
|
||||
"types": "./src/discord/jsx/types.d.ts",
|
||||
"default": "./dist/discord/jsx/jsx-dev-runtime.js"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"dev": "tsdown --watch",
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"release": "bumpp && npm publish",
|
||||
"generate-migrations": "bunx drizzle-kit generate --dialect sqlite --schema ./src/db/schema.ts",
|
||||
"migrate": "bun run ./src/db/migrate.ts",
|
||||
"get-data": "bun refresh:reference-data && bun refresh:hoboleaks && bun static-export",
|
||||
"refresh:reference-data": "bun run ../util/dist/download-and-extract.js https://data.everef.net/reference-data/reference-data-latest.tar.xz ./data/reference-data",
|
||||
"refresh:hoboleaks": "bun run ../util/dist/download-and-extract.js https://data.everef.net/hoboleaks-sde/hoboleaks-sde-latest.tar.xz ./data/hoboleaks",
|
||||
"static-export": "bun run ./scripts/export-solar-systems.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.5",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/node": "^22.15.17",
|
||||
"@types/node-cache": "^4.2.5",
|
||||
"@types/stream-chain": "^2.1.0",
|
||||
"@types/stream-json": "^1.7.8",
|
||||
"bumpp": "^10.1.0",
|
||||
"tsdown": "^0.14.2",
|
||||
"typescript": "^5.9.2",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@types/jwk-to-pem": "^2.0.3",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"prettier-plugin-multiline-arrays": "^4.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@projectdysnomia/dysnomia": "github:projectdysnomia/dysnomia#dev",
|
||||
"acorn": "^8.14.0",
|
||||
"acorn-jsx": "^5.3.2",
|
||||
"html-dom-parser": "^5.1.1",
|
||||
"cron-parser": "^5.3.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"node-cache": "^5.1.2",
|
||||
"stream-chain": "^3.4.0",
|
||||
"stream-json": "^1.9.1",
|
||||
"winston": "^3.17.0",
|
||||
"@orama/orama": "^3.1.13",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"fp-filters": "^0.5.4",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jwk-to-pem": "^2.0.7",
|
||||
"jwt-decode": "^4.0.0"
|
||||
}
|
||||
}
|
||||
19
packages/lib/scripts/export-solar-systems.ts
Normal file
19
packages/lib/scripts/export-solar-systems.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { join } from "node:path";
|
||||
|
||||
const db = new Database(join(process.cwd(), 'data/evestatic.db'));
|
||||
|
||||
const query = db.query("SELECT * FROM mapSolarSystems");
|
||||
const results = query.all();
|
||||
|
||||
const output = results.reduce((acc: any, system: any) => {
|
||||
acc[system.solarSystemID] = system;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const jsonData = JSON.stringify(output, null, 2);
|
||||
|
||||
const fs = await import('fs/promises');
|
||||
await fs.writeFile(join(process.cwd(), 'data/reference-data/solar_systems.json'), jsonData);
|
||||
|
||||
db.close();
|
||||
14
packages/lib/src/discord/commands/command-context.type.ts
Normal file
14
packages/lib/src/discord/commands/command-context.type.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Cache } from '@/discord/core/cache.type';
|
||||
import type { KVStore } from '@/discord/core/kv-store.type.ts';
|
||||
import type { Client } from '@projectdysnomia/dysnomia';
|
||||
import type { CommandState } from './command-state';
|
||||
|
||||
export interface PartialContext<T = any> {
|
||||
client: Client;
|
||||
cache: Cache;
|
||||
kv: KVStore;
|
||||
id?: string; // unique id for this command instance
|
||||
state?: CommandState<T>; // state associated with this command instance
|
||||
}
|
||||
|
||||
export type CommandContext<T = any> = Required<PartialContext<T>>;
|
||||
32
packages/lib/src/discord/commands/command-handler.ts
Normal file
32
packages/lib/src/discord/commands/command-handler.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
AutocompleteInteraction,
|
||||
CommandInteraction,
|
||||
ComponentInteraction,
|
||||
Constants,
|
||||
ModalSubmitInteraction,
|
||||
type ApplicationCommandOptionAutocomplete,
|
||||
type ApplicationCommandOptions,
|
||||
type ApplicationCommandStructure,
|
||||
type ChatInputApplicationCommandStructure,
|
||||
} from '@projectdysnomia/dysnomia';
|
||||
import type { CommandContext, PartialContext } from './command-context.type';
|
||||
|
||||
export interface CommandHandler<T extends ApplicationCommandStructure> {
|
||||
definition: T;
|
||||
execute: (interaction: ExecutableInteraction, ctx: CommandContext) => Promise<void>;
|
||||
}
|
||||
|
||||
export type ExecutableInteraction = CommandInteraction | ModalSubmitInteraction | ComponentInteraction | AutocompleteInteraction;
|
||||
|
||||
export type ChatCommandDefinition = Omit<ChatInputApplicationCommandStructure, 'type'>;
|
||||
export function createChatCommand(
|
||||
definition: ChatCommandDefinition,
|
||||
execute: (interaction: CommandInteraction, ctx: CommandContext) => Promise<void>,
|
||||
): CommandHandler<ChatInputApplicationCommandStructure> {
|
||||
const def = definition as ChatInputApplicationCommandStructure;
|
||||
def.type = 1; // CHAT_INPUT
|
||||
return {
|
||||
definition: def,
|
||||
execute,
|
||||
};
|
||||
}
|
||||
45
packages/lib/src/discord/commands/command-helpers.ts
Normal file
45
packages/lib/src/discord/commands/command-helpers.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
Interaction,
|
||||
CommandInteraction,
|
||||
Constants,
|
||||
ModalSubmitInteraction,
|
||||
ComponentInteraction,
|
||||
AutocompleteInteraction,
|
||||
PingInteraction,
|
||||
} from '@projectdysnomia/dysnomia';
|
||||
import type { ExecutableInteraction } from './command-handler';
|
||||
|
||||
export function isApplicationCommand(interaction: Interaction): interaction is CommandInteraction {
|
||||
return interaction.type === Constants.InteractionTypes.APPLICATION_COMMAND;
|
||||
}
|
||||
|
||||
export function isModalSubmit(interaction: Interaction): interaction is ModalSubmitInteraction {
|
||||
return interaction.type === Constants.InteractionTypes.MODAL_SUBMIT;
|
||||
}
|
||||
|
||||
export function isMessageComponent(interaction: Interaction): interaction is ComponentInteraction {
|
||||
return interaction.type === Constants.InteractionTypes.MESSAGE_COMPONENT;
|
||||
}
|
||||
|
||||
export function isAutocomplete(interaction: Interaction): interaction is AutocompleteInteraction {
|
||||
return interaction.type === Constants.InteractionTypes.APPLICATION_COMMAND_AUTOCOMPLETE;
|
||||
}
|
||||
|
||||
export function isPing(interaction: Interaction): interaction is PingInteraction {
|
||||
return interaction.type === Constants.InteractionTypes.PING;
|
||||
}
|
||||
|
||||
export function commandHasName(interaction: Interaction, name: string): boolean {
|
||||
return isApplicationCommand(interaction) && interaction.data.name === name;
|
||||
}
|
||||
|
||||
export function commandHasIdPrefix(interaction: Interaction, prefix: string): boolean {
|
||||
return (isModalSubmit(interaction) || isMessageComponent(interaction)) && interaction.data.custom_id.startsWith(prefix);
|
||||
}
|
||||
|
||||
export function getCommandName(interaction: ExecutableInteraction): string | undefined {
|
||||
if (isApplicationCommand(interaction) || isAutocomplete(interaction)) {
|
||||
return interaction.data.name;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
63
packages/lib/src/discord/commands/command-injection.ts
Normal file
63
packages/lib/src/discord/commands/command-injection.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { type InteractionModalContent, type Component } from '@projectdysnomia/dysnomia';
|
||||
import type { CommandContext, PartialContext } from './command-context.type';
|
||||
import { isApplicationCommand, isMessageComponent } from './command-helpers';
|
||||
import type { ExecutableInteraction } from './command-handler';
|
||||
|
||||
export function injectInteraction(interaction: ExecutableInteraction, ctx: PartialContext): [ExecutableInteraction, CommandContext] {
|
||||
// Wrap the interaction methods to inject command tracking ids into all custom_ids for modals and components.
|
||||
if (ctx.state.name && (isApplicationCommand(interaction) || isMessageComponent(interaction))) {
|
||||
const _originalCreateModal = interaction.createModal.bind(interaction);
|
||||
interaction.createModal = (content: InteractionModalContent) => {
|
||||
validateCustomIdLength(content.custom_id);
|
||||
content.custom_id = `${content.custom_id}_${ctx.state.id}`;
|
||||
return _originalCreateModal(content);
|
||||
};
|
||||
|
||||
const _originalCreateMessage = interaction.createMessage.bind(interaction);
|
||||
interaction.createMessage = (content) => {
|
||||
if (typeof content === 'string') return _originalCreateMessage(content);
|
||||
if (content.components) {
|
||||
addCommandIdToComponentCustomIds(content.components, ctx.state.id);
|
||||
}
|
||||
return _originalCreateMessage(content);
|
||||
};
|
||||
|
||||
const _originalEditMessage = interaction.editMessage.bind(interaction);
|
||||
interaction.editMessage = (messageID, content) => {
|
||||
if (typeof content === 'string') return _originalEditMessage(messageID, content);
|
||||
if (content.components) {
|
||||
addCommandIdToComponentCustomIds(content.components, ctx.state.id);
|
||||
}
|
||||
return _originalEditMessage(messageID, content);
|
||||
};
|
||||
|
||||
const _originalCreateFollowup = interaction.createFollowup.bind(interaction);
|
||||
interaction.createFollowup = (content) => {
|
||||
if (typeof content === 'string') return _originalCreateFollowup(content);
|
||||
if (content.components) {
|
||||
addCommandIdToComponentCustomIds(content.components, ctx.state.id);
|
||||
}
|
||||
return _originalCreateFollowup(content);
|
||||
};
|
||||
}
|
||||
return [interaction, ctx as CommandContext];
|
||||
}
|
||||
|
||||
function validateCustomIdLength(customId: string) {
|
||||
if (customId.length > 80) {
|
||||
throw new Error(`Custom ID too long: ${customId.length} characters (max 80) with this framework. Consider using shorter IDs.`);
|
||||
}
|
||||
}
|
||||
|
||||
function addCommandIdToComponentCustomIds(components: Component[], commandId: string) {
|
||||
components.forEach((component) => {
|
||||
if (!component) return;
|
||||
if ('custom_id' in component) {
|
||||
validateCustomIdLength(component.custom_id as string);
|
||||
component.custom_id = `${component.custom_id}_${commandId}`;
|
||||
}
|
||||
if ('components' in component && Array.isArray(component.components)) {
|
||||
addCommandIdToComponentCustomIds(component.components, commandId);
|
||||
}
|
||||
});
|
||||
}
|
||||
56
packages/lib/src/discord/commands/command-state.ts
Normal file
56
packages/lib/src/discord/commands/command-state.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { createReactiveState } from '@/util/reactive-state.js';
|
||||
import type { PartialContext } from './command-context.type';
|
||||
import { isApplicationCommand, isAutocomplete } from './command-helpers';
|
||||
import type { ExecutableInteraction } from './command-handler';
|
||||
|
||||
export interface CommandState<T = any> {
|
||||
id: string; // unique id for this command instance
|
||||
name: string; // command name
|
||||
data: T; // internal data storage
|
||||
}
|
||||
|
||||
export async function getCommandState<T>(interaction: ExecutableInteraction, ctx: PartialContext): Promise<CommandState<T>> {
|
||||
const id = instanceIdFromInteraction(interaction);
|
||||
|
||||
let state: CommandState<T>;
|
||||
|
||||
// get state from kv store if possible
|
||||
if (ctx.kv.has(`command-state:${id}`)) {
|
||||
state = await ctx.kv.get<CommandState<T>>(`command-state:${id}`);
|
||||
}
|
||||
if (!state) {
|
||||
state = { id: id, name: '', data: {} as T };
|
||||
}
|
||||
const [reactiveState, subscribe] = createReactiveState(state);
|
||||
subscribe(async (newState) => {
|
||||
if (ctx.kv) {
|
||||
await ctx.kv.set(`command-state:${id}`, newState);
|
||||
}
|
||||
});
|
||||
ctx.state = reactiveState;
|
||||
return reactiveState;
|
||||
}
|
||||
|
||||
function instanceIdFromInteraction(interaction: ExecutableInteraction) {
|
||||
if (isAutocomplete(interaction)) {
|
||||
// autocomplete should not be stateful, they get no id
|
||||
return '';
|
||||
}
|
||||
|
||||
if (isApplicationCommand(interaction)) {
|
||||
// for application commands, we create a new instance id
|
||||
const instance_id = crypto.randomUUID();
|
||||
return instance_id;
|
||||
}
|
||||
|
||||
const interact = interaction;
|
||||
const customId: string = interact.data.custom_id;
|
||||
const commandId = customId.split('_').pop();
|
||||
interaction;
|
||||
// command id should be a uuid
|
||||
if (commandId && /^[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(commandId)) {
|
||||
return commandId;
|
||||
}
|
||||
console.error(`Invalid command id extracted from interaction: ${customId}`);
|
||||
return '';
|
||||
}
|
||||
59
packages/lib/src/discord/commands/handle-commands.test.ts
Normal file
59
packages/lib/src/discord/commands/handle-commands.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { expect, test, mock, beforeEach, afterEach } from 'bun:test';
|
||||
import { handleCommands } from './handle-commands';
|
||||
import { CommandInteraction, Constants, ModalSubmitInteraction, type ApplicationCommandStructure } from '@projectdysnomia/dysnomia';
|
||||
import type { CommandHandler } from './command-handler';
|
||||
|
||||
let commands: Record<string, CommandHandler<ApplicationCommandStructure>>;
|
||||
|
||||
beforeEach(() => {
|
||||
commands = {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
commands = {};
|
||||
});
|
||||
|
||||
mock.module('./command-helpers', () => ({
|
||||
getCommandName: () => 'testCommand',
|
||||
}));
|
||||
|
||||
test('handleCommands executes command when interaction is CommandInteraction and command exists', async () => {
|
||||
const mockExecute = mock(() => Promise.resolve());
|
||||
const mockCommand = { definition: { name: 'testCommand' } as any, execute: mockExecute };
|
||||
commands['testCommand'] = mockCommand;
|
||||
|
||||
const mockInteraction = {
|
||||
type: Constants.InteractionTypes.APPLICATION_COMMAND,
|
||||
data: { name: 'testCommand' },
|
||||
} as any;
|
||||
Object.setPrototypeOf(mockInteraction, CommandInteraction.prototype);
|
||||
|
||||
handleCommands(mockInteraction, commands, {} as any);
|
||||
|
||||
expect(mockExecute).toHaveBeenCalledWith(mockInteraction, expect.any(Object));
|
||||
});
|
||||
|
||||
test('handleCommands executes command when interaction is CommandInteraction and command exists', async () => {
|
||||
const mockExecute = mock(() => Promise.resolve());
|
||||
const mockCommand = { definition: { name: 'testCommand' } as any, execute: mockExecute };
|
||||
commands['testCommand'] = mockCommand;
|
||||
|
||||
const mockInteraction = {
|
||||
type: Constants.InteractionTypes.MODAL_SUBMIT,
|
||||
data: { name: 'testCommand' },
|
||||
} as any;
|
||||
Object.setPrototypeOf(mockInteraction, ModalSubmitInteraction.prototype);
|
||||
|
||||
handleCommands(mockInteraction, commands, {} as any);
|
||||
|
||||
expect(mockExecute).toHaveBeenCalledWith(mockInteraction, expect.any(Object));
|
||||
});
|
||||
|
||||
test('handleCommands does nothing when interaction not a CommandInteraction, ModalSubmitInteraction, MessageComponentInteraction, or AutoCompleteInteraction', () => {
|
||||
const mockInteraction = {
|
||||
instanceof: (cls: any) => false,
|
||||
} as any;
|
||||
|
||||
// Should not throw or do anything
|
||||
expect(() => handleCommands(mockInteraction, commands, {} as any)).not.toThrow();
|
||||
});
|
||||
73
packages/lib/src/discord/commands/handle-commands.ts
Normal file
73
packages/lib/src/discord/commands/handle-commands.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { type ApplicationCommandStructure } from '@projectdysnomia/dysnomia';
|
||||
import { getCommandName, isApplicationCommand, isAutocomplete, isMessageComponent, isModalSubmit } from './command-helpers';
|
||||
import type { PartialContext } from './command-context.type';
|
||||
import type { CommandHandler, ExecutableInteraction } from './command-handler';
|
||||
import { injectInteraction } from './command-injection';
|
||||
import { getCommandState } from './command-state';
|
||||
|
||||
export async function handleCommands(
|
||||
interaction: ExecutableInteraction,
|
||||
commands: Record<string, CommandHandler<ApplicationCommandStructure>>,
|
||||
ctx: PartialContext,
|
||||
) {
|
||||
ctx.state = await getCommandState(interaction, ctx);
|
||||
if (!ctx.state.name) {
|
||||
ctx.state.name = getCommandName(interaction);
|
||||
}
|
||||
|
||||
if (isAutocomplete(interaction) && ctx.state.name) {
|
||||
const acCommand = commands[ctx.state.name];
|
||||
return acCommand.execute(interaction, ctx as any);
|
||||
}
|
||||
|
||||
if (!ctx.state.id) {
|
||||
console.error(`No command ID found for interaction ${interaction.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const command = commands[ctx.state.name || ''];
|
||||
if (!command) {
|
||||
console.warn(`No command found for interaction: ${JSON.stringify(interaction, undefined, 2)}`);
|
||||
return;
|
||||
}
|
||||
cleanInteractionCustomIds(interaction, ctx.state.id);
|
||||
const [injectedInteraction, fullContext] = await injectInteraction(interaction, ctx);
|
||||
return command.execute(injectedInteraction, fullContext);
|
||||
}
|
||||
|
||||
export function initializeCommandHandling(commands: Record<string, CommandHandler<ApplicationCommandStructure>>, ctx: PartialContext) {
|
||||
ctx.client.on('interactionCreate', async (interaction) => {
|
||||
if (isApplicationCommand(interaction) || isModalSubmit(interaction) || isMessageComponent(interaction) || isAutocomplete(interaction)) {
|
||||
handleCommands(interaction, commands, ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function cleanInteractionCustomIds(interaction: ExecutableInteraction, id: string) {
|
||||
if ('components' in interaction && Array.isArray(interaction.components) && id) {
|
||||
removeCommandIdFromComponentCustomIds(interaction.components, id);
|
||||
}
|
||||
if ('data' in interaction && id) {
|
||||
if ('custom_id' in interaction.data && typeof interaction.data.custom_id === 'string') {
|
||||
interaction.data.custom_id = interaction.data.custom_id.replace(`_${id}`, '');
|
||||
}
|
||||
if ('components' in interaction.data && Array.isArray(interaction.data.components)) {
|
||||
removeCommandIdFromComponentCustomIds(interaction.data.components as any, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeCommandIdFromComponentCustomIds(components: { custom_id?: string; components?: any[] }[], commandId: string) {
|
||||
components.forEach((component) => {
|
||||
if ('custom_id' in component) {
|
||||
component.custom_id = component.custom_id.replace(`_${commandId}`, '');
|
||||
}
|
||||
if ('components' in component && Array.isArray(component.components)) {
|
||||
removeCommandIdFromComponentCustomIds(component.components, commandId);
|
||||
}
|
||||
|
||||
if ('component' in component && 'custom_id' in (component as any).component && Array.isArray(component.components)) {
|
||||
(component.component as any).custom_id = (component.component as any).custom_id.replace(`_${commandId}`, '');
|
||||
}
|
||||
});
|
||||
}
|
||||
19
packages/lib/src/discord/commands/import-commands.test.ts
Normal file
19
packages/lib/src/discord/commands/import-commands.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { expect, test, mock } from 'bun:test';
|
||||
import { importCommands } from './import-commands';
|
||||
import path from 'node:path';
|
||||
|
||||
test('importCommands imports commands from files matching pattern', async () => {
|
||||
const commands = await importCommands('**/*.command.{js,ts}', path.join(__dirname, '../../fixtures'));
|
||||
|
||||
expect(commands).toHaveProperty('test1');
|
||||
expect(commands).toHaveProperty('test2');
|
||||
expect(commands.test1.definition.name).toBe('test1');
|
||||
expect(commands.test2.definition.name).toBe('test2');
|
||||
});
|
||||
|
||||
test('importCommands uses default pattern and baseDir', async () => {
|
||||
const commands = await importCommands();
|
||||
|
||||
// Since there are no command files in src, it should be empty
|
||||
expect(commands).toEqual({});
|
||||
});
|
||||
19
packages/lib/src/discord/commands/import-commands.ts
Normal file
19
packages/lib/src/discord/commands/import-commands.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Glob } from 'bun';
|
||||
import { join } from 'node:path';
|
||||
import type { CommandHandler } from './command-handler';
|
||||
import type { ApplicationCommandStructure } from '@projectdysnomia/dysnomia';
|
||||
|
||||
export async function importCommands(
|
||||
pattern: string = '**/*.command.{js,ts}',
|
||||
baseDir: string = join(process.cwd(), 'src'),
|
||||
commandRegistry: Record<string, CommandHandler<ApplicationCommandStructure>> = {},
|
||||
): Promise<Record<string, CommandHandler<ApplicationCommandStructure>>> {
|
||||
const glob = new Glob(pattern);
|
||||
|
||||
for await (const file of glob.scan({ cwd: baseDir, absolute: true })) {
|
||||
const command = (await import(file)).default as CommandHandler<ApplicationCommandStructure>;
|
||||
commandRegistry[command.definition.name] = command;
|
||||
}
|
||||
|
||||
return commandRegistry;
|
||||
}
|
||||
8
packages/lib/src/discord/commands/index.ts
Normal file
8
packages/lib/src/discord/commands/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './command-handler';
|
||||
export * from './import-commands';
|
||||
export * from './handle-commands';
|
||||
export * from './command-helpers';
|
||||
export * from './register-commands';
|
||||
export * from './command-context.type';
|
||||
export * from './command-state';
|
||||
export * from './option-builders';
|
||||
80
packages/lib/src/discord/commands/option-builders.ts
Normal file
80
packages/lib/src/discord/commands/option-builders.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Constants,
|
||||
type ApplicationCommandOptions,
|
||||
type ApplicationCommandOptionsBoolean,
|
||||
type ApplicationCommandOptionsInteger,
|
||||
type ApplicationCommandOptionsMentionable,
|
||||
type ApplicationCommandOptionsNumber,
|
||||
type ApplicationCommandOptionsRole,
|
||||
type ApplicationCommandOptionsString,
|
||||
type ApplicationCommandOptionsSubCommand,
|
||||
type ApplicationCommandOptionsSubCommandGroup,
|
||||
type ApplicationCommandOptionsUser,
|
||||
} from '@projectdysnomia/dysnomia';
|
||||
|
||||
export type StringOptionDefinition = Omit<ApplicationCommandOptionsString, 'type'> & { autocomplete?: boolean };
|
||||
export function stringOption(options: StringOptionDefinition): ApplicationCommandOptionsString {
|
||||
const def = options as ApplicationCommandOptionsString;
|
||||
def.type = Constants.ApplicationCommandOptionTypes.STRING;
|
||||
return def;
|
||||
}
|
||||
export type IntegerOptionDefinition = Omit<ApplicationCommandOptionsInteger, 'type'> & { autocomplete?: boolean };
|
||||
export function integerOption(options: IntegerOptionDefinition): ApplicationCommandOptionsInteger {
|
||||
const def = options as ApplicationCommandOptionsInteger;
|
||||
def.type = Constants.ApplicationCommandOptionTypes.INTEGER;
|
||||
return def;
|
||||
}
|
||||
export type BooleanOptionDefinition = Omit<ApplicationCommandOptionsBoolean, 'type'>;
|
||||
export function booleanOption(options: BooleanOptionDefinition): ApplicationCommandOptionsBoolean {
|
||||
const def = options as ApplicationCommandOptionsBoolean;
|
||||
def.type = Constants.ApplicationCommandOptionTypes.BOOLEAN;
|
||||
return def;
|
||||
}
|
||||
export type UserOptionDefinition = Omit<ApplicationCommandOptionsUser, 'type'> & { autocomplete?: boolean };
|
||||
export function userOption(options: UserOptionDefinition): ApplicationCommandOptionsUser {
|
||||
const def = options as ApplicationCommandOptionsUser;
|
||||
def.type = Constants.ApplicationCommandOptionTypes.USER;
|
||||
return def;
|
||||
}
|
||||
export type ChannelOptionDefinition = Omit<ApplicationCommandOptions, 'type'> & { autocomplete?: boolean };
|
||||
export function channelOption(options: ChannelOptionDefinition): ApplicationCommandOptions {
|
||||
const def = options as ApplicationCommandOptions;
|
||||
def.type = Constants.ApplicationCommandOptionTypes.CHANNEL;
|
||||
return def;
|
||||
}
|
||||
export type RoleOptionDefinition = Omit<ApplicationCommandOptionsRole, 'type'> & { autocomplete?: boolean };
|
||||
export function roleOption(options: RoleOptionDefinition): ApplicationCommandOptionsRole {
|
||||
const def = options as ApplicationCommandOptionsRole;
|
||||
def.type = Constants.ApplicationCommandOptionTypes.ROLE;
|
||||
return def;
|
||||
}
|
||||
export type MentionableOptionDefinition = Omit<ApplicationCommandOptionsMentionable, 'type'>;
|
||||
export function mentionableOption(options: MentionableOptionDefinition): ApplicationCommandOptionsMentionable {
|
||||
const def = options as ApplicationCommandOptionsMentionable;
|
||||
def.type = Constants.ApplicationCommandOptionTypes.MENTIONABLE;
|
||||
return def;
|
||||
}
|
||||
export type NumberOptionDefinition = Omit<ApplicationCommandOptionsNumber, 'type'> & { autocomplete?: boolean };
|
||||
export function numberOption(options: NumberOptionDefinition): ApplicationCommandOptionsNumber {
|
||||
const def = options as ApplicationCommandOptionsNumber;
|
||||
def.type = Constants.ApplicationCommandOptionTypes.NUMBER;
|
||||
return def;
|
||||
}
|
||||
export type AttachmentOptionDefinition = Omit<ApplicationCommandOptions, 'type'>;
|
||||
export function attachmentOption(options: AttachmentOptionDefinition): ApplicationCommandOptions {
|
||||
const def = options as ApplicationCommandOptions;
|
||||
def.type = Constants.ApplicationCommandOptionTypes.ATTACHMENT;
|
||||
return def;
|
||||
}
|
||||
export type SubCommandOptionDefinition = Omit<ApplicationCommandOptionsSubCommand, 'type'>;
|
||||
export function subCommandOption(options: SubCommandOptionDefinition): ApplicationCommandOptionsSubCommand {
|
||||
const def = options as ApplicationCommandOptionsSubCommand;
|
||||
def.type = Constants.ApplicationCommandOptionTypes.SUB_COMMAND;
|
||||
return def;
|
||||
}
|
||||
export type SubCommandGroupOptionDefinition = Omit<ApplicationCommandOptionsSubCommandGroup, 'type'>;
|
||||
export function subCommandGroupOption(options: SubCommandGroupOptionDefinition): ApplicationCommandOptionsSubCommandGroup {
|
||||
const def = options as ApplicationCommandOptionsSubCommandGroup;
|
||||
def.type = Constants.ApplicationCommandOptionTypes.SUB_COMMAND_GROUP;
|
||||
return def;
|
||||
}
|
||||
11
packages/lib/src/discord/commands/register-commands.ts
Normal file
11
packages/lib/src/discord/commands/register-commands.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { ApplicationCommandStructure, Client } from '@projectdysnomia/dysnomia';
|
||||
|
||||
export async function registerCommands(client: Client, commands: ApplicationCommandStructure[]) {
|
||||
if (!client) throw new Error('Client not initialized');
|
||||
if (!(await client.getCommands()).length || process.env.RESET_COMMANDS === 'true' || process.env.NODE_ENV === 'development') {
|
||||
console.debug('Registering commands...');
|
||||
const response = await client.bulkEditCommands(commands);
|
||||
console.debug(`Registered ${response.length} commands.`);
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
1
packages/lib/src/discord/common/index.ts
Normal file
1
packages/lib/src/discord/common/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './text';
|
||||
2
packages/lib/src/discord/common/text.ts
Normal file
2
packages/lib/src/discord/common/text.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const WHITE_SPACE = ' '; // non-breaking space
|
||||
export const BREAKING_WHITE_SPACE = '\u200B';
|
||||
314
packages/lib/src/discord/components/builders.ts
Normal file
314
packages/lib/src/discord/components/builders.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import {
|
||||
Constants,
|
||||
type ActionRow,
|
||||
type Button,
|
||||
type ChannelSelectMenu,
|
||||
type GuildChannelTypes,
|
||||
type MentionableSelectMenu,
|
||||
type PartialEmoji,
|
||||
type RoleSelectMenu,
|
||||
type StringSelectMenu,
|
||||
type TextInput,
|
||||
type UserSelectMenu,
|
||||
type LabelComponent,
|
||||
type ContainerComponent,
|
||||
type TextDisplayComponent,
|
||||
type SectionComponent,
|
||||
type MediaGalleryComponent,
|
||||
type SeparatorComponent,
|
||||
type FileComponent,
|
||||
type InteractionButton,
|
||||
type URLButton,
|
||||
type PremiumButton,
|
||||
type ThumbnailComponent,
|
||||
} from '@projectdysnomia/dysnomia';
|
||||
|
||||
export type ActionRowItem = Button | StringSelectMenu | UserSelectMenu | RoleSelectMenu | MentionableSelectMenu | ChannelSelectMenu;
|
||||
export const createActionRow = (...components: ActionRowItem[]): ActionRow => ({
|
||||
type: Constants.ComponentTypes.ACTION_ROW,
|
||||
components,
|
||||
});
|
||||
|
||||
export enum ButtonStyle {
|
||||
PRIMARY = 1,
|
||||
SECONDARY = 2,
|
||||
SUCCESS = 3,
|
||||
DANGER = 4,
|
||||
}
|
||||
|
||||
export interface ButtonOptions {
|
||||
style?: ButtonStyle;
|
||||
emoji?: PartialEmoji;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const createButton = (label: string, custom_id: string, options?: ButtonOptions): InteractionButton => ({
|
||||
type: Constants.ComponentTypes.BUTTON,
|
||||
style: options?.style ?? Constants.ButtonStyles.PRIMARY,
|
||||
label,
|
||||
custom_id,
|
||||
...options,
|
||||
});
|
||||
|
||||
export interface URLButtonOptions {
|
||||
emoji?: PartialEmoji;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const createURLButton = (label: string, url: string, options?: URLButtonOptions): URLButton => ({
|
||||
type: Constants.ComponentTypes.BUTTON,
|
||||
style: Constants.ButtonStyles.LINK,
|
||||
label,
|
||||
url,
|
||||
...options,
|
||||
});
|
||||
|
||||
export interface PremiumButtonOptions {
|
||||
emoji?: PartialEmoji;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const createPremiumButton = (sku_id: string, options?: PremiumButtonOptions): PremiumButton => ({
|
||||
type: Constants.ComponentTypes.BUTTON,
|
||||
style: Constants.ButtonStyles.PREMIUM,
|
||||
sku_id,
|
||||
...options,
|
||||
});
|
||||
|
||||
export interface StringSelectOpts {
|
||||
placeholder?: string;
|
||||
min_values?: number;
|
||||
max_values?: number;
|
||||
disabled?: boolean;
|
||||
required?: boolean; // Note: not actually a property of StringSelectMenu, but useful for modals
|
||||
}
|
||||
|
||||
export interface StringSelectOption {
|
||||
label: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
emoji?: {
|
||||
name?: string;
|
||||
id?: string;
|
||||
animated?: boolean;
|
||||
};
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
export const createStringSelect = (
|
||||
custom_id: string,
|
||||
selectOpts: StringSelectOpts,
|
||||
...options: StringSelectOption[]
|
||||
): StringSelectMenu => ({
|
||||
type: Constants.ComponentTypes.STRING_SELECT,
|
||||
custom_id,
|
||||
options,
|
||||
placeholder: selectOpts.placeholder ?? '',
|
||||
min_values: selectOpts.min_values ?? 1,
|
||||
max_values: selectOpts.max_values ?? 1,
|
||||
disabled: selectOpts.disabled ?? false,
|
||||
required: selectOpts.required ?? false, // Note: not actually a property of StringSelectMenu, but useful for modals
|
||||
});
|
||||
|
||||
export interface TextInputOptions {
|
||||
isParagraph?: boolean;
|
||||
label?: string;
|
||||
min_length?: number;
|
||||
max_length?: number;
|
||||
required?: boolean;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const createTextInput = (custom_id: string, options?: TextInputOptions): TextInput => ({
|
||||
type: Constants.ComponentTypes.TEXT_INPUT,
|
||||
custom_id,
|
||||
style: options.isParagraph ? Constants.TextInputStyles.PARAGRAPH : Constants.TextInputStyles.SHORT,
|
||||
label: options?.label ?? '',
|
||||
min_length: options?.min_length ?? 0,
|
||||
max_length: options?.max_length ?? 4000,
|
||||
required: options?.required ?? false,
|
||||
value: options?.value ?? '',
|
||||
placeholder: options?.placeholder ?? '',
|
||||
});
|
||||
|
||||
export interface UserSelectOptions {
|
||||
placeholder?: string;
|
||||
min_values?: number;
|
||||
max_values?: number;
|
||||
disabled?: boolean;
|
||||
default_values?: Array<{ id: string; type: 'user' }>;
|
||||
}
|
||||
|
||||
export const createUserSelect = (custom_id: string, options?: UserSelectOptions): UserSelectMenu => ({
|
||||
type: Constants.ComponentTypes.USER_SELECT,
|
||||
custom_id,
|
||||
placeholder: options?.placeholder ?? '',
|
||||
min_values: options?.min_values ?? 1,
|
||||
max_values: options?.max_values ?? 1,
|
||||
disabled: options?.disabled ?? false,
|
||||
default_values: options?.default_values ?? [],
|
||||
});
|
||||
|
||||
export interface RoleSelectOptions {
|
||||
placeholder?: string;
|
||||
min_values?: number;
|
||||
max_values?: number;
|
||||
disabled?: boolean;
|
||||
default_values?: Array<{ id: string; type: 'role' }>;
|
||||
}
|
||||
|
||||
export const createRoleSelect = (custom_id: string, options?: RoleSelectOptions): RoleSelectMenu => ({
|
||||
type: Constants.ComponentTypes.ROLE_SELECT,
|
||||
custom_id,
|
||||
placeholder: options?.placeholder ?? '',
|
||||
min_values: options?.min_values ?? 1,
|
||||
max_values: options?.max_values ?? 1,
|
||||
disabled: options?.disabled ?? false,
|
||||
default_values: options?.default_values ?? [],
|
||||
});
|
||||
|
||||
export interface MentionableSelectOptions {
|
||||
placeholder?: string;
|
||||
min_values?: number;
|
||||
max_values?: number;
|
||||
disabled?: boolean;
|
||||
default_values?: Array<{ id: string; type: 'user' | 'role' }>;
|
||||
}
|
||||
|
||||
export const createMentionableSelect = (custom_id: string, options?: MentionableSelectOptions): MentionableSelectMenu => ({
|
||||
type: Constants.ComponentTypes.MENTIONABLE_SELECT,
|
||||
custom_id,
|
||||
placeholder: options?.placeholder ?? '',
|
||||
min_values: options?.min_values ?? 1,
|
||||
max_values: options?.max_values ?? 1,
|
||||
disabled: options?.disabled ?? false,
|
||||
default_values: options?.default_values ?? [],
|
||||
});
|
||||
|
||||
export interface ChannelSelectOptions {
|
||||
channel_types?: GuildChannelTypes[];
|
||||
placeholder?: string;
|
||||
min_values?: number;
|
||||
max_values?: number;
|
||||
disabled?: boolean;
|
||||
default_values?: Array<{ id: string; type: 'channel' }>;
|
||||
}
|
||||
|
||||
export const createChannelSelect = (custom_id: string, options?: ChannelSelectOptions): ChannelSelectMenu => ({
|
||||
type: Constants.ComponentTypes.CHANNEL_SELECT,
|
||||
custom_id,
|
||||
channel_types: options?.channel_types ?? [],
|
||||
placeholder: options?.placeholder ?? '',
|
||||
min_values: options?.min_values ?? 1,
|
||||
max_values: options?.max_values ?? 1,
|
||||
disabled: options?.disabled ?? false,
|
||||
default_values: options?.default_values ?? [],
|
||||
});
|
||||
|
||||
export interface SectionOptions {
|
||||
components: Array<TextDisplayComponent>;
|
||||
accessory: Button | ThumbnailComponent;
|
||||
}
|
||||
|
||||
export const createSection = (accessory: Button | ThumbnailComponent, ...components: Array<TextDisplayComponent>): SectionComponent => ({
|
||||
type: Constants.ComponentTypes.SECTION,
|
||||
accessory,
|
||||
components,
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a text display component where the text will be displayed similar to a message: supports markdown
|
||||
* @param content The text content to display.
|
||||
* @returns The created text display component.
|
||||
*/
|
||||
export const createTextDisplay = (content: string) => ({
|
||||
type: Constants.ComponentTypes.TEXT_DISPLAY,
|
||||
content,
|
||||
});
|
||||
|
||||
export interface ThumbnailOptions {
|
||||
media: {
|
||||
url: string; // Supports arbitrary urls and attachment://<filename> references
|
||||
};
|
||||
description?: string;
|
||||
spoiler?: boolean;
|
||||
}
|
||||
|
||||
export const createThumbnail = (url: string, description?: string, spoiler?: boolean): ThumbnailComponent => ({
|
||||
type: Constants.ComponentTypes.THUMBNAIL,
|
||||
media: {
|
||||
url,
|
||||
},
|
||||
description,
|
||||
spoiler,
|
||||
});
|
||||
|
||||
export interface MediaItem {
|
||||
url: string; // Supports arbitrary urls and attachment://<filename> references
|
||||
description?: string;
|
||||
spoiler?: boolean;
|
||||
}
|
||||
|
||||
export const createMediaGallery = (...items: MediaItem[]): MediaGalleryComponent => ({
|
||||
type: Constants.ComponentTypes.MEDIA_GALLERY,
|
||||
items: items.map((item) => ({
|
||||
type: Constants.ComponentTypes.FILE,
|
||||
media: { url: item.url },
|
||||
description: item.description,
|
||||
spoiler: item.spoiler,
|
||||
})),
|
||||
});
|
||||
|
||||
export interface FileOptions {
|
||||
url: string; // Supports only attachment://<filename> references
|
||||
spoiler?: boolean;
|
||||
}
|
||||
|
||||
export const createFile = (url: string, spoiler?: boolean): FileComponent => ({
|
||||
type: Constants.ComponentTypes.FILE,
|
||||
file: {
|
||||
url,
|
||||
},
|
||||
spoiler,
|
||||
});
|
||||
|
||||
export enum Padding {
|
||||
SMALL = 1,
|
||||
LARGE = 2,
|
||||
}
|
||||
|
||||
export interface SeparatorOptions {
|
||||
divider?: boolean;
|
||||
spacing?: Padding;
|
||||
}
|
||||
export const createSeparator = (spacing?: Padding, divider?: boolean): SeparatorComponent => ({
|
||||
type: Constants.ComponentTypes.SEPARATOR,
|
||||
divider,
|
||||
spacing: spacing ?? Padding.SMALL,
|
||||
});
|
||||
|
||||
export interface ContainerOptions {
|
||||
accent_color?: number;
|
||||
spoiler?: boolean;
|
||||
}
|
||||
|
||||
export type ContainerItems =
|
||||
| ActionRow
|
||||
| TextDisplayComponent
|
||||
| SectionComponent
|
||||
| MediaGalleryComponent
|
||||
| SeparatorComponent
|
||||
| FileComponent;
|
||||
|
||||
export const createContainer = (options: ContainerOptions, ...components: ContainerItems[]): ContainerComponent => ({
|
||||
type: Constants.ComponentTypes.CONTAINER,
|
||||
...options,
|
||||
components,
|
||||
});
|
||||
|
||||
export const createModalLabel = (label: string, component: TextInput | StringSelectMenu): LabelComponent => ({
|
||||
type: Constants.ComponentTypes.LABEL,
|
||||
label,
|
||||
component,
|
||||
});
|
||||
23
packages/lib/src/discord/components/helpers.ts
Normal file
23
packages/lib/src/discord/components/helpers.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
Constants,
|
||||
type ComponentBase,
|
||||
type ModalSubmitInteractionDataLabelComponent,
|
||||
type ModalSubmitInteractionDataStringSelectComponent,
|
||||
type ModalSubmitInteractionDataTextInputComponent,
|
||||
} from '@projectdysnomia/dysnomia';
|
||||
|
||||
export function isModalLabel(component: ComponentBase): component is ModalSubmitInteractionDataLabelComponent {
|
||||
return component.type === Constants.ComponentTypes.LABEL;
|
||||
}
|
||||
|
||||
export function isModalTextInput(component: ComponentBase): component is ModalSubmitInteractionDataTextInputComponent {
|
||||
return component.type === Constants.ComponentTypes.TEXT_INPUT;
|
||||
}
|
||||
|
||||
export function isModalSelect(component: ComponentBase): component is ModalSubmitInteractionDataStringSelectComponent {
|
||||
return component.type === Constants.ComponentTypes.STRING_SELECT;
|
||||
}
|
||||
|
||||
export function componentHasIdPrefix(component: ComponentBase, prefix: string): boolean {
|
||||
return (isModalTextInput(component) || isModalSelect(component)) && component.custom_id.startsWith(prefix);
|
||||
}
|
||||
2
packages/lib/src/discord/components/index.ts
Normal file
2
packages/lib/src/discord/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './helpers';
|
||||
export * from './builders';
|
||||
54
packages/lib/src/discord/core/bot.ts
Normal file
54
packages/lib/src/discord/core/bot.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { importCommands, initializeCommandHandling, registerCommands } from '../commands';
|
||||
import { Client } from '@projectdysnomia/dysnomia';
|
||||
import kv, { asyncKV } from '@/util/kv.js';
|
||||
import type { KVStore } from './kv-store.type.ts.ts';
|
||||
import type { Cache } from './cache.type.ts';
|
||||
|
||||
export interface DiscordBotOptions {
|
||||
token?: string;
|
||||
intents?: number[];
|
||||
commandPattern?: string;
|
||||
commandBaseDir?: string;
|
||||
keyStore?: KVStore;
|
||||
cache?: Cache;
|
||||
onError?: (error: Error) => void;
|
||||
onReady?: () => void;
|
||||
}
|
||||
|
||||
export function startDiscordBot({
|
||||
token = process.env.DISCORD_BOT_TOKEN || '',
|
||||
intents = [],
|
||||
commandPattern = '**/*.command.{js,ts}',
|
||||
commandBaseDir = 'src',
|
||||
keyStore = asyncKV,
|
||||
cache = kv,
|
||||
onError,
|
||||
onReady,
|
||||
}: DiscordBotOptions = {}): Client {
|
||||
const client = new Client(`Bot ${token}`, {
|
||||
gateway: {
|
||||
intents,
|
||||
},
|
||||
});
|
||||
|
||||
client.on('ready', async () => {
|
||||
console.debug(`Logged in as ${client.user?.username}#${client.user?.discriminator}`);
|
||||
onReady?.();
|
||||
const commands = await importCommands(commandPattern, commandBaseDir);
|
||||
await registerCommands(
|
||||
client,
|
||||
Object.values(commands).map((cmd) => cmd.definition),
|
||||
);
|
||||
initializeCommandHandling(commands, { client, cache, kv: keyStore });
|
||||
console.debug('Bot is ready and command handling is initialized.');
|
||||
});
|
||||
|
||||
client.on('error', (error) => {
|
||||
console.error('An error occurred:', error);
|
||||
onError?.(error);
|
||||
});
|
||||
|
||||
client.connect().catch(console.error);
|
||||
|
||||
return client;
|
||||
}
|
||||
6
packages/lib/src/discord/core/cache.type.ts
Normal file
6
packages/lib/src/discord/core/cache.type.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface Cache {
|
||||
get: <T>(key: string) => T | undefined;
|
||||
set: <T>(key: string, value: T, ttl?: number | string) => boolean;
|
||||
del: (key: string | string[]) => number;
|
||||
has: (key: string) => boolean;
|
||||
}
|
||||
3
packages/lib/src/discord/core/index.ts
Normal file
3
packages/lib/src/discord/core/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './bot.ts';
|
||||
export * from './cache.type.ts';
|
||||
export * from './kv-store.type.ts.ts';
|
||||
7
packages/lib/src/discord/core/kv-store.type.ts.ts
Normal file
7
packages/lib/src/discord/core/kv-store.type.ts.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface KVStore {
|
||||
get: <T>(key: string) => Promise<T | undefined>;
|
||||
set: (key: string, value: any) => Promise<boolean>;
|
||||
delete: (key: string) => Promise<number>;
|
||||
has: (key: string) => Promise<boolean>;
|
||||
clear: () => Promise<void>;
|
||||
}
|
||||
6
packages/lib/src/discord/index.ts
Normal file
6
packages/lib/src/discord/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './locales';
|
||||
export * from './commands';
|
||||
export * from './core';
|
||||
export * from './jsx';
|
||||
export * from './components';
|
||||
export * from './pages';
|
||||
7
packages/lib/src/discord/jsd/createElement.ts
Normal file
7
packages/lib/src/discord/jsd/createElement.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function createElement(tag: string, attrs: Record<string, any> = {}, ...children: any[]) {
|
||||
return {
|
||||
tag,
|
||||
attrs,
|
||||
children,
|
||||
};
|
||||
}
|
||||
2
packages/lib/src/discord/jsd/index.ts
Normal file
2
packages/lib/src/discord/jsd/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './parser';
|
||||
export * from './createElement';
|
||||
10
packages/lib/src/discord/jsd/parser.test.ts
Normal file
10
packages/lib/src/discord/jsd/parser.test.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { parseJSDFile } from './parser_new';
|
||||
import path from 'node:path';
|
||||
|
||||
describe('parseJSDFile', () => {
|
||||
it('should parse a JSD file', async () => {
|
||||
const result = await parseJSDFile(path.join(__dirname, '../../fixtures/jsd/test.tsd'));
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
});
|
||||
97
packages/lib/src/discord/jsd/parser.ts
Normal file
97
packages/lib/src/discord/jsd/parser.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import parse, { type DOMNode } from 'html-dom-parser';
|
||||
import type { ChildNode } from 'domhandler';
|
||||
|
||||
const JSD_STRING = /\(\s*(<.*)>\s*\)/gs;
|
||||
|
||||
export async function parseJSDFile(filename: string) {
|
||||
const content = (await fs.readFile(filename)).toString();
|
||||
|
||||
const matches = JSD_STRING.exec(content);
|
||||
if (matches) {
|
||||
let html = matches[1] + '>';
|
||||
const root = parse(html);
|
||||
const translated = translate(root[0]);
|
||||
const str = content.replace(matches[1] + '>', translated);
|
||||
await fs.writeFile(filename.replace('.tsd', '.ts'), str);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface state {
|
||||
inInterpolation?: boolean;
|
||||
children?: string[][];
|
||||
parent?: Text[];
|
||||
}
|
||||
|
||||
function translate(root: DOMNode | ChildNode | null, state: state = {}): string | null {
|
||||
if (!root || typeof root !== 'object') return null;
|
||||
|
||||
let children = [];
|
||||
if ('children' in root && Array.isArray(root.children) && root.children.length > 0) {
|
||||
for (const child of root.children) {
|
||||
const translated = translate(child, state);
|
||||
if (translated) {
|
||||
if (state.inInterpolation && state.parent[state.children.length - 1] === child) {
|
||||
state.children[state.children.length - 1].push(translated);
|
||||
} else {
|
||||
children.push(translated);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ('nodeType' in root && root.nodeType === 3) {
|
||||
if (root.data.trim() === '') return null;
|
||||
return parseText(root.data.trim(), state, root);
|
||||
}
|
||||
|
||||
if ('name' in root && root.name) {
|
||||
let tagName = root.name || 'unknown';
|
||||
let attrs = 'attribs' in root ? root.attribs : {};
|
||||
return `StarKitten.createElement("${tagName}", ${JSON.stringify(attrs)}${children.length > 0 ? ', ' + children.join(', ') : ''})`;
|
||||
}
|
||||
}
|
||||
|
||||
const JSD_INTERPOLATION = /\{(.+)\}/gs;
|
||||
const JSD_START_EXP_INTERPOLATION = /\{(.+)\(/gs;
|
||||
const JSD_END_EXP_INTERPOLATION = /\)(.+)\}/gs;
|
||||
|
||||
function parseText(text: string, state: state = {}, parent: Text = {}): string {
|
||||
let interpolations = text.match(JSD_INTERPOLATION);
|
||||
if (!interpolations) {
|
||||
if (text.match(JSD_START_EXP_INTERPOLATION)) {
|
||||
state.inInterpolation = true;
|
||||
state.children = state.children || [[]];
|
||||
state.parent = state.parent || [];
|
||||
state.parent.push(parent);
|
||||
return text.substring(1, text.length - 1);
|
||||
} else if (text.match(JSD_END_EXP_INTERPOLATION)) {
|
||||
const combined = state.children?.[state.children.length - 1].join(' ');
|
||||
state.children?.[state.children.length - 1].splice(0);
|
||||
state.children?.pop();
|
||||
state.parent?.pop();
|
||||
if (state.children.length === 0) {
|
||||
state.inInterpolation = false;
|
||||
return combined + ' ' + text.substring(1, text.length - 1);
|
||||
}
|
||||
}
|
||||
return `"${text}"`;
|
||||
} else {
|
||||
text = replaceInterpolations(text);
|
||||
return `"${text}"`;
|
||||
}
|
||||
}
|
||||
|
||||
function replaceInterpolations(text: string, isOnJSON: boolean = false) {
|
||||
let interpolations = null;
|
||||
|
||||
while ((interpolations = JSD_INTERPOLATION.exec(text))) {
|
||||
if (isOnJSON) {
|
||||
text = text.replace(`"{${interpolations[1]}}"`, interpolations[1]);
|
||||
} else {
|
||||
text = text.replace(`{${interpolations[1]}}`, `"+ ${interpolations[1]} +"`);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
101
packages/lib/src/discord/jsd/parser_new.ts
Normal file
101
packages/lib/src/discord/jsd/parser_new.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import * as acorn from 'acorn';
|
||||
import jsx from 'acorn-jsx';
|
||||
|
||||
const JSD_STRING = /\(\s*(<.*)>\s*\)/gs;
|
||||
|
||||
const parser = acorn.Parser.extend(jsx());
|
||||
|
||||
export async function parseJSDFile(filename: string) {
|
||||
const content = (await fs.readFile(filename)).toString();
|
||||
|
||||
const matches = JSD_STRING.exec(content);
|
||||
if (matches) {
|
||||
const jsxc = matches[1] + '>';
|
||||
const ast = parser.parse(jsxc, { ecmaVersion: 2020, sourceType: 'module' });
|
||||
const translated = traverseJSX((ast.body[0] as any).expression);
|
||||
const str = content.replace(matches[1] + '>', translated);
|
||||
await fs.writeFile(filename.replace('.tsd', '.ts'), str);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function traverseJSX(node: any): string {
|
||||
if (node.type === 'JSXElement') {
|
||||
const tag = node.openingElement.name.name;
|
||||
const attrs: Record<string, any> = {};
|
||||
for (const attr of node.openingElement.attributes) {
|
||||
if (attr.type === 'JSXAttribute') {
|
||||
const name = attr.name.name;
|
||||
const value = attr.value;
|
||||
if (value.type === 'Literal') {
|
||||
attrs[name] = value.value;
|
||||
} else if (value.type === 'JSXExpressionContainer') {
|
||||
attrs[name] = `{${generateCode(value.expression)}}`;
|
||||
} else if (value) {
|
||||
attrs[name] = value.raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
const children = [];
|
||||
for (const child of node.children) {
|
||||
const translated = traverseJSX(child);
|
||||
if (translated) {
|
||||
children.push(translated);
|
||||
}
|
||||
}
|
||||
return `StarKitten.createElement("${tag}", ${JSON.stringify(attrs)}${children.length > 0 ? ', ' + children.join(', ') : ''})`;
|
||||
} else if (node.type === 'JSXExpressionContainer') {
|
||||
const expr = generateCode(node.expression);
|
||||
if (node.expression.type === 'TemplateLiteral' || (node.expression.type === 'Literal' && typeof node.expression.value === 'string')) {
|
||||
return `""+ ${expr} +""`;
|
||||
} else {
|
||||
return expr;
|
||||
}
|
||||
} else if (node.type === 'JSXText') {
|
||||
const text = node.value.trim();
|
||||
if (text) {
|
||||
return `"${text}"`;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function generateCode(node: any): string {
|
||||
if (node.type === 'JSXElement') {
|
||||
return traverseJSX(node);
|
||||
} else if (node.type === 'Identifier') {
|
||||
return node.name;
|
||||
} else if (node.type === 'Literal') {
|
||||
return JSON.stringify(node.value);
|
||||
} else if (node.type === 'TemplateLiteral') {
|
||||
const quasis = node.quasis.map((q: any) => q.value.raw);
|
||||
const expressions = node.expressions.map((e: any) => generateCode(e));
|
||||
let result = quasis[0];
|
||||
for (let i = 0; i < expressions.length; i++) {
|
||||
result += '${' + expressions[i] + '}' + quasis[i + 1];
|
||||
}
|
||||
return '`' + result + '`';
|
||||
} else if (node.type === 'MemberExpression') {
|
||||
const op = node.optional ? '?.' : '.';
|
||||
return generateCode(node.object) + op + (node.computed ? '[' + generateCode(node.property) + ']' : generateCode(node.property));
|
||||
} else if (node.type === 'OptionalMemberExpression') {
|
||||
return generateCode(node.object) + '?.' + (node.computed ? '[' + generateCode(node.property) + ']' : generateCode(node.property));
|
||||
} else if (node.type === 'CallExpression') {
|
||||
return generateCode(node.callee) + '(' + node.arguments.map((a: any) => generateCode(a)).join(', ') + ')';
|
||||
} else if (node.type === 'BinaryExpression') {
|
||||
return generateCode(node.left) + ' ' + node.operator + ' ' + generateCode(node.right);
|
||||
} else if (node.type === 'ConditionalExpression') {
|
||||
return generateCode(node.test) + ' ? ' + generateCode(node.consequent) + ' : ' + generateCode(node.alternate);
|
||||
} else if (node.type === 'LogicalExpression') {
|
||||
return generateCode(node.left) + ' ' + node.operator + ' ' + generateCode(node.right);
|
||||
} else if (node.type === 'UnaryExpression') {
|
||||
return node.operator + generateCode(node.argument);
|
||||
} else if (node.type === 'ObjectExpression') {
|
||||
return '{' + node.properties.map((p: any) => generateCode(p.key) + ': ' + generateCode(p.value)).join(', ') + '}';
|
||||
} else if (node.type === 'ArrayExpression') {
|
||||
return '[' + node.elements.map((e: any) => generateCode(e)).join(', ') + ']';
|
||||
} else {
|
||||
return node.raw || node.name || 'unknown';
|
||||
}
|
||||
}
|
||||
5
packages/lib/src/discord/jsx/components/action-row.ts
Normal file
5
packages/lib/src/discord/jsx/components/action-row.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createActionRow } from '@/discord/components';
|
||||
|
||||
export function ActionRow(props: { children: any | any[] }) {
|
||||
return createActionRow(...(Array.isArray(props.children) ? props.children : [props.children]));
|
||||
}
|
||||
6
packages/lib/src/discord/jsx/components/button.ts
Normal file
6
packages/lib/src/discord/jsx/components/button.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createButton, type ButtonStyle } from '@/discord/components';
|
||||
import type { PartialEmoji } from '@projectdysnomia/dysnomia';
|
||||
|
||||
export function Button(props: { label: string; customId: string; style: ButtonStyle; emoji?: PartialEmoji; disabled?: boolean }) {
|
||||
return createButton(props.label, props.customId, { style: props.style, emoji: props.emoji, disabled: props.disabled });
|
||||
}
|
||||
8
packages/lib/src/discord/jsx/components/container.ts
Normal file
8
packages/lib/src/discord/jsx/components/container.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createContainer } from '@/discord/components';
|
||||
|
||||
export function Container(props: { accent?: number; spoiler?: boolean; children: any | any[] }) {
|
||||
return createContainer(
|
||||
{ accent_color: props.accent, spoiler: props.spoiler },
|
||||
...(Array.isArray(props.children) ? props.children : [props.children]),
|
||||
);
|
||||
}
|
||||
4
packages/lib/src/discord/jsx/components/index.ts
Normal file
4
packages/lib/src/discord/jsx/components/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './action-row';
|
||||
export * from './button';
|
||||
export * from './container';
|
||||
export * from './text-display';
|
||||
5
packages/lib/src/discord/jsx/components/text-display.ts
Normal file
5
packages/lib/src/discord/jsx/components/text-display.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createTextDisplay } from '@/discord/components/builders';
|
||||
|
||||
export function TextDisplay(props: { content: string }) {
|
||||
return createTextDisplay(props.content);
|
||||
}
|
||||
3
packages/lib/src/discord/jsx/index.ts
Normal file
3
packages/lib/src/discord/jsx/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './components';
|
||||
export * from './jsx';
|
||||
export * from './runtime';
|
||||
1
packages/lib/src/discord/jsx/jsx-dev-runtime.ts
Normal file
1
packages/lib/src/discord/jsx/jsx-dev-runtime.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { jsxDEV } from './runtime';
|
||||
1
packages/lib/src/discord/jsx/jsx-runtime.ts
Normal file
1
packages/lib/src/discord/jsx/jsx-runtime.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { jsx } from './runtime';
|
||||
69
packages/lib/src/discord/jsx/jsx.ts
Normal file
69
packages/lib/src/discord/jsx/jsx.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
type ActionRow,
|
||||
type Button,
|
||||
type ChannelSelectMenu,
|
||||
type MentionableSelectMenu,
|
||||
type PartialEmoji,
|
||||
type RoleSelectMenu,
|
||||
type StringSelectMenu,
|
||||
type TextInput,
|
||||
type UserSelectMenu,
|
||||
type LabelComponent,
|
||||
type ContainerComponent,
|
||||
type TextDisplayComponent,
|
||||
type SectionComponent,
|
||||
type MediaGalleryComponent,
|
||||
type SeparatorComponent,
|
||||
type FileComponent,
|
||||
type InteractionButton,
|
||||
type URLButton,
|
||||
type PremiumButton,
|
||||
type ThumbnailComponent,
|
||||
} from '@projectdysnomia/dysnomia';
|
||||
|
||||
export type Component =
|
||||
| ActionRow
|
||||
| Button
|
||||
| StringSelectMenu
|
||||
| UserSelectMenu
|
||||
| RoleSelectMenu
|
||||
| MentionableSelectMenu
|
||||
| ChannelSelectMenu
|
||||
| TextInput
|
||||
| LabelComponent
|
||||
| ContainerComponent
|
||||
| TextDisplayComponent
|
||||
| SectionComponent
|
||||
| MediaGalleryComponent
|
||||
| SeparatorComponent
|
||||
| FileComponent
|
||||
| InteractionButton
|
||||
| URLButton
|
||||
| PremiumButton
|
||||
| ThumbnailComponent;
|
||||
|
||||
export type Element = Component | Promise<Component>;
|
||||
|
||||
export interface ElementClass {
|
||||
render: any;
|
||||
}
|
||||
|
||||
export interface ElementAttributesProperty {
|
||||
props: {};
|
||||
}
|
||||
|
||||
export interface IntrinsicElements {
|
||||
// Allow any element, but prefer known elements
|
||||
// [elemName: string]: any;
|
||||
// Known elements (forcing re-parse)
|
||||
actionRow: { children: any | any[] };
|
||||
button: {
|
||||
label: string;
|
||||
customId: string;
|
||||
style: number;
|
||||
emoji?: PartialEmoji;
|
||||
disabled?: boolean;
|
||||
};
|
||||
container: { color?: string; accent?: number; spoiler?: boolean; children: any | any[] };
|
||||
textDisplay: { content: string };
|
||||
}
|
||||
52
packages/lib/src/discord/jsx/runtime.ts
Normal file
52
packages/lib/src/discord/jsx/runtime.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ActionRow } from './components/action-row';
|
||||
import { Button } from './components/button';
|
||||
import { Container } from './components/container';
|
||||
import { TextDisplay } from './components/text-display';
|
||||
|
||||
const intrinsicComponentMap: Record<string, (props: any) => any> = {
|
||||
actionRow: ActionRow,
|
||||
button: Button,
|
||||
container: Container,
|
||||
textDisplay: TextDisplay,
|
||||
};
|
||||
|
||||
export function jsx(type: any, props: Record<string, any>) {
|
||||
console.log('JSX', type, props);
|
||||
if (typeof type === 'function') {
|
||||
return type(props);
|
||||
}
|
||||
|
||||
if (typeof type === 'string' && intrinsicComponentMap[type]) {
|
||||
return intrinsicComponentMap[type](props);
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
props,
|
||||
};
|
||||
}
|
||||
|
||||
export function jsxDEV(
|
||||
type: any,
|
||||
props: Record<string, any>,
|
||||
key: string | number | symbol,
|
||||
isStaticChildren: boolean,
|
||||
source: any,
|
||||
self: any,
|
||||
) {
|
||||
console.log('JSX DEV', type, props);
|
||||
if (typeof type === 'function') {
|
||||
return type(props);
|
||||
}
|
||||
|
||||
if (typeof type === 'string' && intrinsicComponentMap[type]) {
|
||||
return intrinsicComponentMap[type](props);
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
props: { ...props, key },
|
||||
_source: source,
|
||||
_self: self,
|
||||
};
|
||||
}
|
||||
8
packages/lib/src/discord/jsx/types.d.ts
vendored
Normal file
8
packages/lib/src/discord/jsx/types.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Component, IntrinsicElements as StarKittenIntrinsicElements } from './jsx';
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
type Element = Component;
|
||||
interface IntrinsicElements extends StarKittenIntrinsicElements {}
|
||||
}
|
||||
}
|
||||
26
packages/lib/src/discord/locales.ts
Normal file
26
packages/lib/src/discord/locales.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type Locales = 'en' | 'ru' | 'de' | 'fr' | 'ja' | 'es' | 'zh' | 'ko';
|
||||
export const ALL_LOCALES: Locales[] = ['en', 'ru', 'de', 'fr', 'ja', 'es', 'zh', 'ko'];
|
||||
export const DEFAULT_LOCALE: Locales = 'en';
|
||||
export const LOCALE_NAMES: { [key in Locales]: string } = {
|
||||
en: 'English',
|
||||
ru: 'Русский',
|
||||
de: 'Deutsch',
|
||||
fr: 'Français',
|
||||
ja: '日本語',
|
||||
es: 'Español',
|
||||
zh: '中文',
|
||||
ko: '한국어',
|
||||
};
|
||||
export function toDiscordLocale(locale: Locales): string {
|
||||
switch (locale) {
|
||||
case 'en': return 'en-US';
|
||||
case 'ru': return 'ru';
|
||||
case 'de': return 'de';
|
||||
case 'fr': return 'fr';
|
||||
case 'ja': return 'ja';
|
||||
case 'es': return 'es-ES';
|
||||
case 'zh': return 'zh-CN';
|
||||
case 'ko': return 'ko';
|
||||
default: return 'en-US';
|
||||
}
|
||||
}
|
||||
2
packages/lib/src/discord/pages/index.ts
Normal file
2
packages/lib/src/discord/pages/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './pages';
|
||||
export * from './subroutes';
|
||||
167
packages/lib/src/discord/pages/pages.ts
Normal file
167
packages/lib/src/discord/pages/pages.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { isAutocomplete, isMessageComponent, isModalSubmit, isPing, type CommandContext } from '@/discord/commands';
|
||||
import {
|
||||
Constants,
|
||||
type InteractionContentEdit,
|
||||
type InteractionModalContent,
|
||||
type CommandInteraction,
|
||||
type ComponentInteraction,
|
||||
type ModalSubmitInteraction,
|
||||
Interaction,
|
||||
} from '@projectdysnomia/dysnomia';
|
||||
|
||||
export type PagesInteraction = CommandInteraction | ModalSubmitInteraction | ComponentInteraction;
|
||||
export enum PageType {
|
||||
MODAL = 'modal',
|
||||
MESSAGE = 'message',
|
||||
FOLLOWUP = 'followup',
|
||||
}
|
||||
|
||||
export interface Page<T> {
|
||||
key: string;
|
||||
type?: PageType; // defaults to MESSAGE
|
||||
followUpFlags?: number;
|
||||
render: (
|
||||
ctx: PageContext<T>,
|
||||
) => (InteractionModalContent | InteractionContentEdit) | Promise<InteractionModalContent | InteractionContentEdit>;
|
||||
}
|
||||
|
||||
export interface PagesOptions<T> {
|
||||
pages: Record<string, Page<T>>;
|
||||
initialPage?: string;
|
||||
timeout?: number; // in seconds
|
||||
ephemeral?: boolean; // whether the initial message should be ephemeral
|
||||
useEmbeds?: boolean; // will not enable components v2
|
||||
initialStateData?: T; // initial state to merge with default state
|
||||
router?: (ctx: PageContext<T>) => string; // function to determine the next page key
|
||||
}
|
||||
|
||||
export interface PageState<T> {
|
||||
currentPage: string;
|
||||
timeoutAt: number; // timestamp in ms
|
||||
lastInteractionAt?: number; // timestamp in ms
|
||||
messageId?: string;
|
||||
channelId?: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface PageContext<T> {
|
||||
state: PageState<T>;
|
||||
custom_id: string; // current interaction custom_id
|
||||
interaction: PagesInteraction;
|
||||
goToPage: (pageKey: string) => Promise<InteractionContentEdit>;
|
||||
}
|
||||
|
||||
function createPageContext<T>(interaction: PagesInteraction, options: PagesOptions<T>, state: PageState<T>): PageContext<T> {
|
||||
return {
|
||||
state,
|
||||
interaction,
|
||||
custom_id: 'custom_id' in interaction.data ? interaction.data.custom_id : options.initialPage ?? 'root',
|
||||
goToPage: (pageKey: string) => {
|
||||
const page = options.pages[pageKey];
|
||||
this.state.currentPage = pageKey;
|
||||
if (!page) {
|
||||
throw new Error(`Page with key "${pageKey}" not found`);
|
||||
}
|
||||
return page.render(createPageContext(interaction, options, { ...state, currentPage: pageKey })) as Promise<InteractionContentEdit>;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function defaultPageState<T>(options: PagesOptions<T>): PageState<T> {
|
||||
const timeoutAt = options.timeout ? Date.now() + options.timeout * 1000 : Infinity;
|
||||
return {
|
||||
currentPage: options.initialPage ?? options.pages[0].key,
|
||||
timeoutAt,
|
||||
lastInteractionAt: Date.now(),
|
||||
data: options.initialStateData ?? ({} as T),
|
||||
};
|
||||
}
|
||||
|
||||
function getPageState<T>(options: PagesOptions<T>, cmdCtx: CommandContext & { state: { __pageState?: PageState<T> } }) {
|
||||
const cmdState = cmdCtx.state;
|
||||
if ('__pageState' in cmdState && cmdState.__pageState) {
|
||||
return cmdState.__pageState as PageState<T>;
|
||||
}
|
||||
cmdState.__pageState = defaultPageState(options);
|
||||
return cmdState.__pageState as PageState<T>;
|
||||
}
|
||||
|
||||
function validateOptions<T>(options: PagesOptions<T>) {
|
||||
const keys = Object.keys(options.pages);
|
||||
const uniqueKeys = new Set(keys);
|
||||
if (uniqueKeys.size !== keys.length) {
|
||||
throw new Error('Duplicate page keys found');
|
||||
}
|
||||
}
|
||||
|
||||
function getFlags(options: PagesOptions<any>) {
|
||||
let flags = 0;
|
||||
if (options.ephemeral) {
|
||||
flags |= Constants.MessageFlags.EPHEMERAL;
|
||||
}
|
||||
if (!options.useEmbeds) {
|
||||
flags |= Constants.MessageFlags.IS_COMPONENTS_V2;
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
export async function usePages<T>(options: PagesOptions<T>, interaction: Interaction, cmdCtx: CommandContext) {
|
||||
if (isAutocomplete(interaction) || isPing(interaction)) {
|
||||
throw new Error('usePages cannot be used with autocomplete or ping interactions');
|
||||
}
|
||||
const pagesInteraction = interaction as PagesInteraction;
|
||||
validateOptions(options);
|
||||
const pageState = getPageState(options, cmdCtx);
|
||||
const pageContext = createPageContext(pagesInteraction, options, pageState);
|
||||
const pageKey = options.router
|
||||
? options.router(pageContext)
|
||||
: pageContext.custom_id ?? options.initialPage ?? Object.keys(options.pages)[0];
|
||||
// if we have subroutes, we only want the main route from the page key
|
||||
const page = options.pages[pageKey.split(':')[0]] ?? options.pages[0];
|
||||
pageContext.state.currentPage = page.key;
|
||||
|
||||
if (page.type === PageType.MODAL && !isModalSubmit(pagesInteraction)) {
|
||||
// we don't defer modals and can't respond to a modal with a modal.
|
||||
const cnt = page.render(pageContext);
|
||||
const content = isPromise(cnt) ? await cnt : cnt;
|
||||
return await pagesInteraction.createModal(content as InteractionModalContent);
|
||||
}
|
||||
|
||||
if (page.type === PageType.FOLLOWUP) {
|
||||
if (!pageState.messageId) {
|
||||
throw new Error('Cannot send a followup message before an initial message has been sent');
|
||||
}
|
||||
const flags = page.type === PageType.FOLLOWUP ? page.followUpFlags ?? getFlags(options) : getFlags(options);
|
||||
await pagesInteraction.defer(flags);
|
||||
const cnt = page.render(pageContext);
|
||||
const content = isPromise(cnt) ? await cnt : cnt;
|
||||
return await pagesInteraction.createFollowup({
|
||||
flags,
|
||||
...(content as InteractionContentEdit),
|
||||
});
|
||||
}
|
||||
|
||||
if (pageState.messageId && isMessageComponent(pagesInteraction)) {
|
||||
await pagesInteraction.deferUpdate();
|
||||
const cnt = page.render(pageContext);
|
||||
const content = isPromise(cnt) ? await cnt : cnt;
|
||||
return await pagesInteraction.editMessage(pageState.messageId, content as InteractionContentEdit);
|
||||
}
|
||||
|
||||
{
|
||||
await pagesInteraction.defer(getFlags(options));
|
||||
const cnt = page.render(pageContext);
|
||||
const content = isPromise(cnt) ? await cnt : cnt;
|
||||
const message = await pagesInteraction.createFollowup({
|
||||
flags: getFlags(options),
|
||||
...(content as InteractionContentEdit),
|
||||
});
|
||||
pageState.messageId = message.id;
|
||||
pageState.channelId = message.channel?.id;
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
|
||||
return typeof (value as Promise<T>)?.then === 'function';
|
||||
}
|
||||
99
packages/lib/src/discord/pages/subroutes.ts
Normal file
99
packages/lib/src/discord/pages/subroutes.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { PartialEmoji } from '@projectdysnomia/dysnomia';
|
||||
import { createActionRow, createButton, createMediaGallery, type ButtonOptions, type ContainerItems } from '@/discord/components';
|
||||
import type { PageContext } from './pages';
|
||||
|
||||
export function getSubrouteKey(prefix: string, subroutes: string[]) {
|
||||
return `${prefix}:${subroutes.join(':')}`;
|
||||
}
|
||||
|
||||
export function parseSubrouteKey(key: string, expectedPrefix: string, expectedLength: number, defaults: string[] = []) {
|
||||
const parts = key.split(':');
|
||||
if (parts[0] !== expectedPrefix) {
|
||||
throw new Error(`Unexpected prefix: ${parts[0]}`);
|
||||
}
|
||||
if (parts.length - 1 < expectedLength && defaults.length) {
|
||||
// fill in defaults
|
||||
parts.push(...defaults.slice(parts.length - 1));
|
||||
}
|
||||
if (parts.length !== expectedLength + 1) {
|
||||
throw new Error(`Expected ${expectedLength} subroutes, but got ${parts.length - 1}`);
|
||||
}
|
||||
return parts.slice(1);
|
||||
}
|
||||
|
||||
export function renderSubrouteButtons(
|
||||
currentSubroute: string,
|
||||
subRoutes: string[],
|
||||
subrouteIndex: number,
|
||||
prefix: string,
|
||||
subroutes: { label: string; value: string; emoji?: PartialEmoji }[],
|
||||
options?: Partial<ButtonOptions>,
|
||||
) {
|
||||
return subroutes
|
||||
.filter((sr) => sr !== undefined)
|
||||
.map(({ label, value, emoji }) => {
|
||||
const routes = [...subRoutes];
|
||||
routes[subrouteIndex] = currentSubroute == value ? '_' : value;
|
||||
return createButton(label, getSubrouteKey(prefix, routes), {
|
||||
...options,
|
||||
disabled: value === currentSubroute,
|
||||
emoji,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface SubrouteOptions {
|
||||
label: string;
|
||||
value: string;
|
||||
emoji?: PartialEmoji;
|
||||
}
|
||||
|
||||
export function renderSubroutes<T, CType = ContainerItems>(
|
||||
context: PageContext<T>,
|
||||
prefix: string,
|
||||
subroutes: (SubrouteOptions & {
|
||||
banner?: string;
|
||||
actionRowPosition?: 'top' | 'bottom';
|
||||
})[][],
|
||||
render: (currentSubroute: string, ctx: PageContext<T>) => CType,
|
||||
btnOptions?: Partial<ButtonOptions>,
|
||||
defaultSubroutes?: string[], // if not provided, will use the first option of each subroute
|
||||
): CType[] {
|
||||
const currentSubroutes = parseSubrouteKey(
|
||||
context.custom_id,
|
||||
prefix,
|
||||
subroutes.length,
|
||||
defaultSubroutes || subroutes.map((s) => s[0].value),
|
||||
);
|
||||
|
||||
const components = subroutes
|
||||
.filter((sr) => sr.length > 0)
|
||||
.map((srOpts, index) => {
|
||||
const opts = srOpts.filter((sr) => sr !== undefined);
|
||||
if (opts.length === 0) return undefined;
|
||||
// find the current subroute, or default to the first
|
||||
const sri = opts.findIndex((s) => s.value === currentSubroutes[index]);
|
||||
const current = opts[sri] || opts[0];
|
||||
const components = [];
|
||||
|
||||
const actionRow = createActionRow(...renderSubrouteButtons(current.value, currentSubroutes, index, prefix, opts, btnOptions));
|
||||
|
||||
if (current.banner) {
|
||||
components.push(createMediaGallery({ url: current.banner }));
|
||||
}
|
||||
|
||||
if (!current.actionRowPosition || current.actionRowPosition === 'top') {
|
||||
components.push(actionRow);
|
||||
}
|
||||
|
||||
components.push(render(current.value, context));
|
||||
|
||||
if (current.actionRowPosition === 'bottom') {
|
||||
components.push(actionRow);
|
||||
}
|
||||
return components;
|
||||
})
|
||||
.flat()
|
||||
.filter((c) => c !== undefined);
|
||||
return components;
|
||||
}
|
||||
30
packages/lib/src/eve/esi/alliance.ts
Normal file
30
packages/lib/src/eve/esi/alliance.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { esiFetch } from './fetch';
|
||||
|
||||
// PUBLIC APIS ---------------------------------------------------------------
|
||||
|
||||
interface AllianceData {
|
||||
creator_corporation_id: number;
|
||||
creator_id: number;
|
||||
date_founded: string;
|
||||
executor_corporation_id: number;
|
||||
faction_id: number;
|
||||
name: string;
|
||||
ticker: string;
|
||||
}
|
||||
|
||||
export async function getAllianceData(id: number) {
|
||||
return await esiFetch<Partial<AllianceData>>(`/alliances/${id}/`);
|
||||
}
|
||||
|
||||
export async function getAllianceCorporations(id: number) {
|
||||
return await esiFetch<number[]>(`/alliances/${id}/corporations/`);
|
||||
}
|
||||
|
||||
interface AllianceIcons {
|
||||
px128x128: string;
|
||||
px64x64: string;
|
||||
}
|
||||
|
||||
export async function getAllianceIcons(id: number) {
|
||||
return await esiFetch<Partial<AllianceIcons>>(`/alliances/${id}/icons/`);
|
||||
}
|
||||
102
packages/lib/src/eve/esi/auth.ts
Normal file
102
packages/lib/src/eve/esi/auth.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { encodeBase64urlNoPadding } from '@oslojs/encoding';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import jwkToPem from 'jwk-to-pem';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import { options } from './options';
|
||||
|
||||
export interface EveTokens {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
function generateState(): string {
|
||||
const randomValues = new Uint8Array(32);
|
||||
crypto.getRandomValues(randomValues);
|
||||
return encodeBase64urlNoPadding(randomValues);
|
||||
}
|
||||
|
||||
export async function createAuthorizationURL(scopes: string[] | string = 'publicData') {
|
||||
const state = generateState();
|
||||
const url = new URL('https://login.eveonline.com/v2/oauth/authorize/');
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('redirect_uri', options.callback_url);
|
||||
url.searchParams.set('client_id', options.client_id);
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('scope', Array.isArray(scopes) ? scopes.join(' ') : scopes);
|
||||
return {
|
||||
url,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateAuthorizationCode(code: string): Promise<EveTokens> {
|
||||
try {
|
||||
const response = await fetch('https://login.eveonline.com/v2/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Basic ${Buffer.from(`${options.client_id}:${options.client_secret}`).toString('base64')}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
}),
|
||||
});
|
||||
return (await response.json()) as EveTokens;
|
||||
} catch (error) {
|
||||
console.error(`failed to validate EVE authorization code`, error);
|
||||
throw `${error}`;
|
||||
}
|
||||
}
|
||||
|
||||
// cache the public key for EVE Online's OAuth2 provider
|
||||
let eveAuthPublicKey: any;
|
||||
export async function validateToken(token: string) {
|
||||
if (!eveAuthPublicKey) {
|
||||
try {
|
||||
const eveJWKS = (await (await fetch('https://login.eveonline.com/oauth/jwks')).json()) as { keys: any[] };
|
||||
eveAuthPublicKey = jwkToPem(eveJWKS.keys[0]);
|
||||
} catch (err) {
|
||||
console.error(`failed to get EVE Auth public keys`, err);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, eveAuthPublicKey);
|
||||
return decoded;
|
||||
} catch (err) {
|
||||
console.error(`failed to validate EVE token`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function refresh(
|
||||
{ refresh_token }: { refresh_token: string },
|
||||
scopes?: string[] | string,
|
||||
): Promise<EveTokens> {
|
||||
const params = {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token,
|
||||
scope: '' as string | string[],
|
||||
};
|
||||
|
||||
if (scopes) {
|
||||
params['scope'] = Array.isArray(scopes) ? scopes.join(' ') : scopes;
|
||||
}
|
||||
|
||||
const response = await fetch('https://login.eveonline.com/v2/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Basic ${Buffer.from(`${options.client_id}:${options.client_secret}`).toString('base64')}`,
|
||||
},
|
||||
body: new URLSearchParams(params),
|
||||
});
|
||||
return (await response.json()) as EveTokens;
|
||||
}
|
||||
|
||||
export function characterIdFromToken(token: string) {
|
||||
const payload = jwtDecode(token);
|
||||
return parseInt(payload.sub!.split(':')[2]);
|
||||
}
|
||||
381
packages/lib/src/eve/esi/character.ts
Normal file
381
packages/lib/src/eve/esi/character.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import { CharacterHelper, type Character } from '@/eve/db';
|
||||
import { esiFetch } from './fetch';
|
||||
import { tokenHasScopes } from './scopes';
|
||||
|
||||
// PUBLIC APIS ---------------------------------------------------------------
|
||||
|
||||
export interface CharacterData {
|
||||
alliance_id: number;
|
||||
birthday: string;
|
||||
bloodline_id: number;
|
||||
corporation_id: number;
|
||||
description: string;
|
||||
faction_id: number;
|
||||
gender: 'male' | 'female';
|
||||
name: string;
|
||||
race_id: number;
|
||||
security_status: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function getCharacterPublicData(id: number) {
|
||||
return esiFetch<Partial<CharacterData>>(`/characters/${id}/`);
|
||||
}
|
||||
|
||||
export interface CharacterAffiliations {
|
||||
alliance_id: number;
|
||||
character_id: number;
|
||||
corporation_id: number;
|
||||
faction_id: number;
|
||||
}
|
||||
|
||||
export function getCharacterAffiliations(ids: number[]) {
|
||||
return esiFetch<Partial<CharacterAffiliations>[]>(`/characters/affiliation/`, undefined, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(ids),
|
||||
})[0] as Partial<CharacterAffiliations>;
|
||||
}
|
||||
|
||||
export interface CharacterPortraits {
|
||||
px128x128: string;
|
||||
px256x256: string;
|
||||
px512x512: string;
|
||||
px64x64: string;
|
||||
}
|
||||
|
||||
export function getCharacterPortraits(id: number) {
|
||||
return esiFetch<Partial<CharacterPortraits>>(`/characters/${id}/portrait/`);
|
||||
}
|
||||
|
||||
export interface CharacterCorporationHistory {
|
||||
corporation_id: number;
|
||||
is_deleted: boolean;
|
||||
record_id: number;
|
||||
start_date: string;
|
||||
}
|
||||
|
||||
export function getCharacterCorporationHistory(id: number) {
|
||||
return esiFetch<Partial<CharacterCorporationHistory>[]>(`/characters/${id}/corporationhistory/`);
|
||||
}
|
||||
|
||||
export function getPortraitURL(id: number) {
|
||||
return `https://images.evetech.net/characters/${id}/portrait`;
|
||||
}
|
||||
|
||||
// PRIVATE APIS --------------------------------------------------------------
|
||||
|
||||
export interface CharacterRoles {
|
||||
roles: string[];
|
||||
roles_at_base: string[];
|
||||
roles_at_hq: string[];
|
||||
roles_at_other: string[];
|
||||
}
|
||||
|
||||
// required scope: esi-characters.read_corporation_roles.v1
|
||||
export function getCharacterRoles(character: Character) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-characters.read_corporation_roles.v1')) return null;
|
||||
return esiFetch<Partial<CharacterRoles>>(`/characters/${character.eveID}/roles/`, character);
|
||||
}
|
||||
|
||||
export interface CharacterTitles {
|
||||
titles: {
|
||||
name: string;
|
||||
title_id: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
// required scope: esi-characters.read_titles.v1
|
||||
export function getCharacterTitles(character: Character) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-characters.read_titles.v1')) return null;
|
||||
return esiFetch<Partial<CharacterTitles>>(`/characters/${character.eveID}/titles/`, character);
|
||||
}
|
||||
|
||||
export interface CharacterStandings {
|
||||
from_id: number;
|
||||
from_type: 'agent' | 'npc_corp' | 'faction';
|
||||
standing: number;
|
||||
}
|
||||
|
||||
// required scope: esi-characters.read_standings.v1
|
||||
export function getCharacterStandings(character: Character) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-characters.read_standings.v1')) return null;
|
||||
return esiFetch<Partial<CharacterStandings>[]>(`/characters/${character.eveID}/standings/`, character);
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
is_read: boolean;
|
||||
sender_id: number;
|
||||
sender_type: 'character' | 'corporation' | 'alliance' | 'faction' | 'system';
|
||||
text: string;
|
||||
timestamp: string;
|
||||
type:
|
||||
| 'character'
|
||||
| 'corporation'
|
||||
| 'alliance'
|
||||
| 'faction'
|
||||
| 'inventory'
|
||||
| 'industry'
|
||||
| 'loyalty'
|
||||
| 'skills'
|
||||
| 'sov'
|
||||
| 'structures'
|
||||
| 'war';
|
||||
}
|
||||
|
||||
// required scope: esi-characters.read_notifications.v1
|
||||
export function getCharacterNotifications(character: Character) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-characters.read_notifications.v1')) return null;
|
||||
return esiFetch<Partial<Notification>[]>(`/characters/${character.eveID}/notifications/`, character);
|
||||
}
|
||||
|
||||
export interface ContactNotification {
|
||||
message: string;
|
||||
notification_id: number;
|
||||
send_date: string;
|
||||
sender_character_id: number;
|
||||
standing_level: number;
|
||||
}
|
||||
|
||||
// required scope: esi-characters.read_notifications.v1
|
||||
export function getCharacterContactNotifications(character: Character) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-characters.read_notifications.v1')) return null;
|
||||
return esiFetch<Partial<ContactNotification>[]>(`/characters/${character.eveID}/notifications/contacts`, character);
|
||||
}
|
||||
|
||||
export interface Medals {
|
||||
corporation_id: number;
|
||||
date: string;
|
||||
description: string;
|
||||
graphics: {
|
||||
color: number;
|
||||
graphic: number;
|
||||
layer: number;
|
||||
part: number;
|
||||
}[];
|
||||
issuer_id: number;
|
||||
medal_id: number;
|
||||
reason: string;
|
||||
status: 'private' | 'public';
|
||||
title: string;
|
||||
}
|
||||
|
||||
// required scope: esi-characters.read_medals.v1
|
||||
export function getCharacterMedals(character: Character) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-characters.read_medals.v1')) return null;
|
||||
return esiFetch<Partial<Medals>[]>(`/characters/${character.eveID}/medals/`, character);
|
||||
}
|
||||
|
||||
export interface JumpFatigue {
|
||||
jump_fatigue_expire_date: string;
|
||||
last_jump_date: string;
|
||||
last_update_date: string;
|
||||
}
|
||||
|
||||
// required scope: esi-characters.read_fatigue.v1
|
||||
export function getCharacterJumpFatigue(character: Character) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-characters.read_fatigue.v1')) return null;
|
||||
return esiFetch<Partial<JumpFatigue>>(`/characters/${character.eveID}/fatigue/`, character);
|
||||
}
|
||||
|
||||
export interface Blueprint {
|
||||
item_id: number;
|
||||
location_flag: string;
|
||||
location_id: number;
|
||||
material_efficiency: number;
|
||||
quantity: number;
|
||||
runs: number;
|
||||
time_efficiency: number;
|
||||
type_id: number;
|
||||
}
|
||||
|
||||
// required scope: esi-characters.read_blueprints.v1
|
||||
export function getCharacterBlueprints(character: Character) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-characters.read_blueprints.v1')) return null;
|
||||
return esiFetch<Partial<Blueprint>[]>(`/characters/${character.eveID}/blueprints/`, character);
|
||||
}
|
||||
|
||||
export interface AgentResearch {
|
||||
agent_id: number;
|
||||
points_per_day: number;
|
||||
remainder_points: number;
|
||||
skill_type_id: number;
|
||||
started_at: string;
|
||||
}
|
||||
|
||||
// required scope: esi-characters.read_agents_research.v1
|
||||
export function getCharacterAgentResearch(character: Character) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-characters.read_agents_research.v1')) return null;
|
||||
return esiFetch<Partial<AgentResearch>[]>(`/characters/${character.eveID}/agents_research/`, character);
|
||||
}
|
||||
|
||||
// CLONES --------------------------------------------------------------------
|
||||
|
||||
export interface Clones {
|
||||
home_location: {
|
||||
location_id: number;
|
||||
location_type: 'station' | 'structure';
|
||||
};
|
||||
jump_clones: {
|
||||
implants: number[];
|
||||
jump_clone_id: number;
|
||||
location_id: number;
|
||||
location_type: 'station' | 'structure';
|
||||
name: string;
|
||||
}[];
|
||||
last_clone_jump_date: string;
|
||||
last_station_change_date: string;
|
||||
}
|
||||
|
||||
// required scope: esi-clones.read_clones.v1
|
||||
export function getCharacterClones(character: Character) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-clones.read_clones.v1')) return null;
|
||||
return esiFetch<Partial<Clones>>(`/characters/${character.eveID}/clones/`, character);
|
||||
}
|
||||
|
||||
// required scope: esi-clones.read_implants.v1
|
||||
export function getCharacterImplants(character: Character) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-clones.read_implants.v1')) return null;
|
||||
return esiFetch<number[]>(`/characters/${character.eveID}/implants/`, character);
|
||||
}
|
||||
|
||||
// ASSETS --------------------------------------------------------------------
|
||||
|
||||
export interface Asset {
|
||||
is_blueprint_copy: boolean;
|
||||
is_singleton: boolean;
|
||||
item_id: number;
|
||||
location_flag: string;
|
||||
location_id: number;
|
||||
location_type: 'station' | 'solar_system' | 'other';
|
||||
quantity: number;
|
||||
type_id: number;
|
||||
}
|
||||
|
||||
// required scope: esi-assets.read_assets.v1
|
||||
export function getCharacterAssets(character: Character) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-assets.read_assets.v1')) return null;
|
||||
return esiFetch<Partial<Asset>[]>(`/characters/${character.eveID}/assets/`, character);
|
||||
}
|
||||
|
||||
export interface AssetLocation {
|
||||
item_id: number;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
};
|
||||
}
|
||||
|
||||
// required scope: esi-assets.read_assets.v1
|
||||
export function getCharacterAssetLocations(character: Character, ids: number[]) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-assets.read_assets.v1')) return null;
|
||||
return esiFetch<Partial<AssetLocation>[]>(`/characters/${character.eveID}/assets/locations/`, character, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(ids),
|
||||
});
|
||||
}
|
||||
|
||||
export interface AssetNames {
|
||||
item_id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// required scope: esi-assets.read_assets.v1
|
||||
export function getCharacterAssetNames(character: Character, ids: number[]) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-assets.read_assets.v1')) return null;
|
||||
return esiFetch<Partial<AssetNames>[]>(`/characters/${character.eveID}/assets/names/`, character, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(ids),
|
||||
});
|
||||
}
|
||||
|
||||
// WALLET --------------------------------------------------------------------
|
||||
|
||||
// required scope: esi-wallet.read_character_wallet.v1
|
||||
export function getCharacterWallet(character: Character) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-wallet.read_character_wallet.v1')) return null;
|
||||
return esiFetch<number>(`/characters/${character.eveID}/wallet/`, character);
|
||||
}
|
||||
|
||||
export interface WalletTransaction {
|
||||
client_id: number;
|
||||
date: string;
|
||||
is_buy: boolean;
|
||||
is_personal: boolean;
|
||||
journal_ref_id: number;
|
||||
location_id: number;
|
||||
quantity: number;
|
||||
transaction_id: number;
|
||||
type_id: number;
|
||||
unit_price: number;
|
||||
}
|
||||
|
||||
// required scope: esi-wallet.read_character_wallet.v1
|
||||
export function getCharacterWalletTransactions(character: Character, fromId: number) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-wallet.read_character_wallet.v1')) return null;
|
||||
return esiFetch<Partial<WalletTransaction>[]>(`/characters/${character.eveID}/wallet/transactions/`, character, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(fromId),
|
||||
});
|
||||
}
|
||||
|
||||
export interface WalletJournalEntry {
|
||||
amount: number; // The amount of ISK given or taken from the wallet as a result of the given transaction. Positive when ISK is deposited into the wallet and negative when ISK is withdrawn
|
||||
balance: number; // Wallet balance after transaction occurred
|
||||
context_id: number; // And ID that gives extra context to the particualr transaction. Because of legacy reasons the context is completely different per ref_type and means different things. It is also possible to not have a context_id
|
||||
context_id_type: 'character' | 'corporation' | 'alliance' | 'faction'; // The type of the given context_id if present
|
||||
date: string; // Date and time of transaction
|
||||
description: string;
|
||||
first_party_id: number;
|
||||
id: number;
|
||||
reason: string;
|
||||
ref_type: 'agent' | 'assetSafety' | 'bounty' | 'bountyPrizes' | 'contract' | 'dividend' | 'marketTransaction' | 'other';
|
||||
second_party_id: number;
|
||||
tax: number;
|
||||
tax_receiver_id: number;
|
||||
}
|
||||
|
||||
// required scope: esi-wallet.read_character_wallet.v1
|
||||
export function getCharacterWalletJournal(character: Character, page: number = 1) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-wallet.read_character_wallet.v1')) return null;
|
||||
return esiFetch<Partial<WalletJournalEntry>[]>(`/characters/${character.eveID}/wallet/journal/?page=${page}`, character);
|
||||
}
|
||||
|
||||
// LOCATION --------------------------------------------------
|
||||
|
||||
export interface Location {
|
||||
solar_system_id: number;
|
||||
station_id: number;
|
||||
structure_id: number;
|
||||
}
|
||||
|
||||
// required scope: esi-location.read_location.v1
|
||||
export function getCharacterLocation(character: Character) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-location.read_location.v1')) return null;
|
||||
return esiFetch<Partial<Location>>(`/characters/${character.eveID}/location/`, character);
|
||||
}
|
||||
|
||||
export interface Online {
|
||||
last_login: string;
|
||||
last_logout: string;
|
||||
logins: number;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
// required scope: esi-location.read_online.v1
|
||||
export function getCharacterOnline(character: Character) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-location.read_online.v1')) return null;
|
||||
return esiFetch<Partial<Online>>(`/characters/${character.eveID}/online/`, character);
|
||||
}
|
||||
|
||||
export interface CurrentShip {
|
||||
ship_item_id: number;
|
||||
ship_type_id: number;
|
||||
ship_name: string;
|
||||
}
|
||||
|
||||
// required scope: esi-location.read_ship_type.v1
|
||||
export function getCharacterCurrentShip(character: Character) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-location.read_ship_type.v1')) return null;
|
||||
return esiFetch<Partial<CurrentShip>>(`/characters/${character.eveID}/ship/`, character);
|
||||
}
|
||||
97
packages/lib/src/eve/esi/corporation.ts
Normal file
97
packages/lib/src/eve/esi/corporation.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { CharacterHelper, type Character } from '@/eve/db';
|
||||
import { esiFetch } from './fetch';
|
||||
|
||||
// PUBLIC APIS ---------------------------------------------------------------
|
||||
|
||||
interface CorporationData {
|
||||
alliance_id: number;
|
||||
ceo_id: number;
|
||||
creator_id: number;
|
||||
date_founded: string;
|
||||
description: string;
|
||||
faction_id: number;
|
||||
home_station_id: number;
|
||||
member_count: number;
|
||||
name: string;
|
||||
shares: number;
|
||||
tax_rate: number;
|
||||
ticker: string;
|
||||
url: string;
|
||||
war_eligible: boolean;
|
||||
}
|
||||
|
||||
export async function getCorporationData(id: number) {
|
||||
return await esiFetch<Partial<CorporationData>>(`/corporations/${id}/`);
|
||||
}
|
||||
|
||||
interface AllianceHistory {
|
||||
alliance_id: number;
|
||||
is_deleted: boolean;
|
||||
record_id: number;
|
||||
start_date: string;
|
||||
}
|
||||
|
||||
export async function getCorporationAllianceHistory(id: number) {
|
||||
return await esiFetch<Partial<AllianceHistory>[]>(`/corporations/${id}/alliancehistory/`);
|
||||
}
|
||||
|
||||
interface CorporationIcons {
|
||||
px256x256: string;
|
||||
px128x128: string;
|
||||
px64x64: string;
|
||||
}
|
||||
|
||||
export async function getCorporationIcons(id: number) {
|
||||
return await esiFetch<Partial<CorporationIcons>>(`/corporations/${id}/icons/`);
|
||||
}
|
||||
|
||||
// ASSETS -------------------------------------------------------------------
|
||||
|
||||
export interface AssetData {
|
||||
is_blueprint_copy: boolean;
|
||||
is_singleton: boolean;
|
||||
item_id: number;
|
||||
location_flag: string;
|
||||
location_id: number;
|
||||
location_type: string;
|
||||
quantity: number;
|
||||
type_id: number;
|
||||
}
|
||||
|
||||
// required scope: esi-assets.read_corporation_assets.v1
|
||||
export async function getCorporationAssets(id: number, character: Character) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-assets.read_corporation_assets.v1')) return null;
|
||||
return await esiFetch<Partial<AssetData>[]>(`/corporations/${id}/assets/`, character);
|
||||
}
|
||||
|
||||
export interface AssetLocation {
|
||||
item_id: number;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
};
|
||||
}
|
||||
|
||||
// required scope: esi-assets.read_corporation_assets.v1
|
||||
export async function getCorporationAssetLocations(id: number, character: Character, ids: number[]) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-assets.read_corporation_assets.v1')) return null;
|
||||
return await esiFetch<Partial<AssetLocation>[]>(`/corporations/${id}/assets/locations/`, character, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(ids),
|
||||
});
|
||||
}
|
||||
|
||||
export interface AssetNames {
|
||||
item_id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// required scope: esi-assets.read_corporation_assets.v1
|
||||
export async function getCorporationAssetNames(id: number, character: Character, ids: number[]) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-assets.read_corporation_assets.v1')) return null;
|
||||
return await esiFetch<Partial<AssetNames>[]>(`/corporations/${id}/assets/names/`, character, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(ids),
|
||||
});
|
||||
}
|
||||
92
packages/lib/src/eve/esi/fetch.ts
Normal file
92
packages/lib/src/eve/esi/fetch.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { type Character, CharacterHelper } from '@/eve/db/models';
|
||||
import { options } from './options';
|
||||
import { ESI_LATEST_URL } from './scopes';
|
||||
|
||||
const cache = new Map<string, CacheItem>();
|
||||
|
||||
interface RequestOptions extends RequestInit {
|
||||
noCache?: boolean;
|
||||
cacheDuration?: number; // default 30 minutes
|
||||
}
|
||||
|
||||
interface CacheItem {
|
||||
expires: number;
|
||||
data: any;
|
||||
}
|
||||
|
||||
function cleanCache() {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of cache) {
|
||||
if (value.expires < now) {
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(cleanCache, 1000 * 60 * 15); // clean cache every 15 minutes
|
||||
|
||||
const defaultCacheDuration = 1000 * 60 * 30; // 30 minutes
|
||||
|
||||
export async function esiFetch<T>(
|
||||
path: string,
|
||||
character?: Character,
|
||||
{ method = 'GET', body, noCache = false, cacheDuration = defaultCacheDuration }: Partial<RequestOptions> = {},
|
||||
) {
|
||||
try {
|
||||
const headers = {
|
||||
'User-Agent': options.user_agent,
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
if (character) {
|
||||
// check if the token is expired
|
||||
if (!CharacterHelper.hasValidToken(character)) {
|
||||
await CharacterHelper.refreshTokens(character);
|
||||
if (!CharacterHelper.hasValidToken(character)) {
|
||||
throw new Error(`Failed to refresh token for character: ${character.eveID}`);
|
||||
}
|
||||
}
|
||||
|
||||
headers['Authorization'] = `Bearer ${character.accessToken}`;
|
||||
}
|
||||
|
||||
const init: RequestInit = {
|
||||
headers,
|
||||
method: method || 'GET',
|
||||
body: body || undefined,
|
||||
};
|
||||
|
||||
const url = new URL(`${ESI_LATEST_URL}${path.startsWith('/') ? path : '/' + path}`);
|
||||
url.searchParams.set('datasource', 'tranquility');
|
||||
|
||||
if (!noCache && init.method === 'GET') {
|
||||
const cached = cache.get(url.href);
|
||||
if (cached && cached?.expires > Date.now()) {
|
||||
return cached.data as T;
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(url, init);
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(`ESI request failure at ${path} | ${res.status}:${res.statusText} => ${JSON.stringify(data)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (init.method === 'GET') {
|
||||
cache.set(url.href, {
|
||||
expires: Math.max(
|
||||
(res.headers.get('expires') && new Date(Number(res.headers.get('expires') || '')).getTime()) || 0,
|
||||
Date.now() + cacheDuration,
|
||||
),
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
return data as T;
|
||||
} catch (err) {
|
||||
console.error(`ESI request failure at ${path} | ${JSON.stringify(err)}`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
12
packages/lib/src/eve/esi/index.ts
Normal file
12
packages/lib/src/eve/esi/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export * from './scopes';
|
||||
export * as CharacterAPI from './character';
|
||||
export * as CorporationAPI from './corporation';
|
||||
export * as AllianceAPI from './alliance';
|
||||
export * as auth from './auth';
|
||||
export * from './auth';
|
||||
export * from './fetch';
|
||||
export * from './skills';
|
||||
export * from './options';
|
||||
export * from './mail';
|
||||
export * from './character';
|
||||
export * from './alliance';
|
||||
130
packages/lib/src/eve/esi/mail.ts
Normal file
130
packages/lib/src/eve/esi/mail.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { esiFetch } from './fetch';
|
||||
import { CharacterHelper, type Character } from '@/eve/db';
|
||||
|
||||
export interface MailHeader {
|
||||
from: number; // From whom the mail was sent
|
||||
is_read: boolean; // is_read boolean
|
||||
labels: string[]; // maxItems: 25, minimum: 0, title: get_characters_character_id_mail_labels, uniqueItems: true, labels array
|
||||
mail_id: number; // mail_id integer
|
||||
recipients: {
|
||||
recipient_id: number; // recipient_id integer
|
||||
recipient_type: 'alliance' | 'character' | 'corporation' | 'mailing_list'; // recipient_type enum
|
||||
}[]; // maxItems: 52, minimum: 0, title: get_characters_character_id_mail_recipients, uniqueItems: true, recipients of the mail
|
||||
subject: string; // Mail subject
|
||||
timestamp: string; // When the mail was sent
|
||||
}
|
||||
|
||||
// requires scope: esi-mail.read_mail.v1
|
||||
export function getMailHeaders(character: Character) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-mail.read_mail.v1')) return null;
|
||||
return esiFetch<MailHeader[]>(`/characters/${character.eveID}/mail/`, character);
|
||||
}
|
||||
|
||||
export interface SendMail {
|
||||
approved_cost?: number; // approved_cost number
|
||||
body: string; // body string; max length 10000
|
||||
recipients: {
|
||||
recipient_id: number; // recipient_id integer
|
||||
recipient_type: 'alliance' | 'character' | 'corporation' | 'mailing_list'; // recipient_type enum
|
||||
}[]; // maxItems: 50, minimum: 1, title: post_characters_character_id_mail, recipients of the mail
|
||||
subject: string; // subject string; max length 1000
|
||||
}
|
||||
|
||||
// requires scope: esi-mail.send_mail.v1
|
||||
export function sendMail(character: Character, mail: SendMail) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-mail.send_mail.v1')) return null;
|
||||
return esiFetch(`/characters/${character.eveID}/mail/`, character, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(mail),
|
||||
});
|
||||
}
|
||||
|
||||
// requires scope: esi-mail.read_mail.v1
|
||||
export function deleteMail(character: Character, mailID: number) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-mail.organize_mail.v1')) return null;
|
||||
return esiFetch(`/characters/${character.eveID}/mail/${mailID}/`, character, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export interface Mail {
|
||||
body: string; // body string
|
||||
from: number; // from integer
|
||||
labels: string[]; // labels array
|
||||
read: boolean; // read boolean
|
||||
subject: string; // subject string
|
||||
timestamp: string; // timestamp string
|
||||
recipients: {
|
||||
recipient_id: number; // recipient_id integer
|
||||
recipient_type: 'alliance' | 'character' | 'corporation' | 'mailing_list'; // recipient_type enum
|
||||
}[]; // recipients array
|
||||
}
|
||||
|
||||
// requires scope: esi-mail.read_mail.v1
|
||||
export function getMail(character: Character, mailID: number) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-mail.read_mail.v1')) return null;
|
||||
return esiFetch<Mail>(`/characters/${character.eveID}/mail/${mailID}/`, character);
|
||||
}
|
||||
|
||||
export interface MailMetadata {
|
||||
labels: string[]; // labels array
|
||||
read: boolean; // read boolean
|
||||
}
|
||||
|
||||
// requires scope: esi-mail.organize_mail.v1
|
||||
export function updateMailMetadata(character: Character, mailID: number, metadata: MailMetadata) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-mail.organize_mail.v1')) return null;
|
||||
return esiFetch(`/characters/${character.eveID}/mail/${mailID}/`, character, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(metadata),
|
||||
});
|
||||
}
|
||||
|
||||
export interface MailLabels {
|
||||
labels: {
|
||||
color: number; // color integer
|
||||
label_id: number; // label_id integer
|
||||
name: string; // name string
|
||||
unread_count: number; // unread_count integer
|
||||
}[]; // labels array
|
||||
total_unread_count: number; // total_unread_count integer
|
||||
}
|
||||
|
||||
// requires scope: esi-mail.read_mail.v1
|
||||
export function getMailLabels(character: Character) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-mail.read_mail.v1')) return null;
|
||||
return esiFetch<MailLabels>(`/characters/${character.eveID}/mail/labels/`, character);
|
||||
}
|
||||
|
||||
export interface CreateMailLabel {
|
||||
color: number; // color integer
|
||||
name: string; // name string
|
||||
}
|
||||
|
||||
// requires scope: esi-mail.organize_mail.v1
|
||||
export function createMailLabel(character: Character, label: CreateMailLabel) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-mail.organize_mail.v1')) return null;
|
||||
return esiFetch(`/characters/${character.eveID}/mail/labels/`, character, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(label),
|
||||
});
|
||||
}
|
||||
|
||||
// requires scope: esi-mail.organize_mail.v1
|
||||
export function deleteMailLabel(character: Character, labelID: number) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-mail.organize_mail.v1')) return null;
|
||||
return esiFetch(`/characters/${character.eveID}/mail/labels/${labelID}/`, character, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export interface MailingList {
|
||||
mailing_list_id: number; // mailing_list_id integer
|
||||
name: string; // name string
|
||||
}
|
||||
|
||||
// requires scope: esi-mail.read_mail.v1
|
||||
export function getMailingLists(character: Character) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-mail.read_mail.v1')) return null;
|
||||
return esiFetch<MailingList[]>(`/characters/${character.eveID}/mail/lists/`, character);
|
||||
}
|
||||
18
packages/lib/src/eve/esi/options.ts
Normal file
18
packages/lib/src/eve/esi/options.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface EveAuthOptions {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
callback_url: string;
|
||||
user_agent: string;
|
||||
}
|
||||
|
||||
const CLIENT_ID = process.env.EVE_CLIENT_ID || '';
|
||||
const CLIENT_SECRET = process.env.EVE_CLIENT_SECRET || '';
|
||||
const CALLBACK_URL = process.env.EVE_CALLBACK_URL || '';
|
||||
const USER_AGENT = process.env.ESI_USER_AGENT || '';
|
||||
|
||||
export const options: EveAuthOptions = {
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
callback_url: CALLBACK_URL,
|
||||
user_agent: USER_AGENT,
|
||||
};
|
||||
91
packages/lib/src/eve/esi/scopes.ts
Normal file
91
packages/lib/src/eve/esi/scopes.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
|
||||
export const EVE_JWKS_URL = 'https://login.eveonline.com/oauth/jwks';
|
||||
export const EVE_ISSUER = 'login.eveonline.com';
|
||||
export const EVE_AUDIENCE = 'eveonline';
|
||||
export const ESI_LATEST_URL = 'https://esi.evetech.net/latest';
|
||||
export const DATA_SOURCE = 'tranquility';
|
||||
|
||||
export function joinScopes(...scopes: string[]) {
|
||||
return scopes.join(' ');
|
||||
}
|
||||
|
||||
export enum SCOPES {
|
||||
PUBLIC_DATA = 'publicData',
|
||||
CALENDAR_RESPOND_CALENDAR_EVENTS = 'esi-calendar.respond_calendar_events.v1',
|
||||
CALENDAR_READ_CALENDAR_EVENTS = 'esi-calendar.read_calendar_events.v1',
|
||||
LOCATION_READ_LOCATION = 'esi-location.read_location.v1',
|
||||
LOCATION_READ_SHIP_TYPE = 'esi-location.read_ship_type.v1',
|
||||
MAIL_ORGANIZE_MAIL = 'esi-mail.organize_mail.v1',
|
||||
MAIL_READ_MAIL = 'esi-mail.read_mail.v1',
|
||||
MAIL_SEND_MAIL = 'esi-mail.send_mail.v1',
|
||||
SKILLS_READ_SKILLS = 'esi-skills.read_skills.v1',
|
||||
SKILLS_READ_SKILLQUEUE = 'esi-skills.read_skillqueue.v1',
|
||||
WALLET_READ_CHARACTER_WALLET = 'esi-wallet.read_character_wallet.v1',
|
||||
WALLET_READ_CORPORATION_WALLET = 'esi-wallet.read_corporation_wallet.v1',
|
||||
SEARCH_SEARCH_STRUCTURES = 'esi-search.search_structures.v1',
|
||||
CLONES_READ_CLONES = 'esi-clones.read_clones.v1',
|
||||
CHARACTERS_READ_CONTACTS = 'esi-characters.read_contacts.v1',
|
||||
UNIVERSE_READ_STRUCTURES = 'esi-universe.read_structures.v1',
|
||||
KILLMAILS_READ_KILLMAILS = 'esi-killmails.read_killmails.v1',
|
||||
CORPORATIONS_READ_CORPORATION_MEMBERSHIP = 'esi-corporations.read_corporation_membership.v1',
|
||||
ASSETS_READ_ASSETS = 'esi-assets.read_assets.v1',
|
||||
PLANETS_MANAGE_PLANETS = 'esi-planets.manage_planets.v1',
|
||||
FLEETS_READ_FLEET = 'esi-fleets.read_fleet.v1',
|
||||
FLEETS_WRITE_FLEET = 'esi-fleets.write_fleet.v1',
|
||||
UI_OPEN_WINDOW = 'esi-ui.open_window.v1',
|
||||
UI_WRITE_WAYPOINT = 'esi-ui.write_waypoint.v1',
|
||||
CHARACTERS_WRITE_CONTACTS = 'esi-characters.write_contacts.v1',
|
||||
FITTINGS_READ_FITTINGS = 'esi-fittings.read_fittings.v1',
|
||||
FITTINGS_WRITE_FITTINGS = 'esi-fittings.write_fittings.v1',
|
||||
MARKETS_STRUCTURE_MARKETS = 'esi-markets.structure_markets.v1',
|
||||
CORPORATIONS_READ_STRUCTURES = 'esi-corporations.read_structures.v1',
|
||||
CHARACTERS_READ_LOYALTY = 'esi-characters.read_loyalty.v1',
|
||||
CHARACTERS_READ_OPPORTUNITIES = 'esi-characters.read_opportunities.v1',
|
||||
CHARACTERS_READ_CHAT_CHANNELS = 'esi-characters.read_chat_channels.v1',
|
||||
CHARACTERS_READ_MEDALS = 'esi-characters.read_medals.v1',
|
||||
CHARACTERS_READ_STANDINGS = 'esi-characters.read_standings.v1',
|
||||
CHARACTERS_READ_AGENTS_RESEARCH = 'esi-characters.read_agents_research.v1',
|
||||
INDUSTRY_READ_CHARACTER_JOBS = 'esi-industry.read_character_jobs.v1',
|
||||
MARKETS_READ_CHARACTER_ORDERS = 'esi-markets.read_character_orders.v1',
|
||||
CHARACTERS_READ_BLUEPRINTS = 'esi-characters.read_blueprints.v1',
|
||||
CHARACTERS_READ_CORPORATION_ROLES = 'esi-characters.read_corporation_roles.v1',
|
||||
LOCATION_READ_ONLINE = 'esi-location.read_online.v1',
|
||||
CONTRACTS_READ_CHARACTER_CONTRACTS = 'esi-contracts.read_character_contracts.v1',
|
||||
CLONES_READ_IMPLANTS = 'esi-clones.read_implants.v1',
|
||||
CHARACTERS_READ_FATIGUE = 'esi-characters.read_fatigue.v1',
|
||||
KILLMAILS_READ_CORPORATION_KILLMAILS = 'esi-killmails.read_corporation_killmails.v1',
|
||||
CORPORATIONS_TRACK_MEMBERS = 'esi-corporations.track_members.v1',
|
||||
WALLET_READ_CORPORATION_WALLETS = 'esi-wallet.read_corporation_wallets.v1',
|
||||
CHARACTERS_READ_NOTIFICATIONS = 'esi-characters.read_notifications.v1',
|
||||
CORPORATIONS_READ_DIVISIONS = 'esi-corporations.read_divisions.v1',
|
||||
CORPORATIONS_READ_CONTACTS = 'esi-corporations.read_contacts.v1',
|
||||
ASSETS_READ_CORPORATION_ASSETS = 'esi-assets.read_corporation_assets.v1',
|
||||
CORPORATIONS_READ_TITLES = 'esi-corporations.read_titles.v1',
|
||||
CORPORATIONS_READ_BLUEPRINTS = 'esi-corporations.read_blueprints.v1',
|
||||
CONTRACTS_READ_CORPORATION_CONTRACTS = 'esi-contracts.read_corporation_contracts.v1',
|
||||
CORPORATIONS_READ_STANDINGS = 'esi-corporations.read_standings.v1',
|
||||
CORPORATIONS_READ_STARBASES = 'esi-corporations.read_starbases.v1',
|
||||
INDUSTRY_READ_CORPORATION_JOBS = 'esi-industry.read_corporation_jobs.v1',
|
||||
MARKETS_READ_CORPORATION_ORDERS = 'esi-markets.read_corporation_orders.v1',
|
||||
CORPORATIONS_READ_CONTAINER_LOGS = 'esi-corporations.read_container_logs.v1',
|
||||
INDUSTRY_READ_CHARACTER_MINING = 'esi-industry.read_character_mining.v1',
|
||||
INDUSTRY_READ_CORPORATION_MINING = 'esi-industry.read_corporation_mining.v1',
|
||||
PLANETS_READ_CUSTOMS_OFFICES = 'esi-planets.read_customs_offices.v1',
|
||||
CORPORATIONS_READ_FACILITIES = 'esi-corporations.read_facilities.v1',
|
||||
CORPORATIONS_READ_MEDALS = 'esi-corporations.read_medals.v1',
|
||||
CHARACTERS_READ_TITLES = 'esi-characters.read_titles.v1',
|
||||
ALLIANCES_READ_CONTACTS = 'esi-alliances.read_contacts.v1',
|
||||
CHARACTERS_READ_FW_STATS = 'esi-characters.read_fw_stats.v1',
|
||||
CORPORATIONS_READ_FW_STATS = 'esi-corporations.read_fw_stats.v1',
|
||||
}
|
||||
|
||||
export function tokenHasScopes(access_token: string, ...scopes: string[]) {
|
||||
let tokenScopes = getScopesFromToken(access_token);
|
||||
return scopes.every((scope) => tokenScopes.includes(scope));
|
||||
}
|
||||
|
||||
export function getScopesFromToken(access_token: string) {
|
||||
const decoded = jwtDecode(access_token) as { scp: string[] | string; };
|
||||
return typeof decoded.scp === 'string' ? [decoded.scp] : decoded.scp;
|
||||
}
|
||||
66
packages/lib/src/eve/esi/skills.ts
Normal file
66
packages/lib/src/eve/esi/skills.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { CharacterHelper, type Character } from '@/eve/db/models';
|
||||
import { esiFetch } from './fetch';
|
||||
|
||||
export interface CharacterAttributes {
|
||||
charisma: number;
|
||||
intelligence: number;
|
||||
memory: number;
|
||||
perception: number;
|
||||
willpower: number;
|
||||
last_remap_date?: string;
|
||||
bonus_remaps?: number;
|
||||
accrued_remap_cooldown_date?: string;
|
||||
}
|
||||
|
||||
// required scope: esi-skills.read_skills.v1
|
||||
export function getCharacterAttributes(character: Character) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-skills.read_skills.v1')) return null;
|
||||
return esiFetch<CharacterAttributes>(`/characters/${character.eveID}/attributes`, character);
|
||||
}
|
||||
|
||||
export interface SkillQueueItem {
|
||||
finish_date?: string;
|
||||
finished_level: number;
|
||||
level_end_sp?: number;
|
||||
level_start_sp?: number;
|
||||
queue_position: number;
|
||||
skill_id: number;
|
||||
start_date?: string;
|
||||
training_start_sp?: number;
|
||||
}
|
||||
|
||||
// required scope: esi-skills.read_skillqueue.v1
|
||||
export function getCharacterSkillQueue(character: Character) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-skills.read_skillqueue.v1')) return null;
|
||||
return esiFetch<SkillQueueItem[]>(`/characters/${character.eveID}/skillqueue`, character);
|
||||
}
|
||||
|
||||
export interface APISkill {
|
||||
active_skill_level: number;
|
||||
skill_id: number;
|
||||
skillpoints_in_skill: number;
|
||||
trained_skill_level: number;
|
||||
}
|
||||
|
||||
export interface CharacterSkills {
|
||||
skills: APISkill[]; // max 1000
|
||||
total_sp: number;
|
||||
unallocated_sp?: number;
|
||||
}
|
||||
|
||||
// required scope: esi-skills.read_skills.v1
|
||||
export function getCharacterSkills(character: Character) {
|
||||
if (!CharacterHelper.hasScope(character, 'esi-skills.read_skills.v1')) return null;
|
||||
return esiFetch<CharacterSkills>(`/characters/${character.eveID}/skills`, character);
|
||||
}
|
||||
|
||||
export function calculateTrainingPercentage(queuedSkill: SkillQueueItem) {
|
||||
// percentage in when training started
|
||||
const trainingStartPosition = (queuedSkill.training_start_sp! - queuedSkill.level_start_sp!) / queuedSkill.level_end_sp!;
|
||||
// percentage completed between start and now
|
||||
const timePosition =
|
||||
(new Date().getTime() - new Date(queuedSkill.start_date!).getTime()) /
|
||||
(new Date(queuedSkill.finish_date!).getTime() - new Date(queuedSkill.start_date!).getTime());
|
||||
// percentage completed
|
||||
return trainingStartPosition + (1 - trainingStartPosition) * timePosition;
|
||||
}
|
||||
4
packages/lib/src/eve/index.ts
Normal file
4
packages/lib/src/eve/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './esi/index';
|
||||
export * from './db';
|
||||
export * from './ref';
|
||||
export * from './third-party';
|
||||
130
packages/lib/src/eve/models/attribute.ts
Normal file
130
packages/lib/src/eve/models/attribute.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { LocalizedString } from './shared-types';
|
||||
import { dataSets, loadModels } from './loadModels';
|
||||
|
||||
export interface Attribute {
|
||||
readonly attribute_id: number;
|
||||
readonly category_id: number;
|
||||
readonly data_type: number;
|
||||
readonly default_value: number;
|
||||
readonly description: LocalizedString;
|
||||
readonly high_is_good: boolean;
|
||||
readonly icon_id?: number;
|
||||
readonly name: string;
|
||||
readonly published: boolean;
|
||||
readonly stackable: boolean;
|
||||
readonly unit_id?: number;
|
||||
readonly display_name: LocalizedString;
|
||||
readonly tooltip_title?: LocalizedString;
|
||||
readonly tooltip_description?: LocalizedString;
|
||||
}
|
||||
|
||||
export const getAttribute = (id: number) => {
|
||||
if (!dataSets.loaded) loadModels();
|
||||
const data = dataSets.dogma_attributes[String(id)];
|
||||
if (!data) throw new Error(`Attribute ID ${id} not found in reference data`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export enum CommonAttribute {
|
||||
// Structure
|
||||
StructureHitpoints = 9,
|
||||
CargoCapacity = 38,
|
||||
DroneCapacity = 283,
|
||||
DroneBandwidth = 1271,
|
||||
Mass = 4,
|
||||
Volume = 161,
|
||||
InertiaModifier = 70,
|
||||
StructureEMResistance = 113,
|
||||
StructureThermalResistance = 110,
|
||||
StructureKineticResistance = 109,
|
||||
StructureExplosiveResistance = 111,
|
||||
|
||||
// Armor
|
||||
ArmorHitpoints = 265,
|
||||
ArmorEMResistance = 267,
|
||||
ArmorThermalResistance = 270,
|
||||
ArmorKineticResistance = 269,
|
||||
ArmorExplosiveResistance = 268,
|
||||
|
||||
// Shield
|
||||
ShieldCapacity = 263,
|
||||
ShieldRechargeTime = 479,
|
||||
ShieldEMResistance = 271,
|
||||
ShieldThermalResistance = 274,
|
||||
ShieldKineticResistance = 273,
|
||||
ShieldExplosiveResistance = 272,
|
||||
|
||||
// Electronic Resistances
|
||||
CapacitorWarfareResistance = 2045,
|
||||
StasisWebifierResistance = 2115,
|
||||
WeaponDisruptionResistance = 2113,
|
||||
|
||||
// Capacitor
|
||||
CapacitorCapacity = 482,
|
||||
CapacitorRechargeTime = 55,
|
||||
|
||||
// Targeting
|
||||
MaxTargetRange = 76,
|
||||
MaxLockedTargets = 192,
|
||||
SignatureRadius = 552,
|
||||
ScanResolution = 564,
|
||||
RadarSensorStrength = 208,
|
||||
MagnetometricSensorStrength = 210,
|
||||
GravimetricSensorStrength = 211,
|
||||
LadarSensorStrength = 209,
|
||||
|
||||
// Jump Drive Systems
|
||||
HasJumpDrive = 861,
|
||||
JumpDriveCapacitorNeed = 898,
|
||||
MaxJumpRange = 867,
|
||||
JumpDriveFuelNeed = 866,
|
||||
JumpDriveConsumptionAmount = 868,
|
||||
FuelBayCapacity = 1549,
|
||||
ConduitJumpConsumptionAmount = 3131,
|
||||
COnduitJumpPassengerCapacity = 3133,
|
||||
|
||||
// Propulsion
|
||||
MaxVelocity = 37,
|
||||
WarpSpeed = 600,
|
||||
|
||||
// FITTING
|
||||
|
||||
// Slots
|
||||
HighSlots = 14,
|
||||
MediumSlots = 13,
|
||||
LowSlots = 12,
|
||||
|
||||
// Stats
|
||||
PowergridOutput = 11,
|
||||
CPUOutput = 48,
|
||||
TurretHardpoints = 102,
|
||||
LauncherHardpoints = 101,
|
||||
|
||||
// Rigging
|
||||
RigSlots = 1137,
|
||||
RigSize = 1547,
|
||||
Calibration = 1132,
|
||||
|
||||
// Module
|
||||
CPUUsage = 50,
|
||||
PowergridUsage = 30,
|
||||
ActivationCost = 6,
|
||||
|
||||
// EWAR
|
||||
MaxVelocityBonus = 20,
|
||||
WarpScrambleStrength = 105,
|
||||
WarpDisruptionStrength = 2425,
|
||||
WarpDisruptionRange = 103,
|
||||
|
||||
// Weapon
|
||||
DamageMultiplier = 64,
|
||||
AccuracyFalloff = 158,
|
||||
OptimalRange = 54,
|
||||
RateOfFire = 51,
|
||||
TrackingSpeed = 160,
|
||||
ReloadTime = 1795,
|
||||
ActivationTime = 73,
|
||||
UsedWithCharge1 = 604,
|
||||
UsedWithCharge2 = 605,
|
||||
ChargeSize = 128,
|
||||
}
|
||||
105
packages/lib/src/eve/models/blueprint.ts
Normal file
105
packages/lib/src/eve/models/blueprint.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { ActivityType, type TypeIDQuantity } from './shared-types';
|
||||
import type { Type } from './type';
|
||||
import { getType } from './type';
|
||||
import { dataSets, loadModels } from './loadModels';
|
||||
|
||||
export interface Activity {
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface ManufacturingActivity extends Activity {
|
||||
time: number;
|
||||
materials: { [type_id: string]: TypeIDQuantity };
|
||||
products: { [type_id: string]: TypeIDQuantity };
|
||||
}
|
||||
|
||||
export interface InventionActivity extends Activity {
|
||||
time: number;
|
||||
materials: { [type_id: string]: TypeIDQuantity };
|
||||
products: { [type_id: string]: TypeIDQuantity };
|
||||
skills: { [skill_type_id: string]: number }; // skill_type_id : level
|
||||
}
|
||||
|
||||
export interface TypeQuantity {
|
||||
type: Type;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface Blueprint {
|
||||
readonly blueprint_type_id: number;
|
||||
readonly max_production_limit: number;
|
||||
readonly activities: {
|
||||
[ActivityType.MANUFACTURING]?: ManufacturingActivity;
|
||||
[ActivityType.RESEARCH_MATERIAL]?: Activity;
|
||||
[ActivityType.RESEARCH_TIME]?: Activity;
|
||||
[ActivityType.COPYING]?: Activity;
|
||||
[ActivityType.INVENTION]?: InventionActivity;
|
||||
};
|
||||
}
|
||||
|
||||
export function getBlueprint(blueprint_type_id: number) {
|
||||
if (!dataSets.loaded) loadModels();
|
||||
const data = dataSets.blueprints[String(blueprint_type_id)];
|
||||
if (!data) throw new Error(`Blueprint Type ID ${blueprint_type_id} not found in reference data`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export function getManufacturingMaterials(blueprint: Blueprint) {
|
||||
const manufacturing = blueprint.activities[ActivityType.MANUFACTURING];
|
||||
if (!manufacturing) return [];
|
||||
|
||||
return Promise.all(
|
||||
Object.entries(manufacturing.materials).map(([type_id, { quantity }]) => ({
|
||||
type: getType(parseInt(type_id)),
|
||||
quantity,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function getManufacturingProducts(blueprint: Blueprint) {
|
||||
const manufacturing = blueprint.activities[ActivityType.MANUFACTURING];
|
||||
if (!manufacturing) return [];
|
||||
|
||||
return Promise.all(
|
||||
Object.entries(manufacturing.products).map(([type_id, { quantity }]) => ({
|
||||
type: getType(parseInt(type_id)),
|
||||
quantity,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function getInventionMaterials(blueprint: Blueprint) {
|
||||
const invention = blueprint.activities[ActivityType.INVENTION];
|
||||
if (!invention) return [];
|
||||
|
||||
return Promise.all(
|
||||
Object.entries(invention.materials).map(([type_id, { quantity }]) => ({
|
||||
type: getType(parseInt(type_id)),
|
||||
quantity,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function getInventionProducts(blueprint: Blueprint) {
|
||||
const invention = blueprint.activities[ActivityType.INVENTION];
|
||||
if (!invention) return [];
|
||||
|
||||
return Promise.all(
|
||||
Object.entries(invention.products).map(([type_id, { quantity }]) => ({
|
||||
type: getType(parseInt(type_id)),
|
||||
quantity,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function getInventionSkills(blueprint: Blueprint) {
|
||||
const invention = blueprint.activities[ActivityType.INVENTION];
|
||||
if (!invention) return [];
|
||||
|
||||
return Promise.all(
|
||||
Object.entries(invention.skills).map(([skill_type_id, level]) => ({
|
||||
type: getType(parseInt(skill_type_id)),
|
||||
level,
|
||||
})),
|
||||
);
|
||||
}
|
||||
35
packages/lib/src/eve/models/category.ts
Normal file
35
packages/lib/src/eve/models/category.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { LocalizedString } from './shared-types';
|
||||
import { dataSets, loadModels } from './loadModels';
|
||||
|
||||
export enum CommonCategory {
|
||||
CARGO = 5,
|
||||
SHIP = 6,
|
||||
MODULE = 7,
|
||||
CHARGE = 8,
|
||||
BLUEPRINT = 9,
|
||||
SKILL = 16,
|
||||
DRONE = 18,
|
||||
IMPLANT = 20,
|
||||
APPAREL = 30,
|
||||
DEPLOYABLE = 22,
|
||||
REACTION = 24,
|
||||
SUBSYSTEM = 32,
|
||||
STRUCTURE = 65,
|
||||
STRUCTURE_MODULE = 66,
|
||||
FIGHTER = 87,
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
readonly category_id: number;
|
||||
readonly name: LocalizedString;
|
||||
readonly published: boolean;
|
||||
readonly group_ids: number[];
|
||||
readonly icon_id?: number;
|
||||
}
|
||||
|
||||
export function getCategory(category_id: number) {
|
||||
if (!dataSets.loaded) loadModels();
|
||||
const data = dataSets.categories[String(category_id)];
|
||||
if (!data) throw new Error(`Category ID ${category_id} not found in reference data`);
|
||||
return data;
|
||||
}
|
||||
65
packages/lib/src/eve/models/effect.ts
Normal file
65
packages/lib/src/eve/models/effect.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { getAttribute } from './attribute';
|
||||
import type { LocalizedString } from './shared-types';
|
||||
import { dataSets, loadModels } from './loadModels';
|
||||
|
||||
interface Modifier {
|
||||
domain: number;
|
||||
func: number;
|
||||
group_id?: number;
|
||||
modified_attribute_id: number;
|
||||
modifying_attribute_id: number;
|
||||
skill_type_id?: number;
|
||||
operator: number;
|
||||
}
|
||||
|
||||
export interface Effect {
|
||||
readonly effect_id: number;
|
||||
readonly disallow_auto_repeat: boolean;
|
||||
readonly discharge_attribute_id?: number;
|
||||
readonly distribution?: number;
|
||||
readonly duration_attribute_id?: number;
|
||||
readonly effect_category: number;
|
||||
readonly effect_name: string;
|
||||
readonly electronic_chance: boolean;
|
||||
readonly falloff_attribute_id?: number;
|
||||
readonly guid: string;
|
||||
readonly is_assistance: boolean;
|
||||
readonly is_offensive: boolean;
|
||||
readonly is_warp_safe: boolean;
|
||||
readonly propulsion_chance: boolean;
|
||||
readonly published: boolean;
|
||||
readonly range_attribute_id?: number;
|
||||
readonly range_chance: boolean;
|
||||
readonly modifiers: Modifier[];
|
||||
readonly tracking_speed_attribute_id?: number;
|
||||
readonly description: LocalizedString;
|
||||
readonly display_name: LocalizedString;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export function getEffect(effect_id: number) {
|
||||
if (!dataSets.loaded) loadModels();
|
||||
const data = dataSets.dogma_effects[String(effect_id)];
|
||||
if (!data) throw new Error(`Effect ID ${effect_id} not found in reference data`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export function getDischargeAttribute(effect: Effect) {
|
||||
return effect.discharge_attribute_id && getAttribute(effect.discharge_attribute_id);
|
||||
}
|
||||
|
||||
export function getFalloffAttribute(effect: Effect) {
|
||||
return effect.falloff_attribute_id && getAttribute(effect.falloff_attribute_id);
|
||||
}
|
||||
|
||||
export function getDurationAttribute(effect: Effect) {
|
||||
return effect.duration_attribute_id && getAttribute(effect.duration_attribute_id);
|
||||
}
|
||||
|
||||
export function getRangeAttribute(effect: Effect) {
|
||||
return effect.range_attribute_id && getAttribute(effect.range_attribute_id);
|
||||
}
|
||||
|
||||
export function getTrackingSpeedAttribute(effect: Effect) {
|
||||
return effect.tracking_speed_attribute_id && getAttribute(effect.tracking_speed_attribute_id);
|
||||
}
|
||||
29
packages/lib/src/eve/models/group.ts
Normal file
29
packages/lib/src/eve/models/group.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { LocalizedString } from './shared-types';
|
||||
import { dataSets, loadModels } from './loadModels';
|
||||
|
||||
export interface Group {
|
||||
readonly group_id: number;
|
||||
readonly category_id: number;
|
||||
readonly name: LocalizedString;
|
||||
readonly published: boolean;
|
||||
readonly icon_id?: number;
|
||||
readonly anchorable: boolean;
|
||||
readonly anchored: boolean;
|
||||
readonly fittable_non_singleton: boolean;
|
||||
readonly use_base_price: boolean;
|
||||
readonly type_ids?: number[];
|
||||
}
|
||||
export function getGroup(group_id: number) {
|
||||
if (!dataSets.loaded) loadModels();
|
||||
const data = dataSets.groups[String(group_id)];
|
||||
if (!data) throw new Error(`Group ID ${group_id} not found in reference data`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export function groupEveRefLink(group_id: number) {
|
||||
return `https://everef.net/groups/${group_id}`;
|
||||
}
|
||||
|
||||
export function renderGroupEveRefLink(group: Group, locale: string = 'en') {
|
||||
return `[${group.name[locale] ?? group.name.en}](${groupEveRefLink(group.group_id)})`;
|
||||
}
|
||||
41
packages/lib/src/eve/models/icon.ts
Normal file
41
packages/lib/src/eve/models/icon.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { dataSets, loadModels } from './loadModels';
|
||||
|
||||
export enum IconSize {
|
||||
SIZE_32 = 32,
|
||||
SIZE_64 = 64,
|
||||
SIZE_128 = 128,
|
||||
SIZE_256 = 256,
|
||||
SIZE_512 = 512,
|
||||
}
|
||||
|
||||
export interface Icon {
|
||||
readonly icon_id: number;
|
||||
readonly description: string;
|
||||
readonly file: string;
|
||||
}
|
||||
|
||||
export function getIcon(icon_id: number) {
|
||||
if (!dataSets.loaded) loadModels();
|
||||
const data = dataSets.icons[String(icon_id)];
|
||||
if (!data) throw new Error(`Icon ID ${icon_id} not found in reference data`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export function getIconUrl(
|
||||
icon_id: Icon,
|
||||
{
|
||||
size = IconSize.SIZE_64,
|
||||
isBp = false,
|
||||
isBpc = false,
|
||||
}: {
|
||||
size?: IconSize;
|
||||
isBp?: boolean;
|
||||
isBpc?: boolean;
|
||||
} = {},
|
||||
): string {
|
||||
return `https://images.evetech.net/types/${icon_id}/icon${
|
||||
isBp ? '/bp'
|
||||
: isBpc ? '/bpc'
|
||||
: ''
|
||||
}?size=${size}`;
|
||||
}
|
||||
14
packages/lib/src/eve/models/index.ts
Normal file
14
packages/lib/src/eve/models/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export * from './attribute';
|
||||
export * from './blueprint';
|
||||
export * from './category';
|
||||
export * from './effect';
|
||||
export * from './group';
|
||||
export * from './icon';
|
||||
export * from './market-group';
|
||||
export * from './meta-group';
|
||||
export * from './region';
|
||||
export * from './schematic';
|
||||
export * from './skill';
|
||||
export * from './solar-system';
|
||||
export * from './type';
|
||||
export * from './unit';
|
||||
53
packages/lib/src/eve/models/loadModels.ts
Normal file
53
packages/lib/src/eve/models/loadModels.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import fs from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { Unit } from './unit';
|
||||
import type { SolarSystem } from './solar-system';
|
||||
import type { Attribute } from './attribute';
|
||||
import type { Blueprint } from './blueprint';
|
||||
import type { Category } from './category';
|
||||
import type { Effect } from './effect';
|
||||
import type { Group } from './group';
|
||||
import type { Icon } from './icon';
|
||||
import type { MarketGroup } from './market-group';
|
||||
import type { MetaGroup } from './meta-group';
|
||||
import type { Region } from './region';
|
||||
import type { Schematic } from './schematic';
|
||||
import type { Skill } from './skill';
|
||||
import type { Type } from './type';
|
||||
|
||||
const dataSets = {
|
||||
loaded: false,
|
||||
dogma_attributes: {} as Record<string, Attribute>,
|
||||
blueprints: {} as Record<string, Blueprint>,
|
||||
categories: {} as Record<string, Category>,
|
||||
dogma_effects: {} as Record<string, Effect>,
|
||||
groups: {} as Record<string, Group>,
|
||||
icons: {} as Record<string, Icon>,
|
||||
market_groups: {} as Record<string, MarketGroup>,
|
||||
meta_groups: {} as Record<string, MetaGroup>,
|
||||
regions: {} as Record<string, Region>,
|
||||
schematics: {} as Record<string, Schematic>,
|
||||
skills: {} as Record<string, Skill>,
|
||||
solar_systems: {} as Record<string, SolarSystem>,
|
||||
types: {} as Record<string, Type>,
|
||||
units: {} as Record<string, Unit>,
|
||||
};
|
||||
export async function loadModels() {
|
||||
dataSets.dogma_attributes = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/dogma_attributes.json')).toString());
|
||||
dataSets.blueprints = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/blueprints.json')).toString());
|
||||
dataSets.categories = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/categories.json')).toString());
|
||||
dataSets.dogma_effects = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/dogma_effects.json')).toString());
|
||||
dataSets.groups = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/groups.json')).toString());
|
||||
dataSets.icons = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/icons.json')).toString());
|
||||
dataSets.market_groups = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/market_groups.json')).toString());
|
||||
dataSets.meta_groups = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/meta_groups.json')).toString());
|
||||
dataSets.regions = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/regions.json')).toString());
|
||||
dataSets.schematics = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/schematics.json')).toString());
|
||||
dataSets.skills = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/skills.json')).toString());
|
||||
dataSets.solar_systems = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/solar_systems.json')).toString());
|
||||
dataSets.types = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/types.json')).toString());
|
||||
dataSets.units = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/units.json')).toString());
|
||||
dataSets.loaded = true;
|
||||
}
|
||||
|
||||
export { dataSets };
|
||||
24
packages/lib/src/eve/models/market-group.ts
Normal file
24
packages/lib/src/eve/models/market-group.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { LocalizedString } from './shared-types';
|
||||
import { dataSets, loadModels } from './loadModels';
|
||||
|
||||
export interface MarketGroup {
|
||||
readonly market_group_id: number;
|
||||
readonly parent_group_id: number;
|
||||
readonly name: LocalizedString;
|
||||
readonly description: LocalizedString;
|
||||
readonly child_market_group_ids: number[];
|
||||
readonly icon_id: number;
|
||||
readonly has_types: boolean;
|
||||
}
|
||||
|
||||
export function getMarketGroup(market_group_id: number) {
|
||||
if (!dataSets.loaded) loadModels();
|
||||
const data = dataSets.market_groups[String(market_group_id)];
|
||||
if (!data) throw new Error(`Market group ID ${market_group_id} not found in reference data`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getAllChildMarketGroups(marketGroup: MarketGroup): Promise<MarketGroup[]> {
|
||||
const children = await Promise.all(marketGroup.child_market_group_ids.map((id) => getMarketGroup(id)));
|
||||
return children.concat(...(await Promise.all(children.map((child) => getAllChildMarketGroups(child)))));
|
||||
}
|
||||
17
packages/lib/src/eve/models/meta-group.ts
Normal file
17
packages/lib/src/eve/models/meta-group.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { LocalizedString } from './shared-types';
|
||||
import { dataSets, loadModels } from './loadModels';
|
||||
|
||||
export interface MetaGroup {
|
||||
readonly meta_group_id: number;
|
||||
readonly name: LocalizedString;
|
||||
readonly type_ids: number[];
|
||||
readonly icon_id?: number;
|
||||
readonly icon_suffix?: string;
|
||||
}
|
||||
|
||||
export function getMetaGroup(meta_group_id: number) {
|
||||
if (!dataSets.loaded) loadModels();
|
||||
const data = dataSets.meta_groups[String(meta_group_id)];
|
||||
if (!data) throw new Error(`Meta group ID ${meta_group_id} not found in reference data`);
|
||||
return data;
|
||||
}
|
||||
24
packages/lib/src/eve/models/region.ts
Normal file
24
packages/lib/src/eve/models/region.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { LocalizedString, Position } from './shared-types';
|
||||
import { dataSets, loadModels } from './loadModels';
|
||||
|
||||
export interface Region {
|
||||
readonly region_id: number;
|
||||
readonly center: Position;
|
||||
readonly description_id: number;
|
||||
readonly faction_id: number;
|
||||
readonly max: Position;
|
||||
readonly min: Position;
|
||||
readonly name_id: number;
|
||||
readonly wormhole_class_id?: number;
|
||||
readonly nebula_id?: number;
|
||||
readonly universe_id: string;
|
||||
readonly description: LocalizedString;
|
||||
readonly name: LocalizedString;
|
||||
}
|
||||
|
||||
export function getRegion(region_id: number) {
|
||||
if (!dataSets.loaded) loadModels();
|
||||
const data = dataSets.regions[String(region_id)];
|
||||
if (!data) throw new Error(`Region ID ${region_id} not found in reference data`);
|
||||
return data;
|
||||
}
|
||||
37
packages/lib/src/eve/models/schematic.ts
Normal file
37
packages/lib/src/eve/models/schematic.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { LocalizedString, TypeIDQuantity } from './shared-types';
|
||||
import { getType } from './type';
|
||||
import { dataSets, loadModels } from './loadModels';
|
||||
|
||||
export interface Schematic {
|
||||
readonly schematic_id: number;
|
||||
readonly cycle_time: number;
|
||||
readonly name: LocalizedString;
|
||||
readonly materials: { [type_id: string]: TypeIDQuantity };
|
||||
readonly products: { [type_id: string]: TypeIDQuantity };
|
||||
readonly pin_type_ids: number[];
|
||||
}
|
||||
|
||||
export function getSchematic(schematic_id: number) {
|
||||
if (!dataSets.loaded) loadModels();
|
||||
const data = dataSets.schematics[String(schematic_id)];
|
||||
if (!data) throw new Error(`Schematic ID ${schematic_id} not found in reference data`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export function getMaterialQuantities(schematic: Schematic) {
|
||||
return Object.entries(schematic.materials).map(([type_id, { quantity }]) => ({
|
||||
type: getType(Number(type_id)),
|
||||
quantity,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getProductQuantities(schematic: Schematic) {
|
||||
return Object.entries(schematic.products).map(([type_id, { quantity }]) => ({
|
||||
type: getType(Number(type_id)),
|
||||
quantity,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getPinTypes(schematic: Schematic) {
|
||||
return schematic.pin_type_ids.map(getType);
|
||||
}
|
||||
56
packages/lib/src/eve/models/shared-types.ts
Normal file
56
packages/lib/src/eve/models/shared-types.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Type } from './type';
|
||||
|
||||
export interface LocalizedString {
|
||||
de?: string;
|
||||
en?: string;
|
||||
es?: string;
|
||||
fr?: string;
|
||||
ja?: string;
|
||||
ko?: string;
|
||||
ru?: string;
|
||||
zh?: string;
|
||||
}
|
||||
|
||||
export interface TypeIDQuantity {
|
||||
type_id: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface TypeQuantity {
|
||||
type: Type;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface AttributeIDValue {
|
||||
attribute_id: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface EffectIDDefault {
|
||||
effect_id: number;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export interface MaterialIDQuantity {
|
||||
material_type_id: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface BlueprintTypeIDActivity {
|
||||
blueprint_type_id: number;
|
||||
blueprint_activity: ActivityType;
|
||||
}
|
||||
|
||||
export enum ActivityType {
|
||||
MANUFACTURING = 'manufacturing',
|
||||
RESEARCH_MATERIAL = 'research_material',
|
||||
RESEARCH_TIME = 'research_time',
|
||||
COPYING = 'copying',
|
||||
INVENTION = 'invention',
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
47
packages/lib/src/eve/models/skill.ts
Normal file
47
packages/lib/src/eve/models/skill.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { type Attribute, getAttribute } from './attribute';
|
||||
import { dataSets, loadModels } from './loadModels';
|
||||
|
||||
export interface Skill {
|
||||
readonly type_id: number;
|
||||
readonly primary_dogma_attribute_id: number;
|
||||
readonly secondary_dogma_attribute_id: number;
|
||||
readonly primary_character_attribute_id: number;
|
||||
readonly secondary_character_attribute_id: number;
|
||||
readonly training_time_multiplier: number;
|
||||
readonly required_skills?: { [skill_type_id: string]: number }; // skill_type_id : level
|
||||
}
|
||||
|
||||
export function getSkill(type_id: number) {
|
||||
if (!dataSets.loaded) loadModels();
|
||||
const data = dataSets.skills[String(type_id)];
|
||||
if (!data) throw new Error(`Skill ID ${type_id} not found in reference data`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export function getPrimaryDogmaAttribute(skill: Skill) {
|
||||
return getAttribute(skill.primary_dogma_attribute_id);
|
||||
}
|
||||
|
||||
export function getSecondaryDogmaAttribute(skill: Skill) {
|
||||
return getAttribute(skill.secondary_dogma_attribute_id);
|
||||
}
|
||||
|
||||
export function getPrimaryCharacterAttribute(skill: Skill) {
|
||||
return getAttribute(skill.primary_character_attribute_id);
|
||||
}
|
||||
|
||||
export function getSecondaryCharacterAttribute(skill: Skill) {
|
||||
return getAttribute(skill.secondary_character_attribute_id);
|
||||
}
|
||||
|
||||
export function getPrerequisites(skill: Skill): { skill: Skill; level: number }[] {
|
||||
if (!skill.required_skills) return [];
|
||||
return Object.entries(skill.required_skills).map(([skill_type_id, level]) => ({
|
||||
skill: getSkill(parseInt(skill_type_id)),
|
||||
level,
|
||||
}));
|
||||
}
|
||||
|
||||
export function skillpointsAtLevel(skill: Skill, level: number): number {
|
||||
return Math.pow(2, 2.5 * (level - 1)) * 250 * skill.training_time_multiplier;
|
||||
}
|
||||
42
packages/lib/src/eve/models/solar-system.ts
Normal file
42
packages/lib/src/eve/models/solar-system.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { dataSets, loadModels } from './loadModels';
|
||||
|
||||
export interface SolarSystem {
|
||||
readonly regionID: number;
|
||||
readonly constellationID: number;
|
||||
readonly solarSystemID: number;
|
||||
readonly solarSystemName: string;
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly z: number;
|
||||
readonly xMin: number;
|
||||
readonly xMax: number;
|
||||
readonly yMin: number;
|
||||
readonly yMax: number;
|
||||
readonly zMin: number;
|
||||
readonly zMax: number;
|
||||
readonly luminosity: number;
|
||||
readonly border: boolean;
|
||||
readonly fringe: boolean;
|
||||
readonly corridor: boolean;
|
||||
readonly hub: boolean;
|
||||
readonly international: boolean;
|
||||
readonly regional: boolean;
|
||||
readonly security: number;
|
||||
readonly factionID: number;
|
||||
readonly radius: number;
|
||||
readonly sunTypeID: number;
|
||||
readonly securityClass: string;
|
||||
}
|
||||
|
||||
export function getSolarSystem(solarSystemID: number): SolarSystem {
|
||||
if (!dataSets.loaded) loadModels();
|
||||
const data = dataSets.solar_systems[String(solarSystemID)] as any;
|
||||
if (!data) throw new Error(`Solar System ID ${solarSystemID} not found in reference data`);
|
||||
return {
|
||||
...data,
|
||||
security: parseFloat(data.security),
|
||||
radius: parseFloat(data.radius),
|
||||
sunTypeID: parseInt(data.sun_type_id, 10),
|
||||
securityClass: data.security_class ?? 'nullsec', // Default to 'nullsec' if security_class is not present
|
||||
};
|
||||
}
|
||||
197
packages/lib/src/eve/models/type.ts
Normal file
197
packages/lib/src/eve/models/type.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type { AttributeIDValue, BlueprintTypeIDActivity, EffectIDDefault, LocalizedString, MaterialIDQuantity } from './shared-types';
|
||||
import { IconSize } from './icon';
|
||||
import { getUnit, type Unit } from './unit';
|
||||
import { CommonAttribute, getAttribute } from './attribute';
|
||||
import { getGroup } from './group';
|
||||
import { getMetaGroup } from './meta-group';
|
||||
import { dataSets, loadModels } from './loadModels';
|
||||
|
||||
interface Masteries {
|
||||
'0': number[];
|
||||
'1': number[];
|
||||
'2': number[];
|
||||
'3': number[];
|
||||
'4': number[];
|
||||
}
|
||||
|
||||
interface Bonus {
|
||||
bonus: number;
|
||||
bonus_text: LocalizedString;
|
||||
importance: number;
|
||||
unit_id: number;
|
||||
}
|
||||
|
||||
interface Traits {
|
||||
misc_bonuses: { [level: string]: Bonus };
|
||||
role_bonuses: { [level: string]: Bonus };
|
||||
types: { [skill_type_id: string]: { [order: string]: Bonus } };
|
||||
}
|
||||
|
||||
export interface Type {
|
||||
readonly type_id: number;
|
||||
readonly name: LocalizedString;
|
||||
readonly description: LocalizedString;
|
||||
readonly published: boolean;
|
||||
readonly group_id?: number;
|
||||
readonly base_price?: number;
|
||||
readonly capacity?: number;
|
||||
readonly faction_id?: number;
|
||||
readonly graphic_id?: number;
|
||||
readonly market_group_id?: number;
|
||||
readonly mass?: number;
|
||||
readonly masteries?: Masteries;
|
||||
readonly meta_group_id?: number;
|
||||
readonly portion_size?: number;
|
||||
readonly race_id?: number;
|
||||
readonly radius?: number;
|
||||
readonly sof_faction_name?: string;
|
||||
readonly sound_id?: number;
|
||||
readonly traits?: Traits;
|
||||
readonly volume?: number;
|
||||
readonly dogma_attributes?: {
|
||||
[attribute_id: string]: AttributeIDValue;
|
||||
};
|
||||
readonly dogma_effects?: { [effect_id: string]: EffectIDDefault };
|
||||
readonly packaged_volume?: number;
|
||||
readonly type_materials?: { [type_id: string]: MaterialIDQuantity };
|
||||
readonly required_skills?: { [skill_type_id: string]: number }; // skill_type_id : level
|
||||
readonly type_variations?: { [meta_group_id: string]: number[] }; // meta_group_id : type_ids[]
|
||||
readonly produced_by_blueprints?: {
|
||||
[blueprint_type_id: string]: BlueprintTypeIDActivity;
|
||||
}; // blueprint_type_id : blueprint_activity
|
||||
readonly buildable_pin_type_ids?: number[];
|
||||
readonly is_ore?: boolean;
|
||||
readonly ore_variations?: { [variant: string]: number }; // variant : type_id
|
||||
readonly produced_by_schematic_ids?: number[];
|
||||
readonly used_by_schematic_ids?: number[];
|
||||
readonly is_blueprint?: boolean;
|
||||
}
|
||||
|
||||
export function getType(type_id: number) {
|
||||
if (!dataSets.loaded) loadModels();
|
||||
const data = dataSets.types[String(type_id)];
|
||||
if (!data) throw new Error(`Type ID ${type_id} not found in reference data`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export function getTypeIconUrl(type: Type, size: IconSize = IconSize.SIZE_64) {
|
||||
return `https://images.evetech.net/types/${type.type_id}/icon${type.is_blueprint ? '/bp' : ''}?size=${size}`;
|
||||
}
|
||||
|
||||
export function getSkillBonuses(type: Type): {
|
||||
skill: Type;
|
||||
bonuses: {
|
||||
bonus: number;
|
||||
bonus_text: LocalizedString;
|
||||
importance: number;
|
||||
unit: Unit;
|
||||
}[];
|
||||
}[] {
|
||||
if (!type.traits) return [];
|
||||
const skillBonuses: {
|
||||
skill: Type;
|
||||
bonuses: {
|
||||
bonus: number;
|
||||
bonus_text: LocalizedString;
|
||||
importance: number;
|
||||
unit: Unit;
|
||||
}[];
|
||||
}[] = [];
|
||||
for (const skill_type_id in type.traits.types) {
|
||||
skillBonuses.push({
|
||||
skill: getType(Number(skill_type_id)),
|
||||
bonuses: Object.keys(type.traits.types[skill_type_id]).map((order) => {
|
||||
const bonus = type.traits!.types[skill_type_id][order];
|
||||
return {
|
||||
bonus: bonus.bonus,
|
||||
bonus_text: bonus.bonus_text,
|
||||
importance: bonus.importance,
|
||||
unit: getUnit(bonus.unit_id),
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
return skillBonuses;
|
||||
}
|
||||
|
||||
export function getRoleBonuses(type: Type) {
|
||||
if (!type.traits || !type.traits.role_bonuses) return [];
|
||||
return Object.values(type.traits.role_bonuses).map((bonus) => ({
|
||||
bonus: bonus.bonus,
|
||||
bonus_text: bonus.bonus_text,
|
||||
importance: bonus.importance,
|
||||
unit: bonus.unit_id ? getUnit(bonus.unit_id) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export function eveRefLink(type_id: number) {
|
||||
return `https://everef.net/types/${type_id}`;
|
||||
}
|
||||
|
||||
export function renderTypeEveRefLink(type: Type, locale: string = 'en') {
|
||||
return `[${type.name[locale] ?? type.name.en}](${eveRefLink(type.type_id)})`;
|
||||
}
|
||||
|
||||
export function eveTycoonLink(type_id: number) {
|
||||
return `https://evetycoon.com/market/${type_id}`;
|
||||
}
|
||||
|
||||
export function getTypeAttributes(type: Type) {
|
||||
if (!type.dogma_attributes) return [];
|
||||
Object.keys(type.dogma_attributes).map((attribute_id) => ({
|
||||
attribute: getAttribute(Number(attribute_id)),
|
||||
value: type.dogma_attributes![attribute_id].value,
|
||||
}));
|
||||
}
|
||||
|
||||
export function typeHasAnyAttribute(type: Type, attribute_ids: CommonAttribute[]) {
|
||||
if (!type.dogma_attributes) return false;
|
||||
for (const attribute_id of attribute_ids) {
|
||||
if (type.dogma_attributes[attribute_id]) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getTypeSkills(type: Type) {
|
||||
if (!type.required_skills) return [];
|
||||
return Object.keys(type.required_skills).map((skill_type_id) => ({
|
||||
skill: getType(Number(skill_type_id)),
|
||||
level: type.required_skills![skill_type_id],
|
||||
}));
|
||||
}
|
||||
|
||||
export function typeGetAttribute(type: Type, attribute_id: number) {
|
||||
if (!type.dogma_attributes || !type.dogma_attributes[attribute_id]) return null;
|
||||
return {
|
||||
attribute: getAttribute(attribute_id),
|
||||
value: type.dogma_attributes[attribute_id].value,
|
||||
};
|
||||
}
|
||||
|
||||
export function getTypeBlueprints(type: Type) {
|
||||
if (!type.produced_by_blueprints) return [];
|
||||
return Object.values(type.produced_by_blueprints).map((blueprint) => ({
|
||||
blueprint: getType(blueprint.blueprint_type_id),
|
||||
activity: blueprint.blueprint_activity,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getTypeSchematics(type: Type) {
|
||||
return type.produced_by_schematic_ids?.map((schematic_id) => getType(schematic_id)) ?? [];
|
||||
}
|
||||
|
||||
export function getTypeGroup(type: Type) {
|
||||
if (!type.group_id) return null;
|
||||
return getGroup(type.group_id);
|
||||
}
|
||||
|
||||
export function getTypeVariants(type: Type) {
|
||||
return Object.entries(type.type_variations || {}).map(([meta_group_id, variant_ids]) => ({
|
||||
metaGroup: getMetaGroup(Number(meta_group_id)),
|
||||
types: variant_ids.map((type_id) => getType(type_id)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function typeHasAttributes(type: Type) {
|
||||
return type.dogma_attributes && Object.keys(type.dogma_attributes).length > 0;
|
||||
}
|
||||
406
packages/lib/src/eve/models/unit.test.ts
Normal file
406
packages/lib/src/eve/models/unit.test.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import { test, it, expect, mock, beforeEach } from 'bun:test';
|
||||
import { getUnit, renderUnit, isUnitInversePercentage, type Unit } from './unit';
|
||||
|
||||
test('unit.ts', () => {
|
||||
test('getUnit', () => {
|
||||
it('should return unit when found', async () => {
|
||||
const mockUnit: Unit = {
|
||||
unit_id: 123,
|
||||
display_name: 'Test Unit',
|
||||
description: { en: 'Test description' },
|
||||
name: { en: 'Test Name' },
|
||||
};
|
||||
|
||||
mock.module('@star-kitten/util/json-query.js', () => ({
|
||||
queryJsonObject: () => Promise.resolve(mockUnit),
|
||||
}));
|
||||
|
||||
const result = await getUnit(123);
|
||||
|
||||
expect(result).toEqual(mockUnit);
|
||||
});
|
||||
|
||||
it('should throw error when unit not found', async () => {
|
||||
mock.module('@star-kitten/util/json-query.js', () => ({
|
||||
queryJsonObject: () => Promise.resolve(null),
|
||||
}));
|
||||
|
||||
await expect(getUnit(999)).rejects.toThrow('Unit ID 999 not found in reference data');
|
||||
});
|
||||
});
|
||||
|
||||
test('renderUnit', () => {
|
||||
const mockUnit: Unit = {
|
||||
unit_id: 0,
|
||||
display_name: 'Test Unit',
|
||||
description: { en: 'Test description' },
|
||||
name: { en: 'Test Name' },
|
||||
};
|
||||
|
||||
test('inverse percentage units', () => {
|
||||
it('should render unit 108 as inverse percentage', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 108 };
|
||||
const result = await renderUnit(unit, 0.75);
|
||||
expect(result).toBe('0.25 Test Unit');
|
||||
});
|
||||
|
||||
it('should render unit 111 as inverse percentage', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 111 };
|
||||
const result = await renderUnit(unit, 0.3);
|
||||
expect(result).toBe('0.70 Test Unit');
|
||||
});
|
||||
|
||||
it('should handle missing display_name for inverse percentage', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 108, display_name: '' };
|
||||
const result = await renderUnit(unit, 0.5);
|
||||
expect(result).toBe('0.50 ');
|
||||
});
|
||||
});
|
||||
|
||||
test('time units', () => {
|
||||
it('should render unit 3 (seconds) using convertSecondsToTimeString', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 3 };
|
||||
mock.module('@star-kitten/util/text.js', () => ({
|
||||
convertSecondsToTimeString: (seconds: number) => {
|
||||
if (seconds === 330) return '5m 30s';
|
||||
return '0s';
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await renderUnit(unit, 330);
|
||||
|
||||
expect(result).toBe('5m 30s');
|
||||
});
|
||||
|
||||
it('should render unit 101 (milliseconds) using convertMillisecondsToTimeString', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 101 };
|
||||
mock.module('@star-kitten/util/text.js', () => ({
|
||||
convertMillisecondsToTimeString: (milliseconds: number) => {
|
||||
if (milliseconds === 2500) return '2.5s';
|
||||
return '0s';
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await renderUnit(unit, 2500);
|
||||
|
||||
expect(result).toBe('2.5s');
|
||||
});
|
||||
});
|
||||
|
||||
test('size class unit (117)', () => {
|
||||
it('should render size class 1 as Small', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 117 };
|
||||
const result = await renderUnit(unit, 1);
|
||||
expect(result).toBe('Small');
|
||||
});
|
||||
|
||||
it('should render size class 2 as Medium', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 117 };
|
||||
const result = await renderUnit(unit, 2);
|
||||
expect(result).toBe('Medium');
|
||||
});
|
||||
|
||||
it('should render size class 3 as Large', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 117 };
|
||||
const result = await renderUnit(unit, 3);
|
||||
expect(result).toBe('Large');
|
||||
});
|
||||
|
||||
it('should render size class 4 as X-Large', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 117 };
|
||||
const result = await renderUnit(unit, 4);
|
||||
expect(result).toBe('X-Large');
|
||||
});
|
||||
|
||||
it('should render unknown size class as Unknown', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 117 };
|
||||
const result = await renderUnit(unit, 99);
|
||||
expect(result).toBe('Unknown');
|
||||
});
|
||||
});
|
||||
|
||||
test('specialized units', () => {
|
||||
it('should render unit 141 (hardpoints) as string', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 141 };
|
||||
const result = await renderUnit(unit, 8);
|
||||
expect(result).toBe('8');
|
||||
});
|
||||
|
||||
it('should render unit 120 (calibration) with pts suffix', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 120 };
|
||||
const result = await renderUnit(unit, 400);
|
||||
expect(result).toBe('400 pts');
|
||||
});
|
||||
|
||||
it('should render unit 116 (typeID) using type lookup', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 116 };
|
||||
const mockType = {
|
||||
type_id: 12345,
|
||||
name: { en: 'Test Type' },
|
||||
description: { en: 'Test description' },
|
||||
published: true,
|
||||
};
|
||||
|
||||
mock.module('./type', () => ({
|
||||
getType: (type_id: number) => {
|
||||
if (type_id === 12345) return Promise.resolve(mockType);
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
renderTypeEveRefLink: (type: any, lang: string) => {
|
||||
if (type.type_id === 12345 && lang === 'en') return 'Type Link';
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await renderUnit(unit, 12345, 'en');
|
||||
|
||||
expect(result).toBe('Type Link');
|
||||
});
|
||||
|
||||
it('should render unit 116 (typeID) as Unknown when link is null', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 116 };
|
||||
const mockType = {
|
||||
type_id: 12345,
|
||||
name: { en: 'Test Type' },
|
||||
description: { en: 'Test description' },
|
||||
published: true,
|
||||
};
|
||||
|
||||
mock.module('./type', () => ({
|
||||
getType: (type_id: number) => {
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
renderTypeEveRefLink: (type: any, lang: string) => {
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await renderUnit(unit, 12345);
|
||||
|
||||
expect(result).toBe('Unknown');
|
||||
});
|
||||
|
||||
it('should render unit 115 (groupID) using group lookup', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 115 };
|
||||
const mockGroup = {
|
||||
group_id: 67890,
|
||||
name: { en: 'Test Group' },
|
||||
category_id: 1,
|
||||
published: true,
|
||||
anchorable: false,
|
||||
anchored: false,
|
||||
fittable_non_singleton: false,
|
||||
use_base_price: false,
|
||||
};
|
||||
|
||||
mock.module('./group', () => ({
|
||||
getGroup: () => Promise.resolve(mockGroup),
|
||||
renderGroupEveRefLink: (group: any, lang: string) => {
|
||||
if (group.group_id === 67890 && lang === 'fr') return 'Group Link';
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await renderUnit(unit, 67890, 'fr');
|
||||
|
||||
expect(result).toBe('Group Link');
|
||||
});
|
||||
|
||||
it('should render unit 115 (groupID) as Unknown when link is null', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 115 };
|
||||
const mockGroup = {
|
||||
group_id: 67890,
|
||||
name: { en: 'Test Group' },
|
||||
category_id: 1,
|
||||
published: true,
|
||||
anchorable: false,
|
||||
anchored: false,
|
||||
fittable_non_singleton: false,
|
||||
use_base_price: false,
|
||||
};
|
||||
|
||||
mock.module('./group', () => ({
|
||||
getGroup: (group_id: number) => {
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
renderGroupEveRefLink: (group: any, lang: string) => {
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await renderUnit(unit, 67890);
|
||||
|
||||
expect(result).toBe('Unknown');
|
||||
});
|
||||
});
|
||||
|
||||
test('physical units', () => {
|
||||
it('should render unit 10 (m/s)', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 10 };
|
||||
const result = await renderUnit(unit, 150);
|
||||
expect(result).toBe('150 m/s');
|
||||
});
|
||||
|
||||
it('should render unit 11 (m/s²)', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 11 };
|
||||
const result = await renderUnit(unit, 9.8);
|
||||
expect(result).toBe('9.8 m/s²');
|
||||
});
|
||||
|
||||
it('should render unit 9 (m³)', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 9 };
|
||||
const result = await renderUnit(unit, 1000);
|
||||
expect(result).toBe('1000 m³');
|
||||
});
|
||||
|
||||
it('should render unit 8 (m²)', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 8 };
|
||||
const result = await renderUnit(unit, 50);
|
||||
expect(result).toBe('50 m²');
|
||||
});
|
||||
|
||||
it('should render unit 12 (m⁻¹)', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 12 };
|
||||
const result = await renderUnit(unit, 0.1);
|
||||
expect(result).toBe('0.1 m⁻¹');
|
||||
});
|
||||
|
||||
it('should render unit 128 (Mbps)', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 128 };
|
||||
const result = await renderUnit(unit, 100);
|
||||
expect(result).toBe('100 Mbps');
|
||||
});
|
||||
});
|
||||
|
||||
test('default case', () => {
|
||||
it('should render unknown unit with value and display_name', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 999, display_name: 'Custom Unit' };
|
||||
const result = await renderUnit(unit, 42);
|
||||
expect(result).toBe('42 Custom Unit');
|
||||
});
|
||||
|
||||
it('should render unknown unit with empty display_name', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 999, display_name: '' };
|
||||
const result = await renderUnit(unit, 42);
|
||||
expect(result).toBe('42 ');
|
||||
});
|
||||
|
||||
it('should handle undefined display_name', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 999, display_name: undefined as any };
|
||||
const result = await renderUnit(unit, 42);
|
||||
expect(result).toBe('42 ');
|
||||
});
|
||||
});
|
||||
|
||||
test('edge cases', () => {
|
||||
it('should handle zero values', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 10 };
|
||||
const result = await renderUnit(unit, 0);
|
||||
expect(result).toBe('0 m/s');
|
||||
});
|
||||
|
||||
it('should handle negative values', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 120 };
|
||||
const result = await renderUnit(unit, -50);
|
||||
expect(result).toBe('-50 pts');
|
||||
});
|
||||
|
||||
it('should handle very large values', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 9 };
|
||||
const result = await renderUnit(unit, 1e10);
|
||||
expect(result).toBe('10000000000 m³');
|
||||
});
|
||||
|
||||
it('should handle decimal values for inverse percentage', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 108 };
|
||||
const result = await renderUnit(unit, 0.12345);
|
||||
expect(result).toBe('0.88 Test Unit');
|
||||
});
|
||||
|
||||
it('should default to "en" locale when not specified', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 116 };
|
||||
const mockType = {
|
||||
type_id: 12345,
|
||||
name: { en: 'Test Type' },
|
||||
description: { en: 'Test description' },
|
||||
published: true,
|
||||
};
|
||||
|
||||
mock.module('./type', () => ({
|
||||
getType: (type_id: number) => {
|
||||
if (type_id === 12345) return Promise.resolve(mockType);
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
renderTypeEveRefLink: (type: any, lang: string) => {
|
||||
if (type.type_id === 12345 && lang === 'en') return 'Type Link';
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await renderUnit(unit, 12345);
|
||||
expect(result).toBe('Type Link');
|
||||
});
|
||||
});
|
||||
|
||||
test('error handling', () => {
|
||||
it('should handle getType errors gracefully', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 116 };
|
||||
mock.module('./type', () => ({
|
||||
getType: (type_id: number) => {
|
||||
throw new Error('Type not found');
|
||||
},
|
||||
}));
|
||||
|
||||
await expect(renderUnit(unit, 12345)).rejects.toThrow('Type not found');
|
||||
});
|
||||
|
||||
it('should handle getGroup errors gracefully', async () => {
|
||||
const unit = { ...mockUnit, unit_id: 115 };
|
||||
mock.module('./group', () => ({
|
||||
getGroup: (group_id: number) => {
|
||||
throw new Error('Group not found');
|
||||
},
|
||||
}));
|
||||
|
||||
await expect(renderUnit(unit, 67890)).rejects.toThrow('Group not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('isUnitInversePercentage', () => {
|
||||
const mockUnit: Unit = {
|
||||
unit_id: 0,
|
||||
display_name: 'Test Unit',
|
||||
description: { en: 'Test description' },
|
||||
name: { en: 'Test Name' },
|
||||
};
|
||||
|
||||
it('should return true for unit_id 108', () => {
|
||||
const unit = { ...mockUnit, unit_id: 108 };
|
||||
expect(isUnitInversePercentage(unit)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for unit_id 111', () => {
|
||||
const unit = { ...mockUnit, unit_id: 111 };
|
||||
expect(isUnitInversePercentage(unit)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for other unit_ids', () => {
|
||||
const testCases = [0, 1, 3, 8, 9, 10, 11, 12, 101, 107, 109, 115, 116, 117, 120, 128, 141, 999];
|
||||
|
||||
testCases.forEach((unit_id) => {
|
||||
const unit = { ...mockUnit, unit_id };
|
||||
expect(isUnitInversePercentage(unit)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should use loose equality (==) not strict equality (===)', () => {
|
||||
// Test that the function uses == comparison by verifying it works with string unit_ids
|
||||
const unit108 = { ...mockUnit, unit_id: 108 as any };
|
||||
const unit111 = { ...mockUnit, unit_id: 111 as any };
|
||||
|
||||
expect(isUnitInversePercentage(unit108)).toBe(true);
|
||||
expect(isUnitInversePercentage(unit111)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
66
packages/lib/src/eve/models/unit.ts
Normal file
66
packages/lib/src/eve/models/unit.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { convertMillisecondsToTimeString, convertSecondsToTimeString } from '@/eve/utils/markdown.js';
|
||||
import { getGroup, renderGroupEveRefLink } from './group';
|
||||
import type { LocalizedString } from './shared-types';
|
||||
import { getType, renderTypeEveRefLink } from './type';
|
||||
import { dataSets, loadModels } from './loadModels';
|
||||
|
||||
const sizeMap = {
|
||||
1: 'Small',
|
||||
2: 'Medium',
|
||||
3: 'Large',
|
||||
4: 'X-Large',
|
||||
};
|
||||
|
||||
export interface Unit {
|
||||
readonly unit_id: number;
|
||||
readonly display_name: string;
|
||||
readonly description: LocalizedString;
|
||||
readonly name: LocalizedString;
|
||||
}
|
||||
|
||||
export function getUnit(unit_id: number) {
|
||||
if (!dataSets.loaded) loadModels();
|
||||
const unit = dataSets.units[String(unit_id)];
|
||||
if (!unit) throw new Error(`Unit ID ${unit_id} not found in reference data`);
|
||||
return unit;
|
||||
}
|
||||
|
||||
export function renderUnit(unit: Unit, value: number, locale: string = 'en'): string {
|
||||
switch (unit.unit_id) {
|
||||
case 108: // inverse percentage
|
||||
case 111: // Inverse percentage
|
||||
return [(1 - value).toFixed(2), unit.display_name ?? ''].join(' ');
|
||||
case 3: // seconds
|
||||
return `${convertSecondsToTimeString(value)}`;
|
||||
case 101: // milliseconds
|
||||
return `${convertMillisecondsToTimeString(value)}`;
|
||||
case 117: // size class
|
||||
return sizeMap[value] ?? 'Unknown';
|
||||
case 141: // hardpoints
|
||||
return value + '';
|
||||
case 120: // calibration
|
||||
return value + ' pts';
|
||||
case 116: // typeID
|
||||
return getType(value).name[locale] ?? 'Unknown';
|
||||
case 10: // m/s
|
||||
return `${value} m/s`;
|
||||
case 11: // meters per second squared
|
||||
return `${value} m/s²`;
|
||||
case 9: // cubic meters
|
||||
return `${value} m³`;
|
||||
case 8: // square meters
|
||||
return `${value} m²`;
|
||||
case 12: // reciprocal meters
|
||||
return `${value} m⁻¹`;
|
||||
case 128: // megabits per second
|
||||
return `${value} Mbps`;
|
||||
case 115: // groupID
|
||||
return renderGroupEveRefLink(getGroup(value), locale) ?? 'Unknown';
|
||||
default:
|
||||
return [value, unit.display_name ?? ''].join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
export function isUnitInversePercentage(unit: Unit) {
|
||||
return unit.unit_id == 108 || unit.unit_id == 111;
|
||||
}
|
||||
7
packages/lib/src/eve/ref/index.ts
Normal file
7
packages/lib/src/eve/ref/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import attributeOrders from '@data/hoboleaks/attributeorders.json';
|
||||
|
||||
export const attributeOrdering = {
|
||||
'11': attributeOrders['11'],
|
||||
'87': attributeOrders['87'],
|
||||
'default': attributeOrders.default,
|
||||
};
|
||||
25
packages/lib/src/eve/third-party/evetycoon.ts
vendored
Normal file
25
packages/lib/src/eve/third-party/evetycoon.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
const base_url = 'https://evetycoon.com/api/v1';
|
||||
|
||||
export interface Price {
|
||||
buyVolume: number;
|
||||
sellVolume: number;
|
||||
buyOrders: number;
|
||||
sellOrders: number;
|
||||
buyOutliers: number;
|
||||
sellOutliers: number;
|
||||
buyThreshold: number;
|
||||
sellThreshold: number;
|
||||
buyAvgFivePercent: number;
|
||||
sellAvgFivePercent: number;
|
||||
maxBuy: number;
|
||||
minSell: number;
|
||||
}
|
||||
|
||||
enum Region {
|
||||
TheForge = 10000002,
|
||||
}
|
||||
|
||||
export const fetchPrice = async (type_id: number, region_id: number = Region.TheForge): Promise<Price> => {
|
||||
const response = await fetch(`${base_url}/market/stats/${region_id}/${type_id}`);
|
||||
return (await response.json()) as Price;
|
||||
};
|
||||
2
packages/lib/src/eve/third-party/index.ts
vendored
Normal file
2
packages/lib/src/eve/third-party/index.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export * as evetycoon from './evetycoon';
|
||||
export * as janice from './janice';
|
||||
323
packages/lib/src/eve/third-party/janice.test.ts
vendored
Normal file
323
packages/lib/src/eve/third-party/janice.test.ts
vendored
Normal file
@@ -0,0 +1,323 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
|
||||
import {
|
||||
fetchPrice,
|
||||
fetchPrices,
|
||||
fetchAppraisal,
|
||||
appraiseItems,
|
||||
isPositiveNumber,
|
||||
isNonEmptyString,
|
||||
markets,
|
||||
AppraisalDesignation,
|
||||
AppraisalPricing,
|
||||
AppraisalPricingVariant,
|
||||
clearCache,
|
||||
} from './janice.ts';
|
||||
|
||||
// Mock fetch globally
|
||||
const originalFetch = global.fetch;
|
||||
let mockFetch: any;
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
mockFetch = mock(() => Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
}));
|
||||
global.fetch = mockFetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
// Clear cache between tests
|
||||
// Note: Since cache is internal to the module, we can't directly clear it
|
||||
// In a real scenario, you might want to expose a clearCache function
|
||||
});
|
||||
|
||||
describe('Validation Functions', () => {
|
||||
describe('isPositiveNumber', () => {
|
||||
it('should return true for positive numbers', () => {
|
||||
expect(isPositiveNumber(1)).toBe(true);
|
||||
expect(isPositiveNumber(100)).toBe(true);
|
||||
expect(isPositiveNumber(0.5)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-positive numbers', () => {
|
||||
expect(isPositiveNumber(0)).toBe(false);
|
||||
expect(isPositiveNumber(-1)).toBe(false);
|
||||
expect(isPositiveNumber(-0.5)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-numbers', () => {
|
||||
expect(isPositiveNumber('1')).toBe(false);
|
||||
expect(isPositiveNumber(null)).toBe(false);
|
||||
expect(isPositiveNumber(undefined)).toBe(false);
|
||||
expect(isPositiveNumber(NaN)).toBe(false);
|
||||
expect(isPositiveNumber(Infinity)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNonEmptyString', () => {
|
||||
it('should return true for non-empty strings', () => {
|
||||
expect(isNonEmptyString('test')).toBe(true);
|
||||
expect(isNonEmptyString(' a ')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for empty strings', () => {
|
||||
expect(isNonEmptyString('')).toBe(false);
|
||||
expect(isNonEmptyString(' ')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-strings', () => {
|
||||
expect(isNonEmptyString(123)).toBe(false);
|
||||
expect(isNonEmptyString(null)).toBe(false);
|
||||
expect(isNonEmptyString(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Constants', () => {
|
||||
it('should have correct markets array', () => {
|
||||
expect(markets).toHaveLength(8);
|
||||
expect(markets[0]).toEqual({ id: 2, name: 'Jita 4-4' });
|
||||
expect(markets).toContainEqual({ id: 117, name: 'Dodixie' });
|
||||
});
|
||||
|
||||
it('should have correct enum values', () => {
|
||||
expect(AppraisalDesignation.Appraisal).toBe(AppraisalDesignation.Appraisal);
|
||||
expect(AppraisalPricing.Buy).toBe(AppraisalPricing.Buy);
|
||||
expect(AppraisalPricingVariant.Immediate).toBe(AppraisalPricingVariant.Immediate);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchPrice', () => {
|
||||
const mockPricerItem = {
|
||||
date: '2024-01-01',
|
||||
market: { id: 2, name: 'Jita 4-4' },
|
||||
buyOrderCount: 10,
|
||||
buyVolume: 100,
|
||||
sellOrderCount: 5,
|
||||
sellVolume: 50,
|
||||
immediatePrices: {
|
||||
buyPrice: 100,
|
||||
splitPrice: 95,
|
||||
sellPrice: 110,
|
||||
buyPrice5DayMedian: 98,
|
||||
splitPrice5DayMedian: 93,
|
||||
sellPrice5DayMedian: 108,
|
||||
buyPrice30DayMedian: 97,
|
||||
splitPrice30DayMedian: 92,
|
||||
sellPrice30DayMedian: 107,
|
||||
},
|
||||
top5AveragePrices: {
|
||||
buyPrice: 99,
|
||||
splitPrice: 94,
|
||||
sellPrice: 109,
|
||||
buyPrice5DayMedian: 97,
|
||||
splitPrice5DayMedian: 92,
|
||||
sellPrice5DayMedian: 106,
|
||||
buyPrice30DayMedian: 96,
|
||||
splitPrice30DayMedian: 91,
|
||||
sellPrice30DayMedian: 105,
|
||||
},
|
||||
itemType: { eid: 34, name: 'Tritanium', volume: 0.01, packagedVolume: 0.01 },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockImplementation(() => Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockPricerItem),
|
||||
}));
|
||||
});
|
||||
|
||||
it('should fetch price successfully', async () => {
|
||||
const result = await fetchPrice(34, 2);
|
||||
expect(result).toEqual(mockPricerItem);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://janice.e-351.com/api/rest/v2/pricer/34?market=2',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for invalid type_id', async () => {
|
||||
await expect(fetchPrice(0)).rejects.toThrow('Invalid type_id: must be a positive number');
|
||||
await expect(fetchPrice(-1)).rejects.toThrow('Invalid type_id: must be a positive number');
|
||||
await expect(fetchPrice('34' as any)).rejects.toThrow('Invalid type_id: must be a positive number');
|
||||
});
|
||||
|
||||
it('should throw error for invalid market_id', async () => {
|
||||
await expect(fetchPrice(34, 0)).rejects.toThrow('Invalid market_id: must be a positive number');
|
||||
});
|
||||
|
||||
it('should handle API errors', async () => {
|
||||
mockFetch.mockImplementation(() => Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
}));
|
||||
|
||||
await expect(fetchPrice(34)).rejects.toThrow('API request failed: 404 Not Found');
|
||||
});
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
mockFetch.mockImplementation(() => Promise.reject(new Error('Network error')));
|
||||
|
||||
await expect(fetchPrice(34)).rejects.toThrow('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchPrices', () => {
|
||||
const mockPricerItems = [
|
||||
{
|
||||
date: '2024-01-01',
|
||||
market: { id: 2, name: 'Jita 4-4' },
|
||||
buyOrderCount: 10,
|
||||
buyVolume: 100,
|
||||
sellOrderCount: 5,
|
||||
sellVolume: 50,
|
||||
immediatePrices: {
|
||||
buyPrice: 100,
|
||||
splitPrice: 95,
|
||||
sellPrice: 110,
|
||||
buyPrice5DayMedian: 98,
|
||||
splitPrice5DayMedian: 93,
|
||||
sellPrice5DayMedian: 108,
|
||||
buyPrice30DayMedian: 97,
|
||||
splitPrice30DayMedian: 92,
|
||||
sellPrice30DayMedian: 107,
|
||||
},
|
||||
top5AveragePrices: {
|
||||
buyPrice: 99,
|
||||
splitPrice: 94,
|
||||
sellPrice: 109,
|
||||
buyPrice5DayMedian: 97,
|
||||
splitPrice5DayMedian: 92,
|
||||
sellPrice5DayMedian: 106,
|
||||
buyPrice30DayMedian: 96,
|
||||
splitPrice30DayMedian: 91,
|
||||
sellPrice30DayMedian: 105,
|
||||
},
|
||||
itemType: { eid: 34, name: 'Tritanium', volume: 0.01, packagedVolume: 0.01 },
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockImplementation(() => Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockPricerItems),
|
||||
}));
|
||||
});
|
||||
|
||||
it('should fetch prices successfully', async () => {
|
||||
const result = await fetchPrices([34, 35], 2);
|
||||
expect(result).toEqual(mockPricerItems);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://janice.e-351.com/api/rest/v2/pricer?market=2',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: '34\n35',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for invalid type_ids', async () => {
|
||||
await expect(fetchPrices([])).rejects.toThrow('Invalid type_ids: must be a non-empty array of positive numbers');
|
||||
await expect(fetchPrices([0, 1])).rejects.toThrow('Invalid type_ids: must be a non-empty array of positive numbers');
|
||||
await expect(fetchPrices(['34'] as any)).rejects.toThrow('Invalid type_ids: must be a non-empty array of positive numbers');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchAppraisal', () => {
|
||||
const mockAppraisal = {
|
||||
id: 123,
|
||||
created: '2024-01-01T00:00:00Z',
|
||||
expires: '2024-01-02T00:00:00Z',
|
||||
datasetTime: '2024-01-01T00:00:00Z',
|
||||
code: 'ABC123',
|
||||
designation: AppraisalDesignation.Appraisal,
|
||||
pricing: AppraisalPricing.Buy,
|
||||
pricingVariant: AppraisalPricingVariant.Immediate,
|
||||
pricePercentage: 1,
|
||||
isCompactized: true,
|
||||
failures: '',
|
||||
market: { id: 2, name: 'Jita 4-4' },
|
||||
totalVolume: 100,
|
||||
totalPackagedVolume: 100,
|
||||
effectivePrices: { totalBuyPrice: 1000, totalSplitPrice: 950, totalSellPrice: 1100 },
|
||||
immediatePrices: { totalBuyPrice: 1000, totalSplitPrice: 950, totalSellPrice: 1100 },
|
||||
top5AveragePrices: { totalBuyPrice: 990, totalSplitPrice: 940, totalSellPrice: 1090 },
|
||||
items: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockImplementation(() => Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockAppraisal),
|
||||
}));
|
||||
});
|
||||
|
||||
it('should fetch appraisal successfully', async () => {
|
||||
const result = await fetchAppraisal('ABC123');
|
||||
expect(result).toEqual(mockAppraisal);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://janice.e-351.com/api/rest/v2/appraisal/ABC123',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for invalid code', async () => {
|
||||
await expect(fetchAppraisal('')).rejects.toThrow('Invalid code: must be a non-empty string');
|
||||
await expect(fetchAppraisal(' ')).rejects.toThrow('Invalid code: must be a non-empty string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appraiseItems', () => {
|
||||
const mockAppraisal = {
|
||||
id: 123,
|
||||
created: '2024-01-01T00:00:00Z',
|
||||
expires: '2024-01-02T00:00:00Z',
|
||||
datasetTime: '2024-01-01T00:00:00Z',
|
||||
code: 'ABC123',
|
||||
designation: AppraisalDesignation.Appraisal,
|
||||
pricing: AppraisalPricing.Buy,
|
||||
pricingVariant: AppraisalPricingVariant.Immediate,
|
||||
pricePercentage: 1,
|
||||
isCompactized: true,
|
||||
failures: '',
|
||||
market: { id: 2, name: 'Jita 4-4' },
|
||||
totalVolume: 100,
|
||||
totalPackagedVolume: 100,
|
||||
effectivePrices: { totalBuyPrice: 1000, totalSplitPrice: 950, totalSellPrice: 1100 },
|
||||
immediatePrices: { totalBuyPrice: 1000, totalSplitPrice: 950, totalSellPrice: 1100 },
|
||||
top5AveragePrices: { totalBuyPrice: 990, totalSplitPrice: 940, totalSellPrice: 1090 },
|
||||
items: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockImplementation(() => Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockAppraisal),
|
||||
}));
|
||||
});
|
||||
|
||||
it('should appraise items successfully', async () => {
|
||||
const text = 'Tritanium 100\nPyerite 50';
|
||||
const result = await appraiseItems(text, 2);
|
||||
expect(result).toEqual(mockAppraisal);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://janice.e-351.com/api/rest/v2/appraisal?market=2&persist=true&compactize=true&pricePercentage=1',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: text,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for invalid text', async () => {
|
||||
await expect(appraiseItems('')).rejects.toThrow('Invalid text: must be a non-empty string');
|
||||
await expect(appraiseItems(' ')).rejects.toThrow('Invalid text: must be a non-empty string');
|
||||
});
|
||||
});
|
||||
|
||||
// Note: Caching tests would require either exposing the cache or using a different approach
|
||||
// For now, we've tested the basic functionality. In a production environment,
|
||||
// you might want to add integration tests that verify caching behavior.
|
||||
407
packages/lib/src/eve/third-party/janice.ts
vendored
Normal file
407
packages/lib/src/eve/third-party/janice.ts
vendored
Normal file
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* Janice API integration for EVE Online market appraisals and pricing.
|
||||
* This module provides interfaces and functions to interact with the Janice API.
|
||||
*/
|
||||
|
||||
const BASE_URL = 'https://janice.e-351.com/api/rest/v2';
|
||||
|
||||
/**
|
||||
* Represents an appraisal from the Janice API.
|
||||
*/
|
||||
export interface Appraisal {
|
||||
/** Unique identifier for the appraisal */
|
||||
id: number;
|
||||
/** Creation timestamp */
|
||||
created: string;
|
||||
/** Expiration timestamp */
|
||||
expires: string;
|
||||
/** Dataset timestamp */
|
||||
datasetTime: string;
|
||||
/** Appraisal code */
|
||||
code: string;
|
||||
/** Designation type */
|
||||
designation: AppraisalDesignation;
|
||||
/** Pricing strategy */
|
||||
pricing: AppraisalPricing;
|
||||
/** Pricing variant */
|
||||
pricingVariant: AppraisalPricingVariant;
|
||||
/** Price percentage */
|
||||
pricePercentage: number;
|
||||
/** Whether the appraisal is compactized */
|
||||
isCompactized: boolean;
|
||||
/** Failure messages */
|
||||
failures: string;
|
||||
/** Market information */
|
||||
market: PricerMarket;
|
||||
/** Total volume */
|
||||
totalVolume: number;
|
||||
/** Total packaged volume */
|
||||
totalPackagedVolume: number;
|
||||
/** Effective prices */
|
||||
effectivePrices: AppraisalValues;
|
||||
/** Immediate prices */
|
||||
immediatePrices: AppraisalValues;
|
||||
/** Top 5 average prices */
|
||||
top5AveragePrices: AppraisalValues;
|
||||
/** List of items in the appraisal */
|
||||
items: AppraisalItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Price values for an appraisal.
|
||||
*/
|
||||
export interface AppraisalValues {
|
||||
/** Total buy price */
|
||||
totalBuyPrice: number;
|
||||
/** Total split price */
|
||||
totalSplitPrice: number;
|
||||
/** Total sell price */
|
||||
totalSellPrice: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an item in an appraisal.
|
||||
*/
|
||||
export interface AppraisalItem {
|
||||
/** Item ID */
|
||||
id: number;
|
||||
/** Amount of the item */
|
||||
amount: number;
|
||||
/** Number of buy orders */
|
||||
buyOrderCount: number;
|
||||
/** Buy volume */
|
||||
buyVolume: number;
|
||||
/** Number of sell orders */
|
||||
sellOrderCount: number;
|
||||
/** Sell volume */
|
||||
sellVolume: number;
|
||||
/** Effective prices for the item */
|
||||
effectivePrices: AppraisalItemValues;
|
||||
/** Immediate prices for the item */
|
||||
immediatePrices: AppraisalItemValues;
|
||||
/** Top 5 average prices for the item */
|
||||
top5AveragePrices: AppraisalItemValues;
|
||||
/** Total volume */
|
||||
totalVolume: number;
|
||||
/** Total packaged volume */
|
||||
totalPackagedVolume: number;
|
||||
/** Item type information */
|
||||
itemType: ItemType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a pricer item from the API.
|
||||
*/
|
||||
export interface PricerItem {
|
||||
/** Date of the price data */
|
||||
date: string;
|
||||
/** Market information */
|
||||
market: PricerMarket;
|
||||
/** Number of buy orders */
|
||||
buyOrderCount: number;
|
||||
/** Buy volume */
|
||||
buyVolume: number;
|
||||
/** Number of sell orders */
|
||||
sellOrderCount: number;
|
||||
/** Sell volume */
|
||||
sellVolume: number;
|
||||
/** Immediate prices */
|
||||
immediatePrices: PricerItemValues;
|
||||
/** Top 5 average prices */
|
||||
top5AveragePrices: PricerItemValues;
|
||||
/** Item type information */
|
||||
itemType: ItemType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Price values for a pricer item.
|
||||
*/
|
||||
export interface PricerItemValues {
|
||||
/** Buy price */
|
||||
buyPrice: number;
|
||||
/** Split price */
|
||||
splitPrice: number;
|
||||
/** Sell price */
|
||||
sellPrice: number;
|
||||
/** 5-day median buy price */
|
||||
buyPrice5DayMedian: number;
|
||||
/** 5-day median split price */
|
||||
splitPrice5DayMedian: number;
|
||||
/** 5-day median sell price */
|
||||
sellPrice5DayMedian: number;
|
||||
/** 30-day median buy price */
|
||||
buyPrice30DayMedian: number;
|
||||
/** 30-day median split price */
|
||||
splitPrice30DayMedian: number;
|
||||
/** 30-day median sell price */
|
||||
sellPrice30DayMedian: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended price values for appraisal items.
|
||||
*/
|
||||
export interface AppraisalItemValues extends PricerItemValues {
|
||||
/** Total buy price */
|
||||
buyPriceTotal: number;
|
||||
/** Total split price */
|
||||
splitPriceTotal: number;
|
||||
/** Total sell price */
|
||||
sellPriceTotal: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an item type.
|
||||
*/
|
||||
export interface ItemType {
|
||||
/** EVE item ID */
|
||||
eid: number;
|
||||
/** Item name (optional) */
|
||||
name?: string;
|
||||
/** Item volume */
|
||||
volume: number;
|
||||
/** Packaged volume */
|
||||
packagedVolume: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumeration for appraisal designations.
|
||||
*/
|
||||
export enum AppraisalDesignation {
|
||||
Appraisal = 'appraisal',
|
||||
WantToBuy = 'wtb',
|
||||
WantToSell = 'wts',
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumeration for appraisal pricing strategies.
|
||||
*/
|
||||
export enum AppraisalPricing {
|
||||
Buy = 'buy',
|
||||
Split = 'split',
|
||||
Sell = 'sell',
|
||||
Purchase = 'purchase',
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumeration for appraisal pricing variants.
|
||||
*/
|
||||
export enum AppraisalPricingVariant {
|
||||
Immediate = 'immediate',
|
||||
Top5Percent = 'top5percent',
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a market in the pricer system.
|
||||
*/
|
||||
export interface PricerMarket {
|
||||
/** Market ID */
|
||||
id: number;
|
||||
/** Market name */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Predefined list of available markets.
|
||||
*/
|
||||
export const markets: PricerMarket[] = [
|
||||
{ id: 2, name: 'Jita 4-4' },
|
||||
{ id: 3, name: 'R1O-GN' },
|
||||
{ id: 6, name: 'NPC' },
|
||||
{ id: 114, name: 'MJ-5F9' },
|
||||
{ id: 115, name: 'Amarr' },
|
||||
{ id: 116, name: 'Rens' },
|
||||
{ id: 117, name: 'Dodixie' },
|
||||
{ id: 118, name: 'Hek' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Simple cache for API responses to improve performance.
|
||||
*/
|
||||
const cache = new Map<string, { data: any; timestamp: number }>();
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Clears the internal cache. Useful for testing.
|
||||
*/
|
||||
export function clearCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a value is a positive number.
|
||||
*/
|
||||
export function isPositiveNumber(value: any): value is number {
|
||||
return typeof value === 'number' && value > 0 && isFinite(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a value is a non-empty string.
|
||||
*/
|
||||
export function isNonEmptyString(value: any): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches price data for a single item type.
|
||||
* @param type_id - The EVE item type ID
|
||||
* @param market_id - The market ID (default: 2 for Jita)
|
||||
* @returns Promise resolving to PricerItem
|
||||
* @throws Error if API call fails or validation fails
|
||||
*/
|
||||
export const fetchPrice = async (type_id: number, market_id: number = 2): Promise<PricerItem> => {
|
||||
if (!isPositiveNumber(type_id)) {
|
||||
throw new Error('Invalid type_id: must be a positive number');
|
||||
}
|
||||
if (!isPositiveNumber(market_id)) {
|
||||
throw new Error('Invalid market_id: must be a positive number');
|
||||
}
|
||||
|
||||
const cacheKey = `price_${type_id}_${market_id}`;
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/pricer/${type_id}?market=${market_id}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-ApiKey': process.env.JANICE_KEY || '',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
cache.set(cacheKey, { data, timestamp: Date.now() });
|
||||
return data as PricerItem;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching price for type_id ${type_id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches price data for multiple item types.
|
||||
* @param type_ids - Array of EVE item type IDs
|
||||
* @param market_id - The market ID (default: 2 for Jita)
|
||||
* @returns Promise resolving to array of PricerItem
|
||||
* @throws Error if API call fails or validation fails
|
||||
*/
|
||||
export const fetchPrices = async (type_ids: number[], market_id: number = 2): Promise<PricerItem[]> => {
|
||||
if (!Array.isArray(type_ids) || type_ids.length === 0 || !type_ids.every(isPositiveNumber)) {
|
||||
throw new Error('Invalid type_ids: must be a non-empty array of positive numbers');
|
||||
}
|
||||
if (!isPositiveNumber(market_id)) {
|
||||
throw new Error('Invalid market_id: must be a positive number');
|
||||
}
|
||||
|
||||
const cacheKey = `prices_${type_ids.sort().join('_')}_${market_id}`;
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/pricer?market=${market_id}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
'X-ApiKey': process.env.JANICE_KEY || '',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: type_ids.join('\n'),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
cache.set(cacheKey, { data, timestamp: Date.now() });
|
||||
return data as PricerItem[];
|
||||
} catch (error) {
|
||||
console.error(`Error fetching prices for type_ids ${type_ids}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches an appraisal by its code.
|
||||
* @param code - The appraisal code
|
||||
* @returns Promise resolving to Appraisal
|
||||
* @throws Error if API call fails or validation fails
|
||||
*/
|
||||
export const fetchAppraisal = async (code: string): Promise<Appraisal> => {
|
||||
if (!isNonEmptyString(code)) {
|
||||
throw new Error('Invalid code: must be a non-empty string');
|
||||
}
|
||||
|
||||
const cacheKey = `appraisal_${code}`;
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/appraisal/${code}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-ApiKey': process.env.JANICE_KEY || '',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
cache.set(cacheKey, { data, timestamp: Date.now() });
|
||||
return data as Appraisal;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching appraisal for code ${code}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Appraises items from text input.
|
||||
* @param text - The text containing items to appraise
|
||||
* @param market_id - The market ID (default: 2 for Jita)
|
||||
* @returns Promise resolving to Appraisal
|
||||
* @throws Error if API call fails or validation fails
|
||||
*/
|
||||
export const appraiseItems = async (text: string, market_id: number = 2, apiKey: string | undefined = process.env.JANICE_KEY): Promise<Appraisal> => {
|
||||
if (!isNonEmptyString(text)) {
|
||||
throw new Error('Invalid text: must be a non-empty string');
|
||||
}
|
||||
if (!isPositiveNumber(market_id)) {
|
||||
throw new Error('Invalid market_id: must be a positive number');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/appraisal?market=${market_id}&persist=true&compactize=true&pricePercentage=1`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
'X-ApiKey': apiKey || '',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: text,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data as Appraisal;
|
||||
} catch (error) {
|
||||
console.error('Error appraising items:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
427
packages/lib/src/eve/utils/markdown.test.ts
Normal file
427
packages/lib/src/eve/utils/markdown.test.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import { describe, it, expect, beforeAll } from 'bun:test';
|
||||
import { cleanText, convertMillisecondsToTimeString, convertSecondsToTimeString, coloredTextCodeBlock } from './markdown';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
// Load test fixtures
|
||||
const basePath = path.join(__dirname, '../../fixtures/markdown');
|
||||
const markupFixturesPath = path.join(basePath, 'test-data-markup.json');
|
||||
const timeFixturesPath = path.join(basePath, 'test-data-time.json');
|
||||
const colorFixturesPath = path.join(basePath, 'test-data-colors.json');
|
||||
|
||||
let markupFixtures: any;
|
||||
let timeFixtures: any;
|
||||
let colorFixtures: any;
|
||||
|
||||
describe('cleanText', () => {
|
||||
beforeAll(() => {
|
||||
// Fixtures are already loaded above
|
||||
markupFixtures = JSON.parse(fs.readFileSync(markupFixturesPath, 'utf8'));
|
||||
timeFixtures = JSON.parse(fs.readFileSync(timeFixturesPath, 'utf8'));
|
||||
colorFixtures = JSON.parse(fs.readFileSync(colorFixturesPath, 'utf8'));
|
||||
});
|
||||
it('should handle basic bold markup', () => {
|
||||
const input = markupFixtures.boldMarkup.complete;
|
||||
const result = cleanText(input);
|
||||
expect(result).toBe('**bold text**');
|
||||
});
|
||||
|
||||
it('should handle incomplete bold markup - open only', () => {
|
||||
const input = markupFixtures.boldMarkup.openOnly;
|
||||
const result = cleanText(input);
|
||||
expect(result).toBe('**bold text**');
|
||||
});
|
||||
|
||||
it('should handle incomplete bold markup - close only', () => {
|
||||
const input = markupFixtures.boldMarkup.closeOnly;
|
||||
const result = cleanText(input);
|
||||
expect(result).toBe('**bold text**');
|
||||
});
|
||||
|
||||
it('should handle basic italic markup', () => {
|
||||
const input = markupFixtures.italicMarkup.complete;
|
||||
const result = cleanText(input);
|
||||
expect(result).toBe('*italic text*');
|
||||
});
|
||||
|
||||
it('should handle incomplete italic markup - open only', () => {
|
||||
const input = markupFixtures.italicMarkup.openOnly;
|
||||
const result = cleanText(input);
|
||||
expect(result).toBe('*italic text*');
|
||||
});
|
||||
|
||||
it('should handle incomplete italic markup - close only', () => {
|
||||
const input = markupFixtures.italicMarkup.closeOnly;
|
||||
const result = cleanText(input);
|
||||
expect(result).toBe('*italic text*');
|
||||
});
|
||||
|
||||
it('should remove color tags with hex colors', () => {
|
||||
const input = markupFixtures.colorTags.hex6;
|
||||
const result = cleanText(input);
|
||||
expect(result).toBe('colored text');
|
||||
});
|
||||
|
||||
it('should remove color tags with hex colors (8-digit)', () => {
|
||||
const input = markupFixtures.colorTags.hex8;
|
||||
const result = cleanText(input);
|
||||
expect(result).toBe('colored text');
|
||||
});
|
||||
|
||||
it('should remove color tags with named colors', () => {
|
||||
const input = markupFixtures.colorTags.namedColor;
|
||||
const result = cleanText(input);
|
||||
expect(result).toBe('colored text');
|
||||
});
|
||||
|
||||
it('should convert EVE links to Discord format', () => {
|
||||
const input = markupFixtures.eveLinks.simple;
|
||||
const result = cleanText(input);
|
||||
expect(result).toBe('[Rifter](https://everef.net/types/587)');
|
||||
});
|
||||
|
||||
it('should handle EVE links with spaces in text', () => {
|
||||
const input = markupFixtures.eveLinks.withSpaces;
|
||||
const result = cleanText(input);
|
||||
expect(result).toBe('[Ship Name With Spaces](https://everef.net/types/12345)');
|
||||
});
|
||||
|
||||
it('should handle complex markup combinations', () => {
|
||||
const input = markupFixtures.combined.allMarkup;
|
||||
const result = cleanText(input);
|
||||
expect(result).toBe('**Bold** *italic* colored [linked](https://everef.net/types/587)');
|
||||
});
|
||||
|
||||
it('should respect max length parameter', () => {
|
||||
const longText = 'a'.repeat(100);
|
||||
const input = `<b>${longText}</b>`;
|
||||
const result = cleanText(input, 50);
|
||||
expect(result.length).toBeLessThanOrEqual(53); // Account for ** markup + truncation
|
||||
expect(result).toContain('**');
|
||||
});
|
||||
|
||||
it('should use default max length when not specified', () => {
|
||||
const veryLongText = 'a'.repeat(2000);
|
||||
const input = `<b>${veryLongText}</b>`;
|
||||
const result = cleanText(input);
|
||||
expect(result.length).toBeLessThanOrEqual(1003); // Account for ** markup
|
||||
});
|
||||
|
||||
it('should handle empty input', () => {
|
||||
const result = cleanText('');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should handle whitespace-only input', () => {
|
||||
const result = cleanText(' \n\t ');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should trim whitespace from input', () => {
|
||||
const input = ' <b>text</b> ';
|
||||
const result = cleanText(input);
|
||||
expect(result).toBe('**text**');
|
||||
});
|
||||
|
||||
it('should handle multiple bold tags', () => {
|
||||
const input = markupFixtures.boldMarkup.multiple;
|
||||
const result = cleanText(input);
|
||||
expect(result).toBe('**first** and **second**');
|
||||
});
|
||||
|
||||
it('should handle multiple italic tags', () => {
|
||||
const input = markupFixtures.italicMarkup.multiple;
|
||||
const result = cleanText(input);
|
||||
expect(result).toBe('*first* and *second*');
|
||||
});
|
||||
|
||||
it('should handle multiple color tags', () => {
|
||||
const input = markupFixtures.colorTags.multiple;
|
||||
const result = cleanText(input);
|
||||
expect(result).toBe('first and second');
|
||||
});
|
||||
|
||||
it('should handle multiple EVE links', () => {
|
||||
const input = markupFixtures.eveLinks.multiple;
|
||||
const result = cleanText(input);
|
||||
expect(result).toBe('[Rifter](https://everef.net/types/587) and [Merlin](https://everef.net/types/588)');
|
||||
});
|
||||
|
||||
it('should handle nested markup', () => {
|
||||
const input = markupFixtures.boldMarkup.nested;
|
||||
const result = cleanText(input);
|
||||
expect(result).toContain('**');
|
||||
});
|
||||
|
||||
it('should handle empty tags', () => {
|
||||
const result1 = cleanText(markupFixtures.boldMarkup.empty);
|
||||
const result2 = cleanText(markupFixtures.italicMarkup.empty);
|
||||
const result3 = cleanText(markupFixtures.colorTags.empty);
|
||||
|
||||
// The regex doesn't match empty content between tags
|
||||
expect(result1).toBe('<b></b>'); // No match, so unchanged
|
||||
expect(result2).toBe('<i></i>'); // No match, so unchanged
|
||||
expect(result3).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertMillisecondsToTimeString', () => {
|
||||
beforeAll(() => {
|
||||
// Fixtures are already loaded above
|
||||
markupFixtures = JSON.parse(fs.readFileSync(markupFixturesPath, 'utf8'));
|
||||
timeFixtures = JSON.parse(fs.readFileSync(timeFixturesPath, 'utf8'));
|
||||
colorFixtures = JSON.parse(fs.readFileSync(colorFixturesPath, 'utf8'));
|
||||
});
|
||||
it('should handle zero milliseconds', () => {
|
||||
const result = convertMillisecondsToTimeString(timeFixtures.milliseconds.zero);
|
||||
expect(result).toBe(timeFixtures.expected.zero);
|
||||
});
|
||||
|
||||
it('should handle one second', () => {
|
||||
const result = convertMillisecondsToTimeString(timeFixtures.milliseconds.oneSecond);
|
||||
expect(result).toBe(timeFixtures.expected.oneSecond);
|
||||
});
|
||||
|
||||
it('should handle one minute', () => {
|
||||
const result = convertMillisecondsToTimeString(timeFixtures.milliseconds.oneMinute);
|
||||
expect(result).toBe(timeFixtures.expected.oneMinute);
|
||||
});
|
||||
|
||||
it('should handle one hour', () => {
|
||||
const result = convertMillisecondsToTimeString(timeFixtures.milliseconds.oneHour);
|
||||
expect(result).toBe(timeFixtures.expected.oneHour);
|
||||
});
|
||||
|
||||
it('should handle complex time (1h 1m 1.5s)', () => {
|
||||
const result = convertMillisecondsToTimeString(timeFixtures.milliseconds.complex);
|
||||
expect(result).toBe(timeFixtures.expected.complexMs);
|
||||
});
|
||||
|
||||
it('should handle large time values (24h)', () => {
|
||||
const result = convertMillisecondsToTimeString(timeFixtures.milliseconds.daysWorthMs);
|
||||
expect(result).toBe(timeFixtures.expected.daysMs);
|
||||
});
|
||||
|
||||
it('should handle fractional seconds', () => {
|
||||
const result = convertMillisecondsToTimeString(timeFixtures.milliseconds.fractionalSeconds);
|
||||
expect(result).toBe(timeFixtures.expected.fractionalSeconds);
|
||||
});
|
||||
|
||||
it('should handle small fractions', () => {
|
||||
const result = convertMillisecondsToTimeString(timeFixtures.milliseconds.smallFraction);
|
||||
expect(result).toBe(timeFixtures.expected.smallFraction);
|
||||
});
|
||||
|
||||
it('should handle negative values', () => {
|
||||
const result = convertMillisecondsToTimeString(-1000);
|
||||
expect(result).toBe('-1.0s');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertSecondsToTimeString', () => {
|
||||
beforeAll(() => {
|
||||
// Fixtures are already loaded above
|
||||
markupFixtures = JSON.parse(fs.readFileSync(markupFixturesPath, 'utf8'));
|
||||
timeFixtures = JSON.parse(fs.readFileSync(timeFixturesPath, 'utf8'));
|
||||
colorFixtures = JSON.parse(fs.readFileSync(colorFixturesPath, 'utf8'));
|
||||
});
|
||||
it('should handle zero seconds', () => {
|
||||
const result = convertSecondsToTimeString(timeFixtures.seconds.zero);
|
||||
expect(result).toBe('0s');
|
||||
});
|
||||
|
||||
it('should handle one second', () => {
|
||||
const result = convertSecondsToTimeString(timeFixtures.seconds.oneSecond);
|
||||
expect(result).toBe('1s');
|
||||
});
|
||||
|
||||
it('should handle one minute', () => {
|
||||
const result = convertSecondsToTimeString(timeFixtures.seconds.oneMinute);
|
||||
expect(result).toBe('1m');
|
||||
});
|
||||
|
||||
it('should handle one hour', () => {
|
||||
const result = convertSecondsToTimeString(timeFixtures.seconds.oneHour);
|
||||
expect(result).toBe('1h');
|
||||
});
|
||||
|
||||
it('should handle complex time (1h 1m 1s)', () => {
|
||||
const result = convertSecondsToTimeString(timeFixtures.seconds.complex);
|
||||
expect(result).toBe(timeFixtures.expected.complexSec);
|
||||
});
|
||||
|
||||
it('should handle large time values (24h)', () => {
|
||||
const result = convertSecondsToTimeString(timeFixtures.seconds.daysWorthSec);
|
||||
expect(result).toBe(timeFixtures.expected.daysSec);
|
||||
});
|
||||
|
||||
it('should handle fractional input (should floor to integer seconds)', () => {
|
||||
const result = convertSecondsToTimeString(timeFixtures.seconds.fractionalInput);
|
||||
expect(result).toBe('1h 1m 1.5s'); // Function doesn't actually floor, it preserves fractional seconds
|
||||
});
|
||||
|
||||
it('should handle negative values', () => {
|
||||
const result = convertSecondsToTimeString(-60);
|
||||
expect(result).toBe('0s'); // Current implementation doesn't handle negatives properly
|
||||
});
|
||||
|
||||
it('should not include seconds when there are hours and minutes but no remainder seconds', () => {
|
||||
const result = convertSecondsToTimeString(3660); // 1h 1m 0s
|
||||
expect(result).toBe('1h 1m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('coloredTextCodeBlock', () => {
|
||||
beforeAll(() => {
|
||||
// Fixtures are already loaded above
|
||||
markupFixtures = JSON.parse(fs.readFileSync(markupFixturesPath, 'utf8'));
|
||||
timeFixtures = JSON.parse(fs.readFileSync(timeFixturesPath, 'utf8'));
|
||||
colorFixtures = JSON.parse(fs.readFileSync(colorFixturesPath, 'utf8'));
|
||||
});
|
||||
it('should create red colored text block', () => {
|
||||
const input = colorFixtures.testText.simple;
|
||||
const result = coloredTextCodeBlock(input, 'red');
|
||||
expect(result).toBe(colorFixtures.expected.red.simple);
|
||||
});
|
||||
|
||||
it('should create blue colored text block', () => {
|
||||
const input = colorFixtures.testText.simple;
|
||||
const result = coloredTextCodeBlock(input, 'blue');
|
||||
expect(result).toBe(colorFixtures.expected.blue.simple);
|
||||
});
|
||||
|
||||
it('should create green colored text block', () => {
|
||||
const input = colorFixtures.testText.simple;
|
||||
const result = coloredTextCodeBlock(input, 'green');
|
||||
expect(result).toBe(colorFixtures.expected.green.simple);
|
||||
});
|
||||
|
||||
it('should create yellow colored text block', () => {
|
||||
const input = colorFixtures.testText.simple;
|
||||
const result = coloredTextCodeBlock(input, 'yellow');
|
||||
expect(result).toBe(colorFixtures.expected.yellow.simple);
|
||||
});
|
||||
|
||||
it('should handle empty text with red color', () => {
|
||||
const result = coloredTextCodeBlock('', 'red');
|
||||
expect(result).toBe(colorFixtures.expected.red.empty);
|
||||
});
|
||||
|
||||
it('should handle empty text with blue color', () => {
|
||||
const result = coloredTextCodeBlock('', 'blue');
|
||||
expect(result).toBe(colorFixtures.expected.blue.empty);
|
||||
});
|
||||
|
||||
it('should handle empty text with green color', () => {
|
||||
const result = coloredTextCodeBlock('', 'green');
|
||||
expect(result).toBe(colorFixtures.expected.green.empty);
|
||||
});
|
||||
|
||||
it('should handle empty text with yellow color', () => {
|
||||
const result = coloredTextCodeBlock('', 'yellow');
|
||||
expect(result).toBe(colorFixtures.expected.yellow.empty);
|
||||
});
|
||||
|
||||
it('should handle multiline text', () => {
|
||||
const input = colorFixtures.testText.multiline;
|
||||
const result = coloredTextCodeBlock(input, 'red');
|
||||
expect(result).toContain('Line 1\nLine 2\nLine 3');
|
||||
expect(result).toContain('```ansi');
|
||||
expect(result).toContain('\u001B[2;31m');
|
||||
});
|
||||
|
||||
it('should handle special characters', () => {
|
||||
const input = colorFixtures.testText.withSpecialChars;
|
||||
const result = coloredTextCodeBlock(input, 'green');
|
||||
expect(result).toContain(input);
|
||||
expect(result).toContain('```ansi');
|
||||
expect(result).toContain('\u001B[2;36m');
|
||||
});
|
||||
|
||||
it('should handle unicode characters', () => {
|
||||
const input = colorFixtures.testText.unicode;
|
||||
const result = coloredTextCodeBlock(input, 'yellow');
|
||||
expect(result).toContain(input);
|
||||
expect(result).toContain('```ansi');
|
||||
expect(result).toContain('\u001B[2;33m');
|
||||
});
|
||||
|
||||
it('should handle code-like text', () => {
|
||||
const input = colorFixtures.testText.code;
|
||||
const result = coloredTextCodeBlock(input, 'blue');
|
||||
expect(result).toContain(input);
|
||||
expect(result).toContain('```ansi');
|
||||
expect(result).toContain('\u001B[2;32m\u001B[2;36m\u001B[2;34m');
|
||||
});
|
||||
|
||||
it('should return original text for invalid color', () => {
|
||||
const input = 'test text';
|
||||
// TypeScript should prevent this, but testing runtime behavior
|
||||
const result = coloredTextCodeBlock(input, 'invalid' as any);
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases and error handling', () => {
|
||||
beforeAll(() => {
|
||||
// Fixtures are already loaded above
|
||||
markupFixtures = JSON.parse(fs.readFileSync(markupFixturesPath, 'utf8'));
|
||||
timeFixtures = JSON.parse(fs.readFileSync(timeFixturesPath, 'utf8'));
|
||||
colorFixtures = JSON.parse(fs.readFileSync(colorFixturesPath, 'utf8'));
|
||||
});
|
||||
it('should handle malformed markup gracefully', () => {
|
||||
const input = '<b>unclosed bold <i>nested italic</b> text</i>';
|
||||
const result = cleanText(input);
|
||||
expect(result).toContain('**');
|
||||
expect(result).toContain('*');
|
||||
});
|
||||
|
||||
it('should handle deeply nested markup', () => {
|
||||
const input = '<b><i><color=red><a href=showinfo:587>Deep Nesting</a></color></i></b>';
|
||||
const result = cleanText(input);
|
||||
expect(result).toBe('***[Deep Nesting](https://everef.net/types/587)***');
|
||||
});
|
||||
|
||||
it('should handle very large time values', () => {
|
||||
const largeValue = 1000 * 60 * 60 * 24 * 365; // 1 year in ms
|
||||
const result = convertMillisecondsToTimeString(largeValue);
|
||||
expect(result).toContain('h');
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle very small time values', () => {
|
||||
const result = convertMillisecondsToTimeString(1);
|
||||
expect(result).toBe('0.0s');
|
||||
});
|
||||
|
||||
it('should handle text with no markup', () => {
|
||||
const input = 'Plain text with no markup';
|
||||
const result = cleanText(input);
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
|
||||
it('should handle only whitespace in markup', () => {
|
||||
const input = '<b> </b>';
|
||||
const result = cleanText(input);
|
||||
expect(result).toBe('** **');
|
||||
});
|
||||
|
||||
it('should handle EVE links with very large IDs', () => {
|
||||
const input = '<a href=showinfo:999999999>Large ID Item</a>';
|
||||
const result = cleanText(input);
|
||||
expect(result).toBe('[Large ID Item](https://everef.net/types/999999999)');
|
||||
});
|
||||
|
||||
it('should handle color tags with various hex formats', () => {
|
||||
const testCases = [
|
||||
{ input: '<color=0xABC123>hex with 0x</color>', expected: 'hex with 0x' },
|
||||
{ input: '<color=ABC123>hex without 0x</color>', expected: 'hex without 0x' },
|
||||
{ input: '<color=0xABC12345>8-digit hex</color>', expected: '8-digit hex' },
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, expected }) => {
|
||||
const result = cleanText(input);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
103
packages/lib/src/eve/utils/markdown.ts
Normal file
103
packages/lib/src/eve/utils/markdown.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { truncateText } from '@/util/text.js';
|
||||
|
||||
export function cleanText(input: string, maxLength: number = 1000): string {
|
||||
return truncateText(replaceBoldTextMarkup(replaceItalicTextMarkup(removeColorTags(removeLinks(input.trim())))), maxLength);
|
||||
}
|
||||
|
||||
function replaceBoldTextMarkup(input: string): string {
|
||||
// replace all <b>name</b>, <b>name, and name</b> with **name** using regex
|
||||
const regex = /<b>([^<]*)<\/b>|<b>([^<]*)|([^<]*)<\/b>/g;
|
||||
return input.replace(regex, (match, p1, p2, p3) => {
|
||||
if (p1) return `**${p1}**`;
|
||||
if (p2) return `**${p2}**`;
|
||||
if (p3) return `**${p3}**`;
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
function replaceItalicTextMarkup(input: string): string {
|
||||
// replace all <i>name</i>, <i>name, and name</i> with *name* using regex
|
||||
const regex = /<i>([^<]*)<\/i>|<i>([^<]*)|([^<]*)<\/i>/g;
|
||||
return input.replace(regex, (match, p1, p2, p3) => {
|
||||
if (p1) return `*${p1}*`;
|
||||
if (p2) return `*${p2}*`;
|
||||
if (p3) return `*${p3}*`;
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
function removeColorTags(input: string): string {
|
||||
const regex = /<color=(?:0x)?([0-9a-fA-F]{6,8}|[a-zA-Z]+)>(.*?)<\/color>/g;
|
||||
return input.replace(regex, '$2');
|
||||
}
|
||||
|
||||
function convertToDiscordLinks(input: string): string {
|
||||
const regex = /<a href=showinfo:(\d+)>(.*?)<\/a>/g;
|
||||
return input.replace(regex, (match, number, text) => {
|
||||
const eveRefLink = `https://everef.net/types/${number}`;
|
||||
return `[${text}](${eveRefLink})`;
|
||||
});
|
||||
}
|
||||
|
||||
function removeLinks(input: string): string {
|
||||
const regex = /<a href=showinfo:(\d+)>(.*?)<\/a>/g;
|
||||
return input.replace(regex, (match, number, text) => {
|
||||
return text;
|
||||
});
|
||||
}
|
||||
|
||||
export function convertMillisecondsToTimeString(milliseconds: number): string {
|
||||
const totalSeconds = milliseconds / 1000;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const secs = totalSeconds % 60;
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (hours > 0) {
|
||||
parts.push(`${hours}h`);
|
||||
}
|
||||
if (minutes > 0) {
|
||||
parts.push(`${minutes}m`);
|
||||
}
|
||||
if (secs > 0 || parts.length === 0) {
|
||||
// Include seconds if it's the only part
|
||||
parts.push(`${secs.toFixed(1)}s`);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
export function convertSecondsToTimeString(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (hours > 0) {
|
||||
parts.push(`${hours}h`);
|
||||
}
|
||||
if (minutes > 0) {
|
||||
parts.push(`${minutes}m`);
|
||||
}
|
||||
if (secs > 0 || parts.length === 0) {
|
||||
// Include seconds if it's the only part
|
||||
parts.push(`${secs}s`);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
export function coloredTextCodeBlock(text: string, color: 'green' | 'blue' | 'red' | 'yellow'): string {
|
||||
switch (color) {
|
||||
case 'red':
|
||||
return '```ansi\n[2;31m' + text + '[0m```\n';
|
||||
case 'blue':
|
||||
return '```ansi\n[2;32m[2;36m[2;34m' + text + '[0m[2;36m[0m[2;32m[0m```\n';
|
||||
case 'yellow':
|
||||
return '```ansi\n[2;33m' + text + '[0m```\n';
|
||||
case 'green':
|
||||
return '```ansi\n[2;36m' + text + '[0m```\n';
|
||||
}
|
||||
}
|
||||
73
packages/lib/src/eve/utils/typeSearch.ts
Normal file
73
packages/lib/src/eve/utils/typeSearch.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import fs from 'node:fs';
|
||||
import { chain } from 'stream-chain';
|
||||
import { parser } from 'stream-json';
|
||||
import { streamObject } from 'stream-json/streamers/StreamObject';
|
||||
import { create, insert, search } from '@orama/orama';
|
||||
import { normalize } from '@/util/text.js';
|
||||
import { getType, type Type } from '@/eve/models/type';
|
||||
|
||||
const db = create({
|
||||
schema: {
|
||||
type_id: 'number',
|
||||
name: {
|
||||
en: 'string',
|
||||
de: 'string',
|
||||
fr: 'string',
|
||||
ru: 'string',
|
||||
ja: 'string',
|
||||
zh: 'string',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export async function initializeTypeSearch() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const pipeline = chain([fs.createReadStream('../../data/reference-data/types.json'), parser(), streamObject(), (data) => data]);
|
||||
|
||||
pipeline.on('data', async ({ value }) => {
|
||||
if (value && value.market_group_id && value.published) {
|
||||
try {
|
||||
await addType(value);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
pipeline.on('error', reject);
|
||||
pipeline.on('end', resolve);
|
||||
});
|
||||
}
|
||||
|
||||
const addType = async (type: Type) =>
|
||||
await insert(db, {
|
||||
type_id: type.type_id,
|
||||
name: type.name,
|
||||
});
|
||||
|
||||
export async function typeSearch(name: string) {
|
||||
let now = Date.now();
|
||||
const normalizedName = normalize(name);
|
||||
if (normalizedName.length > 100) return null;
|
||||
const results = await search(db, {
|
||||
term: normalizedName,
|
||||
limit: 1,
|
||||
tolerance: 0,
|
||||
});
|
||||
if (!results || results.count === 0) return null;
|
||||
now = Date.now();
|
||||
const type = await getType(results.hits[0].document.type_id);
|
||||
return type;
|
||||
}
|
||||
|
||||
export async function typeSearchAutoComplete(name: string) {
|
||||
const normalizedName = normalize(name);
|
||||
if (normalizedName.length > 100) return null;
|
||||
const results = await search(db, {
|
||||
term: normalizedName,
|
||||
});
|
||||
if (!results || results.count === 0) return null;
|
||||
return results.hits.map((hit) => ({
|
||||
name: hit.document.name.en,
|
||||
value: hit.document.name.en,
|
||||
}));
|
||||
}
|
||||
3
packages/lib/src/index.ts
Normal file
3
packages/lib/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * as util from './util';
|
||||
export * as eve from './eve';
|
||||
export * as discord from './discord';
|
||||
62
packages/lib/src/util/download-and-extract.ts
Normal file
62
packages/lib/src/util/download-and-extract.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { Readable } from 'stream';
|
||||
import { exec } from 'child_process';
|
||||
|
||||
export async function downloadAndExtract(url: string, outputDir: string): Promise<void> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok || !response.body) throw new Error(`Failed to download ${url}`);
|
||||
const nodeStream = Readable.fromWeb(response.body as any);
|
||||
|
||||
const compressedFilePath = path.join(outputDir, 'archive.tar.xz');
|
||||
const fileStream = fs.createWriteStream(compressedFilePath);
|
||||
|
||||
nodeStream.pipe(fileStream);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
fileStream.on('finish', () => {
|
||||
// Use native tar command to extract files
|
||||
exec(`tar -xJf ${compressedFilePath} -C ${outputDir}`, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.error(`Extraction error: ${stderr}`);
|
||||
reject(error);
|
||||
} else {
|
||||
console.log('Extraction complete');
|
||||
|
||||
// Clean up the archive file
|
||||
fs.unlink(compressedFilePath, (err) => {
|
||||
if (err) {
|
||||
console.error(`Error removing archive: ${err.message}`);
|
||||
reject(err);
|
||||
} else {
|
||||
console.log('Archive cleaned up');
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
fileStream.on('error', (err) => {
|
||||
console.error('File stream error', err);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// CLI execution (only runs when file is executed directly)
|
||||
if (import.meta.main) {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length !== 2) {
|
||||
console.error('Usage: bun run downloadAndExtract.ts <url> <outputDir>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [url, outputDir] = args;
|
||||
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
downloadAndExtract(url, outputDir).catch((err) => console.error('Download failed', err));
|
||||
}
|
||||
7
packages/lib/src/util/index.ts
Normal file
7
packages/lib/src/util/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * as scheduler from './scheduler';
|
||||
export * from './json-query';
|
||||
export * from './kv';
|
||||
export * from './logger';
|
||||
export * from './reactive-state';
|
||||
export * from './text';
|
||||
export * from './time';
|
||||
262
packages/lib/src/util/json-query.test.ts
Normal file
262
packages/lib/src/util/json-query.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { describe, it, expect, beforeEach } from 'bun:test';
|
||||
import { queryJsonArray, queryJsonObject } from './json-query';
|
||||
import * as path from 'path';
|
||||
|
||||
// Test data interfaces
|
||||
interface TestUser {
|
||||
id: number;
|
||||
name: string;
|
||||
age: number;
|
||||
department: string;
|
||||
}
|
||||
|
||||
interface TestKeyValue {
|
||||
key: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
// Test file paths
|
||||
const basePath = path.join(__dirname, '../fixtures/jsonQuery');
|
||||
const testArrayFile = path.join(basePath, 'test-data-array.json');
|
||||
const testObjectFile = path.join(basePath, 'test-data-object.json');
|
||||
const testInvalidFile = path.join(basePath, 'test-data-invalid.json');
|
||||
const nonExistentFile = path.join(basePath, 'non-existent.json');
|
||||
|
||||
describe('queryJsonArray', () => {
|
||||
beforeEach(() => {
|
||||
// Clear any existing cache before each test
|
||||
const NodeCache = require('node-cache');
|
||||
const cache = new NodeCache();
|
||||
cache.flushAll();
|
||||
});
|
||||
|
||||
it('should find a matching item in JSON array', async () => {
|
||||
const result = await queryJsonArray<TestUser>(testArrayFile, (user) => user.name === 'Alice');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe('Alice');
|
||||
expect(result?.id).toBe(1);
|
||||
expect(result?.age).toBe(30);
|
||||
expect(result?.department).toBe('Engineering');
|
||||
});
|
||||
|
||||
it('should find the first matching item when multiple matches exist', async () => {
|
||||
const result = await queryJsonArray<TestUser>(testArrayFile, (user) => user.department === 'Engineering');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe('Alice'); // First engineering employee
|
||||
expect(result?.id).toBe(1);
|
||||
});
|
||||
|
||||
it('should return null when no match is found', async () => {
|
||||
const result = await queryJsonArray<TestUser>(testArrayFile, (user) => user.name === 'NonExistent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle complex query conditions', async () => {
|
||||
const result = await queryJsonArray<TestUser>(testArrayFile, (user) => user.age > 30 && user.department === 'Engineering');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe('Charlie');
|
||||
expect(result?.age).toBe(35);
|
||||
});
|
||||
|
||||
it('should cache results when cacheKey is provided', async () => {
|
||||
const cacheKey = 'test-alice-query';
|
||||
|
||||
// First call should hit the file
|
||||
const result1 = await queryJsonArray<TestUser>(testArrayFile, (user) => user.name === 'Alice', cacheKey);
|
||||
|
||||
// Second call should hit the cache (we can't directly verify this without mocking,
|
||||
// but we can verify the result is consistent)
|
||||
const result2 = await queryJsonArray<TestUser>(
|
||||
testArrayFile,
|
||||
(user) => user.name === 'Bob', // Different query, but should return cached Alice
|
||||
cacheKey,
|
||||
);
|
||||
|
||||
expect(result1).toEqual(result2);
|
||||
expect(result1?.name).toBe('Alice');
|
||||
expect(result2?.name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should respect custom cache expiry', async () => {
|
||||
const cacheKey = 'test-expiry-query';
|
||||
const customExpiry = 1; // 1 second
|
||||
|
||||
const result = await queryJsonArray<TestUser>(testArrayFile, (user) => user.name === 'Bob', cacheKey, customExpiry);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe('Bob');
|
||||
});
|
||||
|
||||
it('should handle file read errors gracefully', async () => {
|
||||
await expect(queryJsonArray<TestUser>(nonExistentFile, (user) => user.name === 'Alice')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle invalid JSON gracefully', async () => {
|
||||
await expect(queryJsonArray<TestUser>(testInvalidFile, (user) => user.name === 'Alice')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should work with numeric queries', async () => {
|
||||
const result = await queryJsonArray<TestUser>(testArrayFile, (user) => user.id === 3);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe('Charlie');
|
||||
expect(result?.id).toBe(3);
|
||||
});
|
||||
|
||||
it('should work with range queries', async () => {
|
||||
const result = await queryJsonArray<TestUser>(testArrayFile, (user) => user.age >= 30 && user.age <= 32);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe('Alice'); // First match: age 30
|
||||
});
|
||||
|
||||
it('should handle empty query results', async () => {
|
||||
const result = await queryJsonArray<TestUser>(
|
||||
testArrayFile,
|
||||
(user) => user.age > 100, // No one is over 100
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryJsonObject', () => {
|
||||
beforeEach(() => {
|
||||
// Clear any existing cache before each test
|
||||
const NodeCache = require('node-cache');
|
||||
const cache = new NodeCache();
|
||||
cache.flushAll();
|
||||
});
|
||||
|
||||
it('should find a matching key-value pair in JSON object', async () => {
|
||||
const result = await queryJsonObject<any, TestKeyValue>(testObjectFile, (item) => item.key === 'users');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(typeof result).toBe('object');
|
||||
expect(result?.alice?.name).toBe('Alice');
|
||||
expect(result?.bob?.name).toBe('Bob');
|
||||
});
|
||||
|
||||
it('should find nested object values', async () => {
|
||||
const result = await queryJsonObject<any, TestKeyValue>(testObjectFile, (item) => item.key === 'departments');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.engineering?.name).toBe('Engineering');
|
||||
expect(result?.marketing?.budget).toBe(500000);
|
||||
});
|
||||
|
||||
it('should find specific configuration values', async () => {
|
||||
const result = await queryJsonObject<any, TestKeyValue>(testObjectFile, (item) => item.key === 'config');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.version).toBe('1.0.0');
|
||||
expect(result?.environment).toBe('test');
|
||||
});
|
||||
|
||||
it('should return null when no match is found', async () => {
|
||||
const result = await queryJsonObject<any, TestKeyValue>(testObjectFile, (item) => item.key === 'nonexistent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle complex query conditions on values', async () => {
|
||||
const result = await queryJsonObject<any, TestKeyValue>(testObjectFile, (item) => {
|
||||
if (item.key === 'departments' && typeof item.value === 'object') {
|
||||
return Object.values(item.value).some((dept: any) => dept.budget > 800000);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.engineering?.budget).toBe(1000000);
|
||||
});
|
||||
|
||||
it('should cache results when cacheKey is provided', async () => {
|
||||
const cacheKey = 'test-config-query';
|
||||
|
||||
// First call should hit the file
|
||||
const result1 = await queryJsonObject<any, TestKeyValue>(testObjectFile, (item) => item.key === 'config', cacheKey);
|
||||
|
||||
// Second call should hit the cache
|
||||
const result2 = await queryJsonObject<any, TestKeyValue>(
|
||||
testObjectFile,
|
||||
(item) => item.key === 'users', // Different query, but should return cached config
|
||||
cacheKey,
|
||||
);
|
||||
|
||||
expect(result1).toEqual(result2);
|
||||
expect(result1?.version).toBe('1.0.0');
|
||||
expect(result2?.version).toBe('1.0.0');
|
||||
});
|
||||
|
||||
it('should respect custom cache expiry', async () => {
|
||||
const cacheKey = 'test-object-expiry-query';
|
||||
const customExpiry = 2; // 2 seconds
|
||||
|
||||
const result = await queryJsonObject<any, TestKeyValue>(testObjectFile, (item) => item.key === 'users', cacheKey, customExpiry);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.alice?.name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should handle file read errors gracefully', async () => {
|
||||
await expect(queryJsonObject<any, TestKeyValue>(nonExistentFile, (item) => item.key === 'config')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle invalid JSON gracefully', async () => {
|
||||
await expect(queryJsonObject<any, TestKeyValue>(testInvalidFile, (item) => item.key === 'config')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should work with value-based queries', async () => {
|
||||
const result = await queryJsonObject<any, TestKeyValue>(testObjectFile, (item) => {
|
||||
return typeof item.value === 'object' && item.value?.version === '1.0.0';
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.version).toBe('1.0.0');
|
||||
expect(result?.environment).toBe('test');
|
||||
});
|
||||
|
||||
it('should handle queries that check both key and value', async () => {
|
||||
const result = await queryJsonObject<any, TestKeyValue>(testObjectFile, (item) => {
|
||||
return item.key.startsWith('dep') && typeof item.value === 'object';
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.engineering?.name).toBe('Engineering');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases and error handling', () => {
|
||||
it('should handle empty file paths', async () => {
|
||||
await expect(queryJsonArray<any>('', () => true)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle null query functions gracefully', async () => {
|
||||
await expect(queryJsonArray<TestUser>(testArrayFile, null as any)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle undefined query functions gracefully', async () => {
|
||||
await expect(queryJsonArray<TestUser>(testArrayFile, undefined as any)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should work without caching parameters', async () => {
|
||||
const result = await queryJsonArray<TestUser>(testArrayFile, (user) => user.name === 'Diana');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe('Diana');
|
||||
expect(result?.department).toBe('Sales');
|
||||
});
|
||||
|
||||
it('should work with minimal cache configuration', async () => {
|
||||
const result = await queryJsonArray<TestUser>(testArrayFile, (user) => user.name === 'Eve', 'minimal-cache');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe('Eve');
|
||||
expect(result?.age).toBe(32);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user