Initial commit

This commit is contained in:
JB
2026-01-14 20:21:44 -05:00
commit e9865d3ee8
237 changed files with 15121 additions and 0 deletions

View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
[test]
coverage = true
coverageSkipTestFiles = true
coverageReporter = ["text", "lcov"]
[run]
bun = true

4
packages/lib/data/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
# Ignore everything in this directory
*
# Except this file
!.gitignore

View File

@@ -0,0 +1,7 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "sqlite",
schema: "./src/db/schema.ts",
out: "./drizzle",
});

View 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;

View 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;

View 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,
),
);
}

View 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"
}
]

View File

@@ -0,0 +1,3 @@
{
"invalid": "json",
"missing": "closing brace"

View 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"
}
}

View 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"
}
}
}

View 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."
}
}

View 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"
}
}

167
packages/lib/package.json Normal file
View File

@@ -0,0 +1,167 @@
{
"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"
],
"main": "./dist/index.js",
"module": "./dist/index.js",
"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/janice.js": {
"import": "./dist/eve/third-party/janice.js",
"types": "./dist/types/eve/third-party/janice.d.ts",
"require": "./src/eve/third-party/janice.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",
"require": "./src/discord/index.js",
"types": "./dist/types/discord/index.d.ts"
},
"./discord/commands": {
"require": "./src/discord/commands/index.js",
"import": "./dist/discord/commands/index.js",
"types": "./dist/types/discord/commands/index.d.ts"
},
"./discord/components": {
"types": "./dist/types/discord/components/index.d.ts",
"require": "./src/discord/components/index.js",
"import": "./dist/discord/components/index.js"
},
"./discord/pages": {
"require": "./src/discord/pages/index.js",
"import": "./dist/discord/pages/index.js",
"types": "./dist/types/discord/pages/index.d.ts"
},
"./discord/common": {
"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"
},
"devDependencies": {
"@types/bun": "^1.3.5",
"@types/jsonwebtoken": "^9.0.10",
"@types/jwk-to-pem": "^2.0.3",
"@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",
"@vitest/coverage-v8": "^3.2.4",
"bumpp": "^10.1.0",
"drizzle-kit": "^0.31.4",
"ghooks": "^2.0.4",
"prettier-plugin-multiline-arrays": "^4.0.3",
"tsdown": "^0.14.2",
"typescript": "^5.9.2"
},
"dependencies": {
"@orama/orama": "^3.1.13",
"@oslojs/encoding": "^1.1.0",
"@projectdysnomia/dysnomia": "github:projectdysnomia/dysnomia#dev",
"acorn": "^8.14.0",
"acorn-jsx": "^5.3.2",
"cron-parser": "^5.3.1",
"date-fns": "^4.1.0",
"domhandler": "^5.0.3",
"drizzle-orm": "^0.44.5",
"elysia": "^1.4.20",
"fp-filters": "^0.5.4",
"html-dom-parser": "^5.1.1",
"jsonwebtoken": "^9.0.2",
"jwk-to-pem": "^2.0.7",
"jwt-decode": "^4.0.0",
"lodash": "^4.17.21",
"node-cache": "^5.1.2",
"stream-chain": "^3.4.0",
"stream-json": "^1.9.1",
"winston": "^3.17.0"
},
"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",
"postinstall": "bun get-data",
"get-data": "bun refresh:reference-data && bun refresh:hoboleaks",
"refresh:reference-data": "bun scripts/download-and-extract.ts https://data.everef.net/reference-data/reference-data-latest.tar.xz ./data/reference-data",
"refresh:hoboleaks": "bun scripts/download-and-extract.ts https://data.everef.net/hoboleaks-sde/hoboleaks-sde-latest.tar.xz ./data/hoboleaks",
"static-export": "bun scripts/export-solar-systems.ts"
}
}

View 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));
}

View 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();

View File

@@ -0,0 +1,15 @@
import { type ChatInputApplicationCommandStructure } from '@projectdysnomia/dysnomia';
import type { ExecutableInteraction } from '../types/interaction.type';
import type { ChatCommandDefinition, CommandContext, CommandHandler } from '../types';
export function createChatCommand(
definition: ChatCommandDefinition,
execute: (interaction: ExecutableInteraction, ctx: CommandContext) => Promise<void>,
): CommandHandler<ChatInputApplicationCommandStructure> {
const def = definition as ChatInputApplicationCommandStructure;
def.type = 1; // CHAT_INPUT
return {
definition: def,
execute,
};
}

View File

@@ -0,0 +1,72 @@
import { Constants } from '@projectdysnomia/dysnomia';
import type {
CommandInteraction,
ExecutableInteraction,
Interaction,
AutocompleteInteraction,
ComponentInteraction,
ModalSubmitInteraction,
PingInteraction,
} from '../types';
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;
}
export function augmentInteraction(interaction: Interaction): Interaction {
interaction.isApplicationCommand = function (): this is CommandInteraction {
return interaction.type === Constants.InteractionTypes.APPLICATION_COMMAND;
};
interaction.isModalSubmit = function (): this is ModalSubmitInteraction {
return interaction.type === Constants.InteractionTypes.MODAL_SUBMIT;
};
interaction.isMessageComponent = function (): this is ComponentInteraction {
return interaction.type === Constants.InteractionTypes.MESSAGE_COMPONENT;
};
interaction.isAutocomplete = function (): this is AutocompleteInteraction {
return interaction.type === Constants.InteractionTypes.APPLICATION_COMMAND_AUTOCOMPLETE;
};
interaction.isPing = function (): this is PingInteraction {
return interaction.type === Constants.InteractionTypes.PING;
};
interaction.isExecutable = function (): this is ExecutableInteraction {
return (
interaction.type === Constants.InteractionTypes.APPLICATION_COMMAND ||
interaction.type === Constants.InteractionTypes.MODAL_SUBMIT ||
interaction.type === Constants.InteractionTypes.MESSAGE_COMPONENT ||
interaction.type === Constants.InteractionTypes.APPLICATION_COMMAND_AUTOCOMPLETE
);
};
return interaction;
}

View File

@@ -0,0 +1,99 @@
import { type InteractionModalContent, type Component, Constants } from '@projectdysnomia/dysnomia';
import type { CommandContext, PartialContext, ExecutableInteraction } from '../types';
import { int } from 'drizzle-orm/mysql-core';
import _ from 'lodash';
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) {
if ('createModal' in 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);
};
interaction.createJSXModal = async (component) => {
return interaction.createModal(component as any);
};
}
if ('createMessage' in interaction) {
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);
};
interaction.createJSXMessage = async (component) => {
const messageContent = {
flags: Constants.MessageFlags.IS_COMPONENTS_V2,
components: [component],
};
return interaction.createMessage(messageContent as any);
};
}
if ('editMessage' in interaction) {
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);
};
interaction.editJSXMessage = async (messageID, component) => {
const messageContent = {
flags: Constants.MessageFlags.IS_COMPONENTS_V2,
components: [component],
};
return interaction.editMessage(messageID, messageContent as any);
};
}
if ('createFollowup' in interaction) {
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);
};
interaction.createJSXFollowup = async (component) => {
const messageContent = {
flags: Constants.MessageFlags.IS_COMPONENTS_V2,
components: [component],
};
return interaction.createFollowup(messageContent as any);
};
}
}
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);
}
});
}

View File

@@ -0,0 +1,48 @@
import { createReactiveState } from '@/util/reactive-state.js';
import { isApplicationCommand, isAutocomplete } from './command-helpers';
import type { CommandState, ExecutableInteraction, PartialContext } from '../types';
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 '';
}

View 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 { CommandHandler } from '../types';
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();
});

View File

@@ -0,0 +1,74 @@
import { type ApplicationCommandStructure } from '@projectdysnomia/dysnomia';
import { augmentInteraction, getCommandName } from './command-helpers';
import { injectInteraction } from './command-injection';
import { getCommandState } from './command-state';
import { type ExecutableInteraction } from '../types/interaction.type';
import type { CommandHandler, PartialContext } from '../types';
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 (interaction.isAutocomplete() && 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) => {
const interaction = augmentInteraction(_interaction as any);
if (interaction.isExecutable()) {
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}`, '');
}
});
}

View 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({});
});

View File

@@ -0,0 +1,19 @@
import { Glob } from 'bun';
import { join } from 'node:path';
import type { ApplicationCommandStructure } from '@projectdysnomia/dysnomia';
import type { CommandHandler } from '../types';
export async function importCommands(
pattern: string = '**/*.command.{js,ts,jsx,tsx}',
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;
}

View File

@@ -0,0 +1,7 @@
export * from './command-handler';
export * from './import-commands';
export * from './handle-commands';
export * from './command-helpers';
export * from './register-commands';
export * from './command-state';
export * from './option-builders';

View 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;
}

View 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;
}

View File

@@ -0,0 +1,339 @@
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,
type ModalSubmitInteractionData,
type FileUploadComponent,
} from '@projectdysnomia/dysnomia';
export type ActionRowItem = Button | StringSelectMenu | UserSelectMenu | RoleSelectMenu | MentionableSelectMenu | ChannelSelectMenu;
export const actionRow = (...components: ActionRowItem[]): ActionRow => ({
type: Constants.ComponentTypes.ACTION_ROW,
components: components.filter((c) => c),
});
export enum ButtonStyle {
PRIMARY = 1,
SECONDARY = 2,
SUCCESS = 3,
DANGER = 4,
}
export interface ButtonOptions {
style?: ButtonStyle;
emoji?: PartialEmoji;
disabled?: boolean;
}
export const button = (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 urlButton = (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 premiumButton = (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?: PartialEmoji;
default?: boolean;
}
export interface StringSelectOptions {
placeholder?: string;
min_values?: number;
max_values?: number;
disabled?: boolean;
required?: boolean;
}
export const stringSelect = (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 InputOptions {
isParagraph?: boolean;
label?: string;
min_length?: number;
max_length?: number;
required?: boolean;
value?: string;
placeholder?: string;
}
export const input = (custom_id: string, options?: InputOptions): 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;
required?: boolean; // if on a modal
default_values?: Array<{ id: string; type: 'user' }>;
}
export const userSelect = (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;
required?: boolean; // if on a modal
default_values?: Array<{ id: string; type: 'role' }>;
}
export const roleSelect = (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;
required?: boolean; // if on a modal
default_values?: Array<{ id: string; type: 'user' | 'role' }>;
}
export const mentionableSelect = (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;
required?: boolean; // if on a modal
default_values?: Array<{ id: string; type: 'channel' }>;
}
export const channelSelect = (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 section = (accessory: Button | ThumbnailComponent, ...components: Array<TextDisplayComponent>): SectionComponent => ({
type: Constants.ComponentTypes.SECTION,
accessory,
components: components.filter((c) => c),
});
/**
* 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 text = (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 thumbnail = (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 gallery = (...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 file = (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 separator = (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 container = (options: ContainerOptions, ...components: ContainerItems[]): ContainerComponent => ({
type: Constants.ComponentTypes.CONTAINER,
...options,
components: components.filter((c) => c),
});
// Modals
export interface LabelOptions {
label: string;
description?: string;
}
export const label = (options: LabelOptions, component: LabelComponent['component']): LabelComponent => ({
type: Constants.ComponentTypes.LABEL,
label: options.label,
description: options.description,
component,
});
export const modal = (
options: { custom_id?: string; title?: string },
...components: Array<LabelComponent | ActionRow | TextDisplayComponent>
): ModalSubmitInteractionData =>
({
type: 9 as any, // Modal type
custom_id: options.custom_id ?? '',
title: options.title ?? '',
components: components.filter((c) => c),
} as any);

View File

@@ -0,0 +1,23 @@
import {
Constants,
type ComponentBase,
type ModalSubmitInteractionDataLabelComponent,
type ModalSubmitInteractionDataSelectComponent,
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 ModalSubmitInteractionDataSelectComponent {
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);
}

View File

@@ -0,0 +1,2 @@
export * from './helpers';
export * from './builders';

View File

@@ -0,0 +1,2 @@
export * from './text';
export * from './locale';

View File

@@ -0,0 +1,34 @@
export enum Locale {
EN_US = 'en-US',
EN_GB = 'en-GB',
DE = 'de',
ES_ES = 'es-ES',
FR = 'fr',
JA = 'ja',
KO = 'ko',
RU = 'ru',
ZH_CN = 'zh-CN',
ID = 'id',
DA = 'da',
ES_419 = 'es-419',
HR = 'hr',
IT = 'it',
LT = 'lt',
HU = 'hu',
NL = 'nl',
NO = 'no',
PL = 'pl',
PT_BR = 'pt-BR',
RO = 'ro',
FI = 'fi',
SV_SE = 'sv-SE',
VI = 'vi',
TR = 'tr',
CS = 'cs',
EL = 'el',
BG = 'bg',
UK = 'uk',
HI = 'hi',
TH = 'th',
ZH_TW = 'zh-TW',
}

View File

@@ -0,0 +1,2 @@
export const WHITE_SPACE = ' '; // non-breaking space
export const BREAKING_WHITE_SPACE = '\u200B';

View 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 startBot({
token = process.env.DISCORD_BOT_TOKEN || '',
intents = [],
commandPattern = '**/*.command.{js,ts,jsx,tsx}',
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;
}

View 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;
}

View File

@@ -0,0 +1,3 @@
export * from './bot.ts';
export * from './cache.type.ts';
export * from './kv-store.type.ts.ts';

View 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>;
}

View File

@@ -0,0 +1,7 @@
export * from './constants';
export * from './commands';
export * from './core';
export * from './jsx';
export * from './components';
export * from './pages';
export * from './types';

View File

@@ -0,0 +1,7 @@
export function createElement(tag: string, attrs: Record<string, any> = {}, ...children: any[]) {
return {
tag,
attrs,
children,
};
}

View File

@@ -0,0 +1,2 @@
export * from './parser';
export * from './createElement';

View 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);
});
});

View 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 = {} as any): 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;
}

View 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';
}
}

View File

@@ -0,0 +1,6 @@
import { actionRow } from '@/discord/components';
import type { ActionRowElement } from './element.types';
export function ActionRow(props: { children: ActionRowElement['children'] }) {
return actionRow(...(Array.isArray(props.children) ? props.children : [props.children]));
}

View File

@@ -0,0 +1,14 @@
import { button, premiumButton, urlButton } from '@/discord/components';
import type { ButtonElement, PremiumButtonElement, URLButtonElement } from './element.types';
export function Button(props: ButtonElement['props']) {
return button(props.label, props.customId, { style: props.style, emoji: props.emoji, disabled: props.disabled });
}
export function URLButton(props: URLButtonElement['props']) {
return urlButton(props.label, props.url, { emoji: props.emoji, disabled: props.disabled });
}
export function PremiumButton(props: PremiumButtonElement['props']) {
return premiumButton(props.skuId, { emoji: props.emoji, disabled: props.disabled });
}

View File

@@ -0,0 +1,14 @@
import { channelSelect } from '@/discord/components';
import type { ChannelSelectElement } from './element.types';
export function ChannelSelect(props: ChannelSelectElement['props']) {
return channelSelect(props.customId, {
channel_types: props.channelTypes,
placeholder: props.placeholder,
min_values: props.minValues,
max_values: props.maxValues,
disabled: props.disabled,
required: props.required,
default_values: props.defaultValues,
});
}

View File

@@ -0,0 +1,9 @@
import { container } from '@/discord/components';
import type { ContainerElement } from './element.types';
export function Container(props: ContainerElement['props'] & { children: ContainerElement['children'] }) {
return container(
{ accent_color: props.accent, spoiler: props.spoiler },
...(Array.isArray(props.children) ? props.children : [props.children]),
);
}

View File

@@ -0,0 +1,235 @@
import type { ActionRowItem, ContainerItems, MediaItem, Padding } from '@/discord/components';
import type {
ActionRow,
Button,
FileUploadComponent,
GuildChannelTypes,
LabelComponent,
PartialEmoji,
SelectMenu,
TextDisplayComponent,
TextInput,
ThumbnailComponent,
} from '@projectdysnomia/dysnomia';
export interface OptionElement {
type: 'option';
props: {
label: string;
value: string;
description?: string;
emoji?: PartialEmoji;
default?: boolean;
};
children: never;
}
export interface StringSelectElement {
type: 'stringSelect';
props: {
customId: string;
placeholder?: string;
minValues?: number;
maxValues?: number;
disabled?: boolean;
required?: boolean; // if on a modal
};
children: OptionElement['props'] | OptionElement['props'][];
}
export interface LabelElement {
type: 'label';
props: {
label: string;
description?: string;
};
children: SelectMenu | TextInput | FileUploadComponent;
}
export type ModalChildren = ActionRow | LabelComponent | TextDisplayComponent;
export interface ModalElement {
type: 'modal';
props: {
customId?: string;
title?: string;
};
children: ModalChildren | ModalChildren[];
}
export interface ActionRowElement {
type: 'actionRow';
props: {};
children: ActionRowItem | ActionRowItem[];
}
export interface ButtonElement {
type: 'button';
props: {
label: string;
customId: string;
style: number;
emoji?: PartialEmoji;
disabled?: boolean;
};
children: never;
}
export interface URLButtonElement {
type: 'urlButton';
props: {
label: string;
url: string;
emoji?: PartialEmoji;
disabled?: boolean;
};
children: never;
}
export interface PremiumButtonElement {
type: 'premiumButton';
props: {
skuId: string;
emoji?: PartialEmoji;
disabled?: boolean;
};
children: never;
}
export interface TextInputElement {
type: 'textInput';
props: {
customId: string;
label?: string; // can not be set within a label on a modal
isParagraph?: boolean;
minLength?: number;
maxLength?: number;
required?: boolean;
value?: string;
placeholder?: string;
};
children: never;
}
export interface TextElement {
type: 'text';
props: {};
children: string | string[];
}
export interface ContainerElement {
type: 'container';
props: {
color?: string;
accent?: number;
spoiler?: boolean;
};
children: ContainerItems | ContainerItems[];
}
export interface UserSelectElement {
type: 'userSelect';
props: {
customId: string;
placeholder?: string;
minValues?: number;
maxValues?: number;
disabled?: boolean;
required?: boolean; // if on a modal
defaultValues?: { id: string; type: 'user' }[];
};
children: never;
}
export interface RoleSelectElement {
type: 'roleSelect';
props: {
customId: string;
placeholder?: string;
minValues?: number;
maxValues?: number;
disabled?: boolean;
required?: boolean; // if on a modal
defaultValues?: { id: string; type: 'role' }[];
};
children: never;
}
export interface MentionableSelectElement {
type: 'mentionableSelect';
props: {
customId: string;
placeholder?: string;
minValues?: number;
maxValues?: number;
disabled?: boolean;
required?: boolean; // if on a modal
defaultValues?: { id: string; type: 'user' | 'role' }[];
};
children: never;
}
export interface ChannelSelectElement {
type: 'channelSelect';
props: {
customId: string;
placeholder?: string;
minValues?: number;
maxValues?: number;
disabled?: boolean;
required?: boolean; // if on a modal
channelTypes?: GuildChannelTypes[];
defaultValues?: { id: string; type: 'channel' }[];
};
children: never;
}
export interface SectionElement {
type: 'section';
props: {};
children: (Button | ThumbnailComponent) | [Button | ThumbnailComponent, ...Array<TextDisplayComponent>];
}
export interface ThumbnailElement {
type: 'thumbnail';
props: {
url: string;
description?: string;
spoiler?: boolean;
};
children: never;
}
export interface GalleryElement {
type: 'gallery';
props: {};
children: MediaElement['props'] | MediaElement['props'][];
}
export interface MediaElement {
type: 'media';
props: {
url: string;
description?: string;
spoiler?: boolean;
};
children: never;
}
export interface FileElement {
type: 'file';
props: {
url: string;
spoiler?: boolean;
};
children: never;
}
export interface SeparatorElement {
type: 'separator';
props: {
divider?: boolean;
spacing?: Padding;
};
children: never;
}

View File

@@ -0,0 +1,6 @@
import { file } from '@/discord/components';
import type { FileElement } from './element.types';
export function File(props: FileElement['props']) {
return file(props.url, props.spoiler);
}

View File

@@ -0,0 +1,7 @@
import { gallery } from '@/discord/components';
import type { GalleryElement } from './element.types';
export function Gallery(props: GalleryElement['props'] & { children: GalleryElement['children'] }) {
const children = Array.isArray(props.children) ? props.children : [props.children];
return gallery(...children);
}

View File

@@ -0,0 +1,19 @@
export * from './action-row';
export * from './button';
export * from './channel-select';
export * from './container';
export * from './file';
export * from './gallery';
export * from './label';
export * from './media';
export * from './mentionable-select';
export * from './modal';
export * from './option';
export * from './role-select';
export * from './section';
export * from './separator';
export * from './string-select';
export * from './text';
export * from './text-input';
export * from './thumbnail';
export * from './user-select';

View File

@@ -0,0 +1,6 @@
import { label } from '@/discord/components';
import type { LabelElement } from './element.types';
export function Label(props: LabelElement['props'] & { children: LabelElement['children'] }) {
return label(props, props.children);
}

View File

@@ -0,0 +1,5 @@
import type { MediaElement } from './element.types';
export function Media(props: MediaElement['props']) {
return props;
}

View File

@@ -0,0 +1,13 @@
import { mentionableSelect } from '@/discord/components';
import type { MentionableSelectElement } from './element.types';
export function MentionableSelect(props: MentionableSelectElement['props']) {
return mentionableSelect(props.customId, {
placeholder: props.placeholder,
min_values: props.minValues,
max_values: props.maxValues,
disabled: props.disabled,
required: props.required,
default_values: props.defaultValues,
});
}

View File

@@ -0,0 +1,6 @@
import { modal } from '@/discord/components';
import type { ModalElement } from './element.types';
export function Modal(props: ModalElement['props'] & { children: ModalElement['children'] }) {
return modal({ custom_id: props.customId, title: props.title }, ...(Array.isArray(props.children) ? props.children : [props.children]));
}

View File

@@ -0,0 +1,5 @@
import type { OptionElement } from './element.types';
export function Option(props: OptionElement['props']) {
return props;
}

View File

@@ -0,0 +1,13 @@
import { roleSelect } from '@/discord/components';
import type { RoleSelectElement } from './element.types';
export function RoleSelect(props: RoleSelectElement['props']) {
return roleSelect(props.customId, {
placeholder: props.placeholder,
min_values: props.minValues,
max_values: props.maxValues,
disabled: props.disabled,
required: props.required,
default_values: props.defaultValues,
});
}

View File

@@ -0,0 +1,7 @@
import { section } from '@/discord/components';
import type { SectionElement } from './element.types';
export function Section(props: SectionElement['props'] & { children: SectionElement['children'] }) {
const children = Array.isArray(props.children) ? props.children : [props.children];
return section(children[0], ...(children.slice(1) as any[]));
}

View File

@@ -0,0 +1,6 @@
import { separator } from '@/discord/components';
import type { SeparatorElement } from './element.types';
export function Separator(props: SeparatorElement['props']) {
return separator(props.spacing, props.divider);
}

View File

@@ -0,0 +1,14 @@
import { stringSelect } from '@/discord/components';
import type { StringSelectElement } from './element.types';
export function StringSelect(props: StringSelectElement['props'] & { children: StringSelectElement['children'] }) {
return stringSelect(
props.customId,
{
placeholder: props.placeholder,
min_values: props.minValues,
max_values: props.maxValues,
},
...(Array.isArray(props.children) ? props.children : [props.children]),
);
}

View File

@@ -0,0 +1,14 @@
import { input } from '@/discord/components';
import type { TextInputElement } from './element.types';
export function TextInput(props: TextInputElement['props']) {
return input(props.customId, {
isParagraph: props.isParagraph,
label: props.label,
min_length: props.minLength,
max_length: props.maxLength,
required: props.required,
value: props.value,
placeholder: props.placeholder,
});
}

View File

@@ -0,0 +1,7 @@
import { text } from '@/discord/components/builders';
import type { TextElement } from './element.types';
export function Text(props: TextElement['props'] & { children: TextElement['children'] }) {
const children = Array.isArray(props.children) ? props.children.join('') : props.children;
return text(children);
}

View File

@@ -0,0 +1,6 @@
import { thumbnail } from '@/discord/components';
import type { ThumbnailElement } from './element.types';
export function Thumbnail(props: ThumbnailElement['props']) {
return thumbnail(props.url, props.description, props.spoiler);
}

View File

@@ -0,0 +1,13 @@
import { userSelect } from '@/discord/components';
import type { UserSelectElement } from './element.types';
export function UserSelect(props: UserSelectElement['props']) {
return userSelect(props.customId, {
placeholder: props.placeholder,
min_values: props.minValues,
max_values: props.maxValues,
disabled: props.disabled,
required: props.required,
default_values: props.defaultValues,
});
}

View File

@@ -0,0 +1,3 @@
export * from './components';
export * from './jsx';
export * from './runtime';

View File

@@ -0,0 +1,2 @@
export { jsxDEV, Fragment } from './runtime';
export type { JSX } from './jsx';

View File

@@ -0,0 +1,2 @@
export { jsx, Fragment } from './runtime';
export type { JSX } from './jsx';

View File

@@ -0,0 +1,112 @@
import {
type ActionRow,
type Button,
type ChannelSelectMenu,
type MentionableSelectMenu,
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,
type ModalSubmitInteractionData,
} from '@projectdysnomia/dysnomia';
import type {
ButtonElement,
ChannelSelectElement,
ContainerElement,
FileElement,
GalleryElement,
LabelElement,
MediaElement,
MentionableSelectElement,
ModalElement,
OptionElement,
PremiumButtonElement,
RoleSelectElement,
SectionElement,
SeparatorElement,
StringSelectElement,
TextElement,
TextInputElement,
ThumbnailElement,
URLButtonElement,
UserSelectElement,
} from './components/element.types';
export type Component =
| ActionRow
| Button
| StringSelectMenu
| UserSelectMenu
| RoleSelectMenu
| MentionableSelectMenu
| ChannelSelectMenu
| TextInput
| LabelComponent
| ContainerComponent
| TextDisplayComponent
| SectionComponent
| MediaGalleryComponent
| SeparatorComponent
| FileComponent
| InteractionButton
| URLButton
| PremiumButton
| ThumbnailComponent
| ModalSubmitInteractionData;
export type StarKittenElement = Component | Promise<Component>;
export interface StarKittenElementClass {
render: any;
}
export interface StarKittenElementAttributesProperty {
props: {};
}
export interface StarKittenElementChildrenAttribute {
children: {};
}
export interface StarKittenIntrinsicElements {
actionRow: { children: StarKittenElement | StarKittenElement[] };
button: ButtonElement['props'];
urlButton: URLButtonElement['props'];
premiumButton: PremiumButtonElement['props'];
modal: ModalElement['props'] & { children: StarKittenElement | StarKittenElement[] };
label: LabelElement['props'] & { children: StarKittenElement | StarKittenElement[] };
stringSelect: StringSelectElement['props'] & { children: StringSelectElement['children'] };
option: OptionElement['props'];
textInput: TextInputElement['props'];
text: TextElement['props'];
container: ContainerElement['props'] & { children: StarKittenElement | StarKittenElement[] };
userSelect: UserSelectElement['props'];
roleSelect: RoleSelectElement['props'];
mentionableSelect: MentionableSelectElement['props'];
channelSelect: ChannelSelectElement['props'];
section: SectionElement['props'] & { children: StarKittenElement | StarKittenElement[] };
thumbnail: ThumbnailElement['props'];
gallery: GalleryElement['props'] & { children: StarKittenElement | StarKittenElement[] };
media: MediaElement['props'];
file: FileElement['props'];
separator: SeparatorElement['props'];
}
export declare namespace JSX {
export type Element = StarKittenElement;
export interface ElementClass extends StarKittenElementClass {}
export interface ElementAttributesProperty extends StarKittenElementAttributesProperty {}
export interface ElementChildrenAttribute extends StarKittenElementChildrenAttribute {}
export interface IntrinsicElements extends StarKittenIntrinsicElements {}
}

View File

@@ -0,0 +1,68 @@
import * as components from './components';
const intrinsicComponentMap: Record<string, (props: any) => any> = {
actionRow: components.ActionRow,
button: components.Button,
container: components.Container,
file: components.File,
gallery: components.Gallery,
label: components.Label,
media: components.Media,
mentionableSelect: components.MentionableSelect,
modal: components.Modal,
option: components.Option,
premiumButton: components.PremiumButton,
roleSelect: components.RoleSelect,
section: components.Section,
separator: components.Separator,
stringSelect: components.StringSelect,
text: components.Text,
textInput: components.TextInput,
thumbnail: components.Thumbnail,
urlButton: components.URLButton,
userSelect: components.UserSelect,
};
export const Fragment = (props: { children: any }) => {
return [...props.children];
};
export function jsx(type: any, props: Record<string, any>) {
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,
};
}

22
packages/lib/src/discord/jsx/types.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
import type { Component, StarKittenIntrinsicElements } from './jsx';
import type { LabelElement } from './components/label';
import type { StringSelectElement } from './components/string-select';
import type { PartialEmoji, StringSelectMenu, TextInput } from '@projectdysnomia/dysnomia';
export declare namespace JSX {
// type Element = Component;
interface ElementClass {
render: any;
}
interface ElementAttributesProperty {
props: {};
}
interface ElementChildrenAttribute {
children: {};
}
interface IntrinsicElements extends StarKittenIntrinsicElements {}
}

View File

@@ -0,0 +1,2 @@
export * from './pages';
export * from './subroutes';

View File

@@ -0,0 +1,166 @@
import { Constants, type InteractionContentEdit, type InteractionModalContent } from '@projectdysnomia/dysnomia';
import type { CommandContext, ExecutableInteraction } from '../types';
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: ExecutableInteraction;
goToPage: (pageKey: string) => Promise<InteractionContentEdit>;
}
function createPageContext<T>(interaction: ExecutableInteraction, 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];
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: ExecutableInteraction, cmdCtx: CommandContext) {
if (interaction.isAutocomplete() || interaction.isPing()) {
throw new Error('usePages cannot be used with autocomplete or ping interactions');
}
const pagesInteraction = interaction;
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 && !pagesInteraction.isModalSubmit()) {
// we don't defer modals and can't respond to a modal with a modal.
const maybePromise = page.render(pageContext);
const content = isPromise(maybePromise) ? await maybePromise : maybePromise;
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 maybePromise = page.render(pageContext);
const content = isPromise(maybePromise) ? await maybePromise : maybePromise;
return await pagesInteraction.createFollowup({
flags,
...wrapJSXContent(content),
});
}
if (pageState.messageId && (pagesInteraction.isMessageComponent() || pagesInteraction.isModalSubmit())) {
await pagesInteraction.deferUpdate();
const maybePromise = page.render(pageContext);
const content = isPromise(maybePromise) ? await maybePromise : maybePromise;
return await pagesInteraction.editMessage(pageState.messageId, wrapJSXContent(content));
}
{
await pagesInteraction.defer(getFlags(options));
const maybePromise = page.render(pageContext);
const content = isPromise(maybePromise) ? await maybePromise : maybePromise;
const message = await pagesInteraction.createFollowup({
flags: getFlags(options),
...wrapJSXContent(content),
});
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';
}
function wrapJSXContent(content: any) {
if ('type' in content) {
return { components: [content] };
}
return content;
}

View File

@@ -0,0 +1,99 @@
import type { PartialEmoji } from '@projectdysnomia/dysnomia';
import { actionRow, button, gallery, 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 button(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 = actionRow(...renderSubrouteButtons(current.value, currentSubroutes, index, prefix, opts, btnOptions));
if (current.banner) {
components.push(gallery({ 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;
}

View File

@@ -0,0 +1,28 @@
import type { ChatInputApplicationCommandStructure, ApplicationCommandStructure } from '@projectdysnomia/dysnomia';
import type { ExecutableInteraction } from './interaction.type';
import type { Cache } from '@/discord/core/cache.type';
import type { KVStore } from '@/discord/core/kv-store.type.ts';
import type { Client } from '@projectdysnomia/dysnomia';
export interface CommandState<T = any> {
id: string; // unique id for this command instance
name: string; // command name
data: T; // internal data storage
}
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>>;
export type ChatCommandDefinition = Omit<ChatInputApplicationCommandStructure, 'type'>;
export interface CommandHandler<T extends ApplicationCommandStructure> {
definition: T;
execute: (interaction: ExecutableInteraction, ctx: CommandContext) => Promise<void>;
}

View File

@@ -0,0 +1,2 @@
export * from './command.type';
export * from './interaction.type';

View File

@@ -0,0 +1,25 @@
import type Dysnomia from '@projectdysnomia/dysnomia';
import type { StarKittenElement } from '../jsx';
export type Interaction = CommandInteraction | ModalSubmitInteraction | ComponentInteraction | AutocompleteInteraction | PingInteraction;
export type ExecutableInteraction = CommandInteraction | ModalSubmitInteraction | ComponentInteraction | AutocompleteInteraction;
export interface InteractionAugments {
isApplicationCommand: () => this is Dysnomia.CommandInteraction;
isModalSubmit: () => this is Dysnomia.ModalSubmitInteraction;
isMessageComponent: () => this is Dysnomia.ComponentInteraction;
isAutocomplete: () => this is Dysnomia.AutocompleteInteraction;
isPing: () => this is Dysnomia.PingInteraction;
isExecutable: () => this is ExecutableInteraction;
createJSXMessage: (component: StarKittenElement) => Promise<Dysnomia.Message>;
editJSXMessage: (messageID: string, component: StarKittenElement) => Promise<Dysnomia.Message>;
createJSXFollowup: (component: StarKittenElement) => Promise<Dysnomia.Message>;
createJSXModal: (component: StarKittenElement) => Promise<void>;
}
export type CommandInteraction = Dysnomia.CommandInteraction & InteractionAugments;
export type ModalSubmitInteraction = Dysnomia.ModalSubmitInteraction & InteractionAugments;
export type ComponentInteraction = Dysnomia.ComponentInteraction & InteractionAugments;
export type AutocompleteInteraction = Dysnomia.AutocompleteInteraction & InteractionAugments;
export type PingInteraction = Dysnomia.PingInteraction & InteractionAugments;

View File

@@ -0,0 +1,14 @@
import { drizzle } from 'drizzle-orm/bun-sqlite';
import { Database } from 'bun:sqlite';
import { join } from 'node:path';
import { characters, resumeCommands, users, miningFleets, miningFleetParticipants } from './schema'; // Added mining tables
export const DB_PATH = process.env.AUTH_DB_PATH || join(process.cwd(), '../../db/kitten.db');
console.log('Using DB_PATH:', DB_PATH);
export * as schema from './schema';
export * as models from './models';
export * from './models';
// 'D:\\dev\\@star-kitten\\db\\kitten.db'
const sqlite = new Database(DB_PATH);
export const db = drizzle(sqlite, { schema: { users, characters, resumeCommands, miningFleets, miningFleetParticipants } });

View File

@@ -0,0 +1,9 @@
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { join } from 'node:path';
import { drizzle } from "drizzle-orm/bun-sqlite";
import { Database } from "bun:sqlite";
import { DB_PATH } from '.';
const sqlite = new Database(DB_PATH);
const db = drizzle(sqlite);
migrate(db, { migrationsFolder: join(process.cwd(), '/drizzle') });

View File

@@ -0,0 +1,186 @@
import type { User } from './user.model';
import { jwtDecode } from 'jwt-decode';
import { characters } from '../schema';
import { eq, and } from 'drizzle-orm';
import { db } from '..';
import { ESI_SCOPE, refresh, verify, type EveTokens } from '@/eve/oauth';
import { options } from '@/eve/esi';
export interface Character {
id: number;
eveID: number;
userID: number;
accessToken: string;
expiresAt: Date;
refreshToken: string;
name: string;
createdAt: Date;
updatedAt?: Date;
}
export class CharacterHelper {
public static hasValidToken(character: Character) {
return new Date() < character.expiresAt;
}
public static getScopes(character: Character) {
const decoded = jwtDecode(character.accessToken) as {
scp: string[] | string;
};
return typeof decoded.scp === 'string' ? [decoded.scp] : decoded.scp;
}
public static hasOnlyPublicScope(character: Character) {
return this.getScopes(character).length === 1 && this.hasScope(character, 'publicData');
}
public static getTokens(character: Character) {
return {
access_token: character.accessToken,
refresh_token: character.refreshToken,
expires_in: (character.expiresAt.getTime() - Date.now()) / 1000,
};
}
public static hasScope(character: Character, scope: string) {
return this.getScopes(character).includes(scope);
}
public static hasAllScopes(character: Character, scopes: string[]) {
const has = this.getScopes(character);
return scopes.every((scope) => has.includes(scope));
}
public static find(id: number) {
const result = db.select().from(characters).where(eq(characters.id, id)).limit(1).get();
const c = this.createCharacters(result);
return c ? c[0] : undefined;
}
public static findByUser(user: User) {
const result = db.select().from(characters).where(eq(characters.userID, user.id)).all();
return this.createCharacters(result);
}
public static findByUserAndEveID(userID: number, eveID: number) {
const result = db
.select()
.from(characters)
.where(and(eq(characters.userID, userID), eq(characters.eveID, eveID)))
.limit(1)
.get();
const c = this.createCharacters(result);
return c ? c[0] : undefined;
}
public static findByName(userID: number, name: string) {
const result = db
.select()
.from(characters)
.where(and(eq(characters.name, name), eq(characters.userID, userID)))
.limit(1)
.get();
const c = this.createCharacters(result);
return c ? c[0] : undefined;
}
public static findAll() {
const result = db.select().from(characters).all();
return this.createCharacters(result);
}
static create(eveID: number, name: string, user: User, tokens: EveTokens) {
return this.save({
eveID: eveID,
userID: user.id,
accessToken: tokens.access_token,
expiresAt: new Date(tokens.expires_in * 1000),
refreshToken: tokens.refresh_token,
name: name,
createdAt: new Date(),
} as Character);
}
static createCharacters(query: any): Character[] {
if (!query) return [];
if (Array.isArray(query)) {
return query.map((character: any) => {
return {
id: character.id,
eveID: character.eveID,
userID: character.userID,
accessToken: character.accessToken,
expiresAt: new Date(character.expiresAt),
refreshToken: character.refreshToken,
name: character.name,
createdAt: new Date(character.createdAt),
updatedAt: new Date(character.updatedAt),
};
});
} else {
return [
{
id: query.id,
eveID: query.eveID,
userID: query.userID,
accessToken: query.accessToken,
expiresAt: new Date(query.expiresAt),
refreshToken: query.refreshToken,
name: query.name,
createdAt: new Date(query.createdAt),
updatedAt: new Date(query.updatedAt),
},
];
}
}
public static save(character: Character) {
db.insert(characters)
.values({
id: character.id,
eveID: character.eveID,
userID: character.userID,
name: character.name,
accessToken: character.accessToken,
expiresAt: character.expiresAt.getTime(),
refreshToken: character.refreshToken,
createdAt: Date.now(),
updatedAt: Date.now(),
})
.onConflictDoUpdate({
target: characters.id,
set: {
eveID: character.eveID,
userID: character.userID,
name: character.name,
accessToken: character.accessToken,
expiresAt: character.expiresAt.getTime(),
refreshToken: character.refreshToken,
updatedAt: Date.now(),
},
})
.run();
return CharacterHelper.findByUserAndEveID(character.userID, character.eveID);
}
public static delete(character: Character) {
db.delete(characters).where(eq(characters.id, character.id)).run();
}
public static async refreshTokens(character: Character, scopes?: ESI_SCOPE[] | ESI_SCOPE) {
const tokens = await refresh(
{ refresh_token: character.refreshToken },
{ scopes, clientId: options.client_id, clientSecret: options.client_secret },
);
const decoded = await verify(tokens.access_token);
if (!decoded) {
console.error(`Failed to validate token for character ${character.eveID}`);
return character;
}
character.accessToken = tokens.access_token;
character.expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
character.refreshToken = tokens.refresh_token;
this.save(character);
return character;
}
}

View File

@@ -0,0 +1,3 @@
export * from './user.model';
export * from './character.model';
export * from './resume-command.model';

View File

@@ -0,0 +1,75 @@
import { eq } from 'drizzle-orm';
import { db } from '..';
import { resumeCommands } from '../schema';
export class ResumeCommand {
id!: string;
command!: string;
params!: string;
context!: string;
created: Date = new Date();
private constructor() {
this.created = new Date();
}
public static find(messageId: string) {
const result = db.select().from(resumeCommands)
.where(eq(resumeCommands.id, messageId))
.get();
return this.createFromQuery(result);
}
static create(messageId: string, command: string, params: any = {}, context: any = {}) {
const resume = new ResumeCommand();
resume.id = messageId;
resume.command = command;
resume.params = JSON.stringify(params);
resume.context = JSON.stringify(context);
return resume;
}
static createFromQuery(query: any) {
if (!query) return null;
const resume = new ResumeCommand();
resume.id = query.id;
resume.command = query.command;
resume.params = query.params;
resume.context = query.context;
resume.created = query.created;
return resume;
}
public save() {
db.insert(resumeCommands)
.values({
id: this.id,
command: this.command,
params: this.params,
context: this.context,
createdAt: this.created.getTime(),
})
.onConflictDoUpdate({
target: resumeCommands.id,
set: {
command: this.command,
params: this.params,
context: this.context,
},
}).run();
return this;
}
public delete() {
db.delete(resumeCommands)
.where(eq(resumeCommands.id, this.id))
.run();
}
static delete(messageId: string) {
db.delete(resumeCommands)
.where(eq(resumeCommands.id, messageId))
.run();
}
}

View File

@@ -0,0 +1,155 @@
import { CharacterHelper } from './character.model';
import { db } from '..';
import { characters, users } from '../schema';
import { eq, sql } from 'drizzle-orm';
export interface User {
id: number;
discordID: string;
createdAt: Date;
updatedAt: Date;
characterIDs: number[];
mainCharacterID?: number;
}
export class UserHelper {
public static find(id: number) {
const result = db.select({
id: users.id,
discordID: users.discordID,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
mainCharacterID: users.mainCharacter,
characterIDsString: sql<string>`json_group_array(characters.id)`,
}).from(users)
.where(eq(users.id, id))
.leftJoin(characters, eq(users.id, characters.userID))
.get();
return this.createFromQuery(result) as User;
}
public static findByDiscordId(id: string) {
const result = db.select({
id: users.id,
discordID: users.discordID,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
mainCharacterID: users.mainCharacter,
characterIDsString: sql<string>`json_group_array(characters.id)`,
}).from(users)
.where(eq(users.discordID, id))
.leftJoin(characters, eq(users.id, characters.userID))
.get();
return this.createFromQuery(result) as User;
}
public static findAll() {
const result = db.select({
id: users.id,
discordID: users.discordID,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
mainCharacterID: users.mainCharacter,
characterIDsString: sql<string>`json_group_array(characters.id)`,
}).from(users)
.leftJoin(characters, eq(users.id, characters.userID))
.all();
return this.createFromQuery(result) as User[];
}
public static findByCharacterId(id: number) {
const result = db.select({
id: users.id,
discordID: users.discordID,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
mainCharacterID: users.mainCharacter,
characterIDsString: sql<string>`json_group_array(characters.id)`,
}).from(users)
.leftJoin(characters, eq(users.id, characters.userID))
.where(eq(characters.id, id))
.all();
return this.createFromQuery(result) as User;
}
public static findByCharacterName(name: string) {
const result = db.select({
id: users.id,
discordID: users.discordID,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
mainCharacterID: users.mainCharacter,
characterIDsString: sql<string>`json_group_array(characters.id)`,
}).from(users)
.leftJoin(characters, eq(users.id, characters.userID))
.where(eq(characters.name, name))
.all();
return this.createFromQuery(result) as User;
}
public static createFromQuery(query: any): User | User[] {
if (!query) return [];
if (Array.isArray(query)) {
return query.map((user: any) => {
return {
id: user.id,
discordID: user.discordID,
createdAt: new Date(user.createdAt),
updatedAt: new Date(user.updatedAt),
characterIDs: user.characterIDsString ? (JSON.parse(user.characterIDsString as any ?? '[]') as any[]).map(s => Number(s)).sort() : [],
mainCharacterID: user.mainCharacterID,
};
});
} else {
return {
id: query.id,
discordID: query.discordID,
createdAt: new Date(query.createdAt),
updatedAt: new Date(query.updatedAt),
characterIDs: query.characterIDsString ? (JSON.parse(query.characterIDsString as any ?? '[]') as any[]).map(s => Number(s)).sort() : [],
mainCharacterID: query.mainCharacterID,
};
}
}
public static create(discordID: string): User {
this.save({
discordID: discordID,
createdAt: new Date(),
updatedAt: new Date(),
} as User);
return this.findByDiscordId(discordID);
}
public static save(user: User) {
db.insert(users)
.values({
id: user.id,
discordID: user.discordID,
mainCharacter: user.mainCharacterID,
createdAt: user.createdAt.getTime(),
updatedAt: user.updatedAt.getTime(),
})
.onConflictDoUpdate({
target: users.id,
set: {
discordID: user.discordID,
mainCharacter: user.mainCharacterID,
updatedAt: user.updatedAt.getTime(),
},
}).run();
return user;
}
public static delete(user: User) {
db.delete(users)
.where(eq(users.id, user.id))
.run();
}
public static getCharacter(user: User, index: number) {
if (!user.characterIDs) return undefined;
if (index >= user.characterIDs.length) return undefined;
return CharacterHelper.find(user.characterIDs[index]);
}
}

View File

@@ -0,0 +1,108 @@
import { sqliteTable, text, integer, index, real } from 'drizzle-orm/sqlite-core';
import { relations } from 'drizzle-orm';
export const shared = {
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at'),
};
export const users = sqliteTable('users', {
id: integer().primaryKey().unique().notNull(),
discordID: text('discord_id').unique().notNull(),
mainCharacter: integer('main_character'),
...shared,
}, (table) => [
index('idx_discord_id').on(table.discordID),
index('idx_main_character').on(table.mainCharacter),
]);
export const usersRelations = relations(users, ({ one, many }) => ({
characters: many(characters),
main: one(characters, {
fields: [users.mainCharacter],
references: [characters.id]
}),
}));
export const characters = sqliteTable('characters', {
id: integer('id').primaryKey({ autoIncrement: true }),
eveID: integer('eve_id').notNull(),
userID: integer('user_id').notNull(),
name: text().notNull(),
accessToken: text('access_token').notNull(),
expiresAt: integer('expires_at').notNull(),
refreshToken: text('refresh_token').notNull(),
...shared,
}, (table) => [
index('idx_user_id').on(table.userID),
index('idx_eve_id').on(table.eveID),
]);
export const charactersRelations = relations(characters, ({ one }) => ({
user: one(users, {
fields: [characters.userID],
references: [users.id],
}),
}));
export const resumeCommands = sqliteTable('resumecommands', {
id: text().primaryKey(),
command: text().notNull(),
params: text().notNull(),
context: text().notNull(),
...shared,
});
// --- Mining Fleet Module ---
export const miningFleets = sqliteTable('mining_fleets', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
description: text('description'),
creatorDiscordId: text('creator_discord_id').notNull(),
startTime: integer('start_time').notNull(),
endTime: integer('end_time'),
status: text('status', { enum: ['configuring', 'active', 'ended', 'generating_report', 'completed', 'failed'] }).notNull().default('configuring'),
taxRate: real('tax_rate').notNull().default(0),
publicMessageId: text('public_message_id').unique(),
publicChannelId: text('public_channel_id'),
reportData: text('report_data'), // Store as JSON string
creatorEphemeralMessageId: text('creator_ephemeral_message_id'),
...shared,
}, (table) => [
index('idx_fleet_creator_discord_id').on(table.creatorDiscordId),
index('idx_fleet_status').on(table.status),
index('idx_fleet_public_message_id').on(table.publicMessageId),
]);
export const miningFleetParticipants = sqliteTable('mining_fleet_participants', {
id: integer('id').primaryKey({ autoIncrement: true }),
fleetId: integer('fleet_id').notNull().references(() => miningFleets.id, { onDelete: 'cascade' }), // Cascade delete participants if fleet is deleted
characterId: integer('character_id').notNull().references(() => characters.id, { onDelete: 'cascade' }), // Reference characters table PK
discordId: text('discord_id').notNull(), // Discord ID of the user who added the character
role: text('role', { enum: ['miner', 'security', 'hauler'] }).notNull(),
joinTime: integer('join_time').notNull(),
...shared,
}, (table) => [
index('idx_participant_fleet_id').on(table.fleetId),
index('idx_participant_character_id').on(table.characterId),
index('idx_participant_discord_id').on(table.discordId),
]);
export const miningFleetsRelations = relations(miningFleets, ({ many }) => ({
participants: many(miningFleetParticipants),
}));
export const miningFleetParticipantsRelations = relations(miningFleetParticipants, ({ one }) => ({
fleet: one(miningFleets, {
fields: [miningFleetParticipants.fleetId],
references: [miningFleets.id],
}),
character: one(characters, {
fields: [miningFleetParticipants.characterId],
references: [characters.id],
}),
}));

View File

@@ -0,0 +1,59 @@
/**
* EVE Swagger Interface - Alliance Endpoints
* https://developers.eveonline.com/api-explorer#/operations/GetAlliances
*/
import { esiFetch, type PublicEsiOptions } from './util/fetch';
/**
* List all active player alliances
* - This route is cached for an hour
* @returns {number[]} - An array of all active player alliance ids
*/
export async function listAlliances(options?: PublicEsiOptions) {
return await esiFetch<number[]>('/alliances/', options);
}
interface AllianceInfo {
creator_corporation_id: number;
creator_id: number;
date_founded: string;
executor_corporation_id: number;
faction_id: number;
name: string;
ticker: string;
}
/**
* Get information about a specific alliance
* - This route is cached for an hour
* @param alliance_id Alliance id
* @returns {AllianceInfo}
*/
export async function getAllianceInformation(alliance_id: number, options?: PublicEsiOptions) {
return await esiFetch<Partial<AllianceInfo>>(`/alliances/${alliance_id}/`, options);
}
/**
* List all corporations in an alliance
* - This route is cached for an hour
* @param alliance_id Alliance id
* @returns {number[]} - Array of corporation ids
*/
export async function listAllianceCorporations(alliance_id: number, options?: PublicEsiOptions) {
return await esiFetch<number[]>(`/alliances/${alliance_id}/corporations/`, options);
}
interface AllianceIcon {
px128x128: string;
px64x64: string;
}
/**
* Get alliance icon
* - This route is cached for an hour
* @param alliance_id Alliance id
* @returns {AllianceIcon}
*/
export async function getAllianceIcon(alliance_id: number, options?: PublicEsiOptions) {
return await esiFetch<Partial<AllianceIcon>>(`/alliances/${alliance_id}/icons/`, options);
}

View File

@@ -0,0 +1,128 @@
/**
* EVE ESI Assets API
* https://developers.eveonline.com/api-explorer#/operations/GetCharactersCharacterIdAssets
*/
import { ESI_SCOPE } from '../oauth';
import { ESI_RATE_LIMIT_GROUP } from './util/rate-limits';
import { checkScopesAndGetCharacterId, esiFetch, type EsiOptions } from './util/fetch';
import type { LocationFlag } from './types/location-flag';
export enum AssetLocationType {
STATION = 'station',
SOLAR_SYSTEM = 'solar_system',
ITEM = 'item',
OTHER = 'other',
}
export interface Asset {
is_blueprint_copy: boolean;
is_singleton: boolean;
item_id: number;
location_flag: LocationFlag;
location_id: number;
location_type: AssetLocationType;
quantity: number;
type_id: number;
}
export function getCharacterAssets(options: EsiOptions, page: number = 1) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-assets.read_assets.v1']);
return esiFetch<Partial<Asset>[]>(`/characters/${character_id}/assets/?page=${page}`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_ASSET,
});
}
export interface AssetLocation {
item_id: number;
position: {
x: number;
y: number;
z: number;
};
}
export function getCharacterAssetLocations(options: EsiOptions, ids: number[]) {
if (ids.length === 0) return [];
if (ids.length > 1000) throw 'Maximum of 1000 IDs can be requested at once';
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-assets.read_assets.v1']);
return esiFetch<Partial<AssetLocation>[]>(`/characters/${character_id}/assets/locations/`, {
method: 'POST',
body: JSON.stringify(ids),
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_ASSET,
});
}
export interface AssetNames {
item_id: number;
name: string;
}
export function getCharacterAssetNames(options: EsiOptions, ids: number[]) {
if (ids.length === 0) return [];
if (ids.length > 1000) throw 'Maximum of 1000 IDs can be requested at once';
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-assets.read_assets.v1']);
return esiFetch<Partial<AssetNames>[]>(`/characters/${character_id}/assets/names/`, {
...options,
method: 'POST',
body: JSON.stringify(ids),
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_ASSET,
});
}
export interface CorpAsset {
is_blueprint_copy: boolean;
is_singleton: boolean;
item_id: number;
location_flag: LocationFlag;
location_id: number;
location_type: 'station' | 'solar_system' | 'item' | 'other';
quantity: number;
type_id: number;
}
export async function getCorporationAssets(options: EsiOptions, corporation_id: number, page: number = 1) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-assets.read_corporation_assets.v1']);
return await esiFetch<Partial<CorpAsset>[]>(`/corporations/${corporation_id}/assets/?page=${page}`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CORP_ASSET,
});
}
export interface AssetLocation {
item_id: number;
position: {
x: number;
y: number;
z: number;
};
}
export async function getCorporationAssetLocations(options: EsiOptions, corporation_id: number, ids: number[]) {
if (ids.length === 0) return [];
if (ids.length > 1000) throw 'Maximum of 1000 IDs can be requested at once';
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-assets.read_corporation_assets.v1']);
return await esiFetch<Partial<AssetLocation>[]>(`/corporations/${corporation_id}/assets/locations/`, {
...options,
method: 'POST',
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CORP_ASSET,
});
}
export interface AssetNames {
item_id: number;
name: string;
}
export async function getCorporationAssetNames(options: EsiOptions, id: number, ids: number[]) {
if (ids.length === 0) return [];
if (ids.length > 1000) throw 'Maximum of 1000 IDs can be requested at once';
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-assets.read_corporation_assets.v1']);
return await esiFetch<Partial<AssetNames>[]>(`/corporations/${id}/assets/names/`, {
...options,
method: 'POST',
body: JSON.stringify(ids),
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CORP_ASSET,
});
}

View File

@@ -0,0 +1,113 @@
/**
* EVE ESI Caldendar Module
*
* This module provides functions to interact with EVE Online's ESI Calendar API,
* allowing retrieval and management of calendar events for characters.
*
* ref: https://developers.eveonline.com/api-explorer#/operations/GetCharactersCharacterIdCalendar
*/
import { checkScopesAndGetCharacterId, esiFetch, type EsiOptions } from './util/fetch';
import { tokenHasScopes } from '../oauth/eve-auth';
import { ESI_SCOPE } from '../oauth';
import { ESI_RATE_LIMIT_GROUP } from './util/rate-limits';
export interface CalendarEvent {
event_date: string; // date-time string
event_id: number;
event_response: 'accepted' | 'declined' | 'tentative' | 'no_response';
importance: number;
title: string;
}
/**
* List calendar event summaries for a character.
*
* Get 50 event summaries from the calendar. If no from_event ID is given, the resource will
* return the next 50 chronological event summaries from now. If a from_event ID is
* specified, it will return the next 50 chronological event summaries from after that event
* - cached for 5 seconds
*
* @param options EsiOptions
* @param from_event Event from which to get the next 50 chronological event summaries
* @returns {Partial<CalendarEvent>[]}
*/
export async function listCalendarEventSummaries(options: EsiOptions, from_event?: number) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-calendar.read_calendar_events.v1']);
return await esiFetch<Partial<CalendarEvent>[]>(`/characters/${character_id}/calendar/${from_event ?? '?from_event=' + from_event}`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_SOCIAL,
});
}
export interface CalendarEventDetails {
date: string; // date-time string
duration: number; // in minutes
event_id: number;
importance: number;
owner_id: number;
owner_name: string;
owner_type: 'eve_server' | 'corporation' | 'faction' | 'alliance' | 'character';
response: 'accepted' | 'declined' | 'tentative' | 'no_response';
text: string;
title: string;
}
/**
* Get an event's details by its ID.
*
* Get all the information for a specific event.
* - cached for 5 seconds
*
* @param options EsiOptions
* @param event_id Event Id
* @returns {Partial<CalendarEventDetails>}
*/
export async function getEventDetails(options: EsiOptions, event_id: number) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-calendar.read_calendar_events.v1']);
return await esiFetch<Partial<CalendarEventDetails>>(`/characters/${character_id}/calendar/${event_id}/`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_SOCIAL,
});
}
/**
* Respond to a calendar event.
*
* Accept, decline, or tentatively accept an event invitation.
*
* @param options EsiOptions
* @param event_id Event Id
* @param response Response: 'accepted' | 'declined' | 'tentative'
*/
export async function respondToEvent(options: EsiOptions, event_id: number, response: 'accepted' | 'declined' | 'tentative') {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-calendar.respond_calendar_events.v1']);
return await esiFetch<void>(`/characters/${character_id}/calendar/${event_id}/`, {
...options,
method: 'PUT',
body: JSON.stringify({ response }),
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_SOCIAL,
});
}
export interface CalendarEventAttendee {
character_id: number;
event_response: 'accepted' | 'declined' | 'tentative' | 'no_response';
}
/**
* Get the attendees of a calendar event.
*
* Get the list of attendees for a specific event.
* - cached for 5 seconds
*
* @param options EsiOptions
* @param event_id Event Id
* @returns {Partial<CalendarEventAttendee>[]}
*/
export async function getEventAttendees(options: EsiOptions, event_id: number) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-calendar.read_calendar_events.v1']);
return await esiFetch<Partial<CalendarEventAttendee>[]>(`/characters/${character_id}/calendar/${event_id}/attendees/`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_SOCIAL,
});
}

View File

@@ -0,0 +1,220 @@
import { ESI_SCOPE } from '../oauth';
import { ESI_RATE_LIMIT_GROUP } from './util/rate-limits';
import type { NotificationType } from './types/notification-type';
import { checkScopesAndGetCharacterId, esiFetch, type EsiOptions, type PublicEsiOptions } from './util/fetch';
import type { Blueprint } from './types/shared';
export interface CharacterAffiliations {
character_id: number;
corporation_id: number;
alliance_id?: number;
faction_id?: number;
}
export function getCharacterAffiliations(character_ids: number[], options?: PublicEsiOptions) {
if (character_ids.length === 0) return [];
if (character_ids.length > 1000) throw 'Maximum of 1000 character IDs can be requested at once';
return esiFetch<Partial<CharacterAffiliations>[]>(`/characters/affiliation/`, {
...options,
method: 'POST',
body: JSON.stringify(character_ids),
});
}
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, options?: PublicEsiOptions) {
return esiFetch<Partial<CharacterData>>(`/characters/${id}/`, options);
}
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(options: EsiOptions) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-characters.read_agents_research.v1']);
return esiFetch<Partial<AgentResearch>[]>(`/characters/${character_id}/agents_research/`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_INDUSTRY,
});
}
// required scope: esi-characters.read_blueprints.v1
export function getCharacterBlueprints(options: EsiOptions, page: number = 1) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-characters.read_blueprints.v1']);
return esiFetch<Partial<Blueprint>[]>(`/characters/${character_id}/blueprints/?page=${page}`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_INDUSTRY,
});
}
export interface CharacterCorporationHistory {
corporation_id: number;
is_deleted: boolean;
record_id: number; // An incrementing ID that can be used to order records where start_date is ambiguous
start_date: string;
}
export function getCharacterCorporationHistory(options: EsiOptions) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-characters.read_corporation_roles.v1']);
return esiFetch<Partial<CharacterCorporationHistory>[]>(`/characters/${character_id}/corporationhistory/`, options);
}
export function calculateCSPAChargeCost(options: EsiOptions, target_character_ids: number[]) {
if (target_character_ids.length === 0) return null;
if (target_character_ids.length > 100) throw 'Maximum of 100 target character IDs can be requested at once';
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-characters.read_cspa.v1']);
return esiFetch<number[]>(`/characters/${character_id}/cspa/`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_DETAIL,
method: 'POST',
body: JSON.stringify(target_character_ids),
});
}
export interface JumpFatigue {
jump_fatigue_expire_date: string;
last_jump_date: string;
last_update_date: string;
}
export function getCharacterJumpFatigue(options: EsiOptions) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-characters.read_fatigue.v1']);
return esiFetch<Partial<JumpFatigue>>(`/characters/${character_id}/fatigue/`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_DETAIL,
});
}
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;
}
export function getCharacterMedals(options: EsiOptions) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-characters.read_medals.v1']);
return esiFetch<Partial<Medals>[]>(`/characters/${character_id}/medals/`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_DETAIL,
});
}
export interface Notification {
is_read: boolean;
notification_id: number;
sender_id: number;
sender_type: 'character' | 'corporation' | 'alliance' | 'faction' | 'other';
text: string;
timestamp: string;
type: NotificationType;
}
export function getCharacterNotifications(options: EsiOptions) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-characters.read_notifications.v1']);
return esiFetch<Partial<Notification>[]>(`/characters/${character_id}/notifications/`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_NOTIFICATION,
});
}
export interface ContactNotification {
message: string;
notification_id: number;
send_date: string;
sender_character_id: number;
standing_level: -10 | -5 | 0 | 5 | 10;
}
export function getCharacterContactNotifications(options: EsiOptions) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-characters.read_notifications.v1']);
return esiFetch<Partial<ContactNotification>[]>(`/characters/${character_id}/notifications/contacts`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_SOCIAL,
});
}
export interface CharacterPortraits {
px128x128: string;
px256x256: string;
px512x512: string;
px64x64: string;
}
export function getCharacterPortraits(character_id: number, options?: PublicEsiOptions) {
return esiFetch<Partial<CharacterPortraits>>(`/characters/${character_id}/portrait/`, options);
}
export function getPortraitURL(character_id: number) {
return `https://images.evetech.net/characters/${character_id}/portrait`;
}
export interface CharacterCorporationRoles {
roles: string[];
roles_at_base: string[];
roles_at_hq: string[];
roles_at_other: string[];
}
export function getCharacterCorporationRoles(options: EsiOptions) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-characters.read_corporation_roles.v1']);
return esiFetch<Partial<CharacterCorporationRoles>>(`/characters/${character_id}/roles`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_DETAIL,
});
}
export interface CharacterStandings {
from_id: number;
from_type: 'agent' | 'npc_corp' | 'faction';
standing: number;
}
export function getCharacterStandings(options: EsiOptions) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-characters.read_standings.v1']);
return esiFetch<Partial<CharacterStandings>[]>(`/characters/${character_id}/standings`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_SOCIAL,
});
}
export interface CharacterTitles {
titles: {
name: string;
title_id: number;
}[];
}
export function getCharacterTitles(options: EsiOptions) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-characters.read_titles.v1']);
return esiFetch<Partial<CharacterTitles>>(`/characters/${character_id}/titles`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_DETAIL,
});
}

View File

@@ -0,0 +1,35 @@
import { ESI_SCOPE } from '../oauth/auth.types';
import { checkScopesAndGetCharacterId, esiFetch, type EsiOptions } from './util/fetch';
import { ESI_RATE_LIMIT_GROUP } from './util/rate-limits';
export interface CharacterClones {
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;
}
export function getCharacterClones(options: EsiOptions) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-clones.read_clones.v1']);
return esiFetch<Partial<CharacterClones>>(`/characters/${character_id}/clones`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_LOCATION,
});
}
export function getCharacterActiveImplants(options: EsiOptions) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-clones.read_implants.v1']);
return esiFetch<number[]>(`/characters/${character_id}/implants`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_DETAIL,
});
}

View File

@@ -0,0 +1,133 @@
import { ESI_SCOPE } from '../oauth/auth.types';
import type { STANDING } from './types/shared';
import { checkScopesAndGetCharacterId, esiFetch, type EsiOptions } from './util/fetch';
import { ESI_RATE_LIMIT_GROUP } from './util/rate-limits';
export interface Contact {
contact_id: number;
contact_type: 'character' | 'corporation' | 'alliance' | 'faction';
label_ids?: number[];
standing: STANDING;
}
export function getAllianceContacts(options: EsiOptions, alliance_id: number, page: number = 1) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-alliances.read_contacts.v1']);
return esiFetch<Contact[]>(`/alliances/${alliance_id}/contacts/?page=${page}`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.ALLIANCE_SOCIAL,
});
}
export interface ContactLabel {
label_id: number;
name: string;
}
export function getAllianceContactLabels(options: EsiOptions, alliance_id: number) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-alliances.read_contacts.v1']);
return esiFetch<ContactLabel[]>(`/alliances/${alliance_id}/contacts/labels/`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.ALLIANCE_SOCIAL,
});
}
export function deleteCharacterContacts(options: EsiOptions, character_ids: number[]) {
if (character_ids.length === 0) return;
if (character_ids.length > 20) throw 'Maximum of 20 IDs can be deleted at once';
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-characters.write_contacts.v1']);
return esiFetch<void>(`/characters/${character_id}/contacts/`, {
method: 'DELETE',
body: JSON.stringify({ contact_ids: character_ids }),
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_SOCIAL,
});
}
export interface CharacterContact extends Contact {
is_blocked?: boolean;
is_watched?: boolean;
}
export function getCharacterContacts(options: EsiOptions, page: number = 1) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-characters.read_contacts.v1']);
return esiFetch<CharacterContact[]>(`/characters/${character_id}/contacts/?page=${page}`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_SOCIAL,
});
}
export function addCharacterContacts(
options: EsiOptions,
character_ids: number[],
standing: STANDING,
label_ids?: number[],
watched?: boolean,
) {
if (character_ids.length === 0) return;
if (character_ids.length > 100) throw 'Maximum of 100 IDs can be added at once';
if (label_ids && label_ids.length > 63) throw 'Maximum of 63 label IDs can be assigned at once';
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-characters.write_contacts.v1']);
return esiFetch<void>(
`/characters/${character_id}/contacts?standing=${standing}${watched ? '&watched=true' : ''}${
label_ids ? `&label_ids=${label_ids.join(',')}` : ''
}`,
{
...options,
method: 'POST',
body: JSON.stringify(character_ids),
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_SOCIAL,
},
);
}
export function editCharacterContacts(
options: EsiOptions,
character_ids: number[],
standing: STANDING,
label_ids?: number[],
watched?: boolean,
) {
if (character_ids.length === 0) return;
if (character_ids.length > 100) throw 'Maximum of 100 IDs can be edited at once';
if (label_ids && label_ids.length > 63) throw 'Maximum of 63 label IDs can be assigned at once';
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-characters.write_contacts.v1']);
return esiFetch<void>(
`/characters/${character_id}/contacts?standing=${standing}${watched ? '&watched=true' : ''}${
label_ids ? `&label_ids=${label_ids.join(',')}` : ''
}`,
{
...options,
method: 'PUT',
body: JSON.stringify(character_ids),
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_SOCIAL,
},
);
}
export function getCharacterContactLabels(options: EsiOptions) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-characters.read_contacts.v1']);
return esiFetch<Partial<ContactLabel>[]>(`/characters/${character_id}/contacts/labels/`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_SOCIAL,
});
}
export interface CorporationContact extends Contact {
is_watched?: boolean;
}
export function getCorporationContacts(options: EsiOptions, corporation_id: number, page: number = 1) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-corporations.read_contacts.v1']);
return esiFetch<CorporationContact[]>(`/corporations/${corporation_id}/contacts/?page=${page}`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CORP_SOCIAL,
});
}
export function getCorporationContactLabels(options: EsiOptions, corporation_id: number) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-corporations.read_contacts.v1']);
return esiFetch<ContactLabel[]>(`/corporations/${corporation_id}/contacts/labels/`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CORP_SOCIAL,
});
}

View File

@@ -0,0 +1,135 @@
import { ESI_SCOPE } from '../oauth/auth.types';
import { ESI_RATE_LIMIT_GROUP } from './util/rate-limits';
import { checkScopesAndGetCharacterId, esiFetch, type EsiOptions, type PublicEsiOptions } from './util/fetch';
export interface PublicContract {
buyout?: number;
collateral?: number;
contract_id: number;
date_expired: string;
date_issued: string;
days_to_complete?: number;
end_location_id?: number;
for_corporation: boolean;
issuer_corporation_id: number;
issuer_id: number;
price?: number;
reward?: number;
start_location_id?: number;
title?: string;
type: 'item_exchange' | 'auction' | 'courier' | 'loan';
volume?: number;
}
export interface Contract extends PublicContract {
acceptor_id: number;
assignee_id: number;
availability: 'public' | 'personal' | 'corporation' | 'alliance';
date_accepted?: string;
date_completed?: string;
status:
| 'outstanding'
| 'in_progress'
| 'finished_issuer'
| 'finished_contractor'
| 'cancelled'
| 'rejected'
| 'failed'
| 'deleted'
| 'reversed';
}
/**
* Returns contracts available to a character, only if the character is issuer, acceptor or assignee.
* Only returns contracts no older than 30 days, or if the status is "in_progress".
*/
export function getCharacterContracts(options: EsiOptions, page: number = 1) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-contracts.read_character_contracts.v1']);
return esiFetch<Contract[]>(`/characters/${character_id}/contracts/?page=${page}`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_CONTRACT,
});
}
export interface PublicContractBid {
amount: number;
bid_id: number;
date_bid: string;
}
export interface ContractBid extends PublicContractBid {
bidder_id: number;
}
export function getContractBids(options: EsiOptions, contract_id: number) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-contracts.read_character_contracts.v1']);
return esiFetch<ContractBid[]>(`/characters/${character_id}/contracts/${contract_id}/bids/`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_CONTRACT,
});
}
export interface ContractItem {
is_included: boolean; // true if the item is included in the contract, false if it is being requested
is_singleton: boolean;
quantity: number; // number of items (for stackable items)
raw_quantity?: number; // -1 indicates that the item is a singleton (non-stackable). If the item happens to be a Blueprint, -1 is an Original and -2 is a Blueprint Copy
record_id: number; // unique ID for this item in the contract
type_id: number; // type ID of the item
}
export function getContractItems(options: EsiOptions, contract_id: number) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-contracts.read_character_contracts.v1']);
return esiFetch<ContractItem[]>(`/characters/${character_id}/contracts/${contract_id}/items/`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CHAR_CONTRACT,
});
}
export function getPublicContractBids(contract_id: number, page: number = 1, options?: PublicEsiOptions) {
return esiFetch<PublicContractBid[]>(`/contracts/public/bids/${contract_id}?page=${page}`, options);
}
export interface PublicContractItem {
is_blueprint_copy?: boolean;
is_included: boolean; // true if the item is included in the contract, false if it is being requested
item_id: number;
material_efficiency?: number; // Material efficiency level of the blueprint
quantity: number;
record_id: number; // unique ID for this item in the contract
runs?: number; // Number of runs for the blueprint
time_efficiency?: number; // Time efficiency level of the blueprint
type_id: number; // type ID of the item
}
export function getPublicContractItems(contract_id: number, page: number = 1, options?: PublicEsiOptions) {
return esiFetch<PublicContractItem[]>(`/contracts/public/items/${contract_id}?page=${page}`, options);
}
export function getPublicContracts(region_id: number, page: number = 1, options?: PublicEsiOptions) {
return esiFetch<PublicContract[]>(`/contracts/public/${region_id}?page=${page}`, options);
}
export function getCorporationContracts(options: EsiOptions, corporation_id: number, page: number = 1) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-contracts.read_corporation_contracts.v1']);
return esiFetch<Contract[]>(`/corporations/${corporation_id}/contracts/?page=${page}`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CORP_CONTRACT,
});
}
export function getCorporationContractBids(options: EsiOptions, corporation_id: number, contract_id: number) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-contracts.read_corporation_contracts.v1']);
return esiFetch<ContractBid[]>(`/corporations/${corporation_id}/contracts/${contract_id}/bids/`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CORP_CONTRACT,
});
}
export function getCorporationContractItems(options: EsiOptions, corporation_id: number, contract_id: number) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-contracts.read_corporation_contracts.v1']);
return esiFetch<ContractItem[]>(`/corporations/${corporation_id}/contracts/${contract_id}/items/`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CORP_CONTRACT,
});
}

View File

@@ -0,0 +1,67 @@
/**
* Eve Corporation Projects ESI endpoints
* ref: https://developers.eveonline.com/api-explorer
*/
import { ESI_SCOPE } from '../oauth/auth.types';
import { ESI_RATE_LIMIT_GROUP } from './util/rate-limits';
import { checkScopesAndGetCharacterId, esiFetch, type EsiOptions, type PublicEsiOptions } from './util/fetch';
import type { Blueprint, Icons, STANDING } from './types/shared';
import type { CorporationRoles } from './types/corporation';
export interface CorporationProject {
id: string; // uuid
last_modified: string;
name: string;
progress: {
current: number;
desired: number;
};
reward: {
initial: number; // original ISK amount reserved for the project
remaining: number; // ISK amount still remaining for the project
};
state: 'Unspecified' | 'Active' | 'Closed' | 'Completed' | 'Expired' | 'Deleted';
/**
* State:
* Unspecified - An unspecified state
* Active - Active and accepting contributions
* Closed - Closed by the corporation
* Completed - Completed
* Expired - Expired
* Deleted - Deleted and the details are no longer available
*/
}
export interface CorporationProjectResponse {
cursor?: {
after?: string; // cursor to use as 'after' in your next request, to continue walking fowards in time
before?: string; // cursor to use as 'before' in your next request, to continue walking backwards in time
};
projects: CorporationProject[];
}
export function listCorporationProjects(
options: EsiOptions,
corporation_id: number,
filters: {
after?: string;
before?: string;
limit?: number;
state?: 'Unspecified' | 'Active' | 'Closed' | 'Completed' | 'Expired' | 'Deleted';
} = {},
) {
const character_id = checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-corporations.read_projects.v1']);
const queryParams = new URLSearchParams();
if (filters.after) queryParams.append('after', filters.after);
if (filters.before) queryParams.append('before', filters.before);
if (filters.limit) queryParams.append('limit', filters.limit.toString());
if (filters.state) queryParams.append('state', filters.state);
return esiFetch<CorporationProjectResponse>(`/corporations/${corporation_id}/projects/?${queryParams.toString()}`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CORP_PROJECT,
});
}
// TODO: finish the remaining endpoints later

View File

@@ -0,0 +1,360 @@
/**
* Eve Corporation ESI endpoints
* ref: https://developers.eveonline.com/api-explorer#/operations/GetCorporationsNpccorps
*/
import { ESI_SCOPE } from '../oauth/auth.types';
import { ESI_RATE_LIMIT_GROUP } from './util/rate-limits';
import { checkScopesAndGetCharacterId, esiFetch, type EsiOptions, type PublicEsiOptions } from './util/fetch';
import type { Blueprint, Icons, STANDING } from './types/shared';
import type { CorporationRoles } from './types/corporation';
export async function getNpcCorporations(options?: PublicEsiOptions) {
return await esiFetch<number[]>('/corporations/npccorps', options);
}
interface CorporationInfo {
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(corporation_id: number, options?: PublicEsiOptions) {
return await esiFetch<CorporationInfo>(`/corporations/${corporation_id}/`, options);
}
interface AllianceHistory {
alliance_id?: number;
is_deleted?: boolean;
record_id: number;
start_date: string;
}
export async function getCorporationAllianceHistory(corporation_id: number, options?: PublicEsiOptions) {
return await esiFetch<AllianceHistory[]>(`/corporations/${corporation_id}/alliancehistory/`, options);
}
export async function getCorporationBlueprints(options: EsiOptions, corporation_id: number, page: number = 1) {
checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-corporations.read_blueprints.v1']);
return await esiFetch<Partial<Blueprint>[]>(`/corporations/${corporation_id}/blueprints/?page=${page}`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CORP_INDUSTRY,
});
}
export async function getAllCorporationALSCLogs(options: EsiOptions, corporation_id: number, page: number = 1) {
checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-corporations.read_container_logs.v1']);
return await esiFetch<any[]>(`/corporations/${corporation_id}/containers/logs/?page=${page}`, {
...options,
});
}
export interface CorporationDivisions {
hangar: {
division: number;
name: string;
}[];
wallet: {
division: number;
name: string;
}[];
}
export async function getCorporationDivisions(options: EsiOptions, corporation_id: number) {
checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-corporations.read_divisions.v1']);
return await esiFetch<CorporationDivisions>(`/corporations/${corporation_id}/divisions/`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CORP_WALLET,
});
}
export interface CorporationFacility {
facility_id: number;
system_id: number;
type_id: number;
}
export async function getCorporationFacilities(options: EsiOptions, corporation_id: number) {
checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-corporations.read_facilities.v1']);
return await esiFetch<CorporationFacility[]>(`/corporations/${corporation_id}/facilities/`, {
...options,
});
}
export async function getCorporationIcons(corporation_id: number, options?: PublicEsiOptions) {
return await esiFetch<Icons>(`/corporations/${corporation_id}/icons/`, options);
}
export interface CorporationMedal {
created_at: string;
createor_id: number;
description: string;
medal_id: number;
title: string;
}
export async function getCorporationMedals(options: EsiOptions, corporation_id: number, page: number = 1) {
checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-corporations.read_medals.v1']);
return await esiFetch<CorporationMedal[]>(`/corporations/${corporation_id}/medals/?page=${page}`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CORP_DETAIL,
});
}
export interface CorporationIssuedMedal {
character_id: number;
issued_at: string;
issuer_id: number;
medal_id: number;
reason: string;
status: string;
}
export async function getCorporationIssuedMedals(options: EsiOptions, corporation_id: number, page: number = 1) {
checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-corporations.read_medals.v1']);
return await esiFetch<CorporationIssuedMedal[]>(`/corporations/${corporation_id}/medals/issued/?page=${page}`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CORP_DETAIL,
});
}
export async function getCorporationMembers(options: EsiOptions, corporation_id: number) {
checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-corporations.read_corporation_membership.v1']);
return await esiFetch<number[]>(`/corporations/${corporation_id}/members/`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CORP_MEMBER,
});
}
export async function getCorporationMemberLimit(options: EsiOptions, corporation_id: number) {
checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-corporations.track_members.v1']);
return await esiFetch<number>(`/corporations/${corporation_id}/members/limit/`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CORP_MEMBER,
});
}
export interface CorporationMemberTitles {
character_id: number;
titles: number[];
}
export async function getCorporationMemberTitles(options: EsiOptions, corporation_id: number) {
checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-corporations.read_titles.v1']);
return await esiFetch<CorporationMemberTitles[]>(`/corporations/${corporation_id}/members/titles/`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CORP_MEMBER,
});
}
export interface CorporationMemberTracking {
base_id?: number;
character_id: number;
location_id?: number;
logoff_date?: string;
logon_date?: string;
ship_type_id?: number;
start_date?: string;
}
export async function getCorporationMemberTracking(options: EsiOptions, corporation_id: number) {
checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-corporations.track_members.v1']);
return await esiFetch<CorporationMemberTracking[]>(`/corporations/${corporation_id}/membertracking/`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CORP_MEMBER,
});
}
export interface CorporationMemberRole {
character_id: number;
grantable_roles: CorporationRoles[];
grantable_roles_at_base: CorporationRoles[];
grantable_roles_at_hq: CorporationRoles[];
grantable_roles_at_other: CorporationRoles[];
roles: CorporationRoles[];
roles_at_base: CorporationRoles[];
roles_at_hq: CorporationRoles[];
roles_at_other: CorporationRoles[];
}
export async function getCorporationMemberRoles(options: EsiOptions, corporation_id: number) {
checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-corporations.read_corporation_membership.v1']);
return await esiFetch<CorporationMemberRole[]>(`/corporations/${corporation_id}/members/roles/`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CORP_MEMBER,
});
}
export interface CorporationMemberRoleHistory {
changed_at: string;
character_id: number;
issuer_id: number;
new_roles: CorporationRoles[];
old_roles: CorporationRoles[];
role_type:
| 'grantable_roles'
| 'grantable_roles_at_base'
| 'grantable_roles_at_hq'
| 'grantable_roles_at_other'
| 'roles'
| 'roles_at_base'
| 'roles_at_hq'
| 'roles_at_other';
}
export async function getCorporationMemberRoleHistory(options: EsiOptions, corporation_id: number, page: number = 1) {
checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-corporations.read_corporation_membership.v1']);
return await esiFetch<CorporationMemberRoleHistory[]>(`/corporations/${corporation_id}/roles/history/?page=${page}`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CORP_MEMBER,
});
}
export interface CorporationShareholder {
share_count: number;
shareholder_id: number;
shareholder_type: 'character' | 'corporation';
}
export async function getCorporationShareholders(options: EsiOptions, corporation_id: number, page: number = 1) {
checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-wallet.read_corporation_wallets.v1']);
return await esiFetch<CorporationShareholder[]>(`/corporations/${corporation_id}/shareholders/?page=${page}`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CORP_DETAIL,
});
}
export interface CorporationStanding {
from_id: number;
from_type: 'agent' | 'npc_corp' | 'faction';
standing: STANDING;
}
export async function getCorporationStandings(options: EsiOptions, corporation_id: number, page: number = 1) {
checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-corporations.read_standings.v1']);
return await esiFetch<CorporationStanding[]>(`/corporations/${corporation_id}/standings/?page=${page}`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CORP_MEMBER,
});
}
export interface CorporationStarbase {
moon_id?: number;
onlined_since?: string;
reinforced_until?: string;
starbase_id: number;
state?: 'offline' | 'online' | 'onlining' | 'reinforced' | 'unanchoring';
system_id: number;
type_id: number;
unanchor_at?: string;
}
export async function getCorporationStarbases(options: EsiOptions, corporation_id: number, page: number = 1) {
checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-corporations.read_starbases.v1']);
return await esiFetch<CorporationStarbase[]>(`/corporations/${corporation_id}/starbases/?page=${page}`, {
...options,
});
}
export type StarbaseRole = 'alliance_member' | 'config_starbase_equipment_role' | 'corporation_member' | 'starbase_fuel_technician_role';
export interface StarbaseDetail {
allow_alliance_members: boolean;
allow_corporation_members: boolean;
anchor: StarbaseRole;
attack_if_at_war: boolean;
attach_if_other_security_status_dropping: boolean;
attack_security_status_threshold?: number;
attack_standing_threshold?: STANDING;
fuel_bay_take: StarbaseRole;
fuel_bay_view: StarbaseRole;
fuels?: {
type_id: number;
quantity: number;
}[];
offline: StarbaseRole;
online: StarbaseRole;
unanchor: StarbaseRole;
use_alliance_standings: boolean;
}
export async function getCorporationStarbaseDetail(options: EsiOptions, corporation_id: number, starbase_id: number, system_id: number) {
checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-corporations.read_starbases.v1']);
return await esiFetch<StarbaseDetail>(`/corporations/${corporation_id}/starbases/${starbase_id}/?system_id=${system_id}`, {
...options,
});
}
export interface StationService {
name: string;
state: 'online' | 'offline' | 'cleanup';
}
export interface CorporationStructure {
corporation_id: number;
fuel_expires: string;
name: string;
next_reinforce_apply: string;
next_reinforce_hour: number;
profile_id: number;
reinforce_hour: number;
services: StationService[];
state:
| 'anchor_vulnerable'
| 'anchoring'
| 'armor_reinforce'
| 'armor_vulnerable'
| 'deploy_vulnerable'
| 'fitting_invulnerable'
| 'hull_reinforce'
| 'hull_vulnerable'
| 'online_depreceated'
| 'onlining_vulnerable'
| 'shield_vulnerable'
| 'unanchored'
| 'unknown';
state_timer_end: string; // date at which the structure will move to it's next state
state_timer_start: string; // date at which the structue entered it's current state
structure_id: number;
system_id: number;
type_id: number;
unanchors_at: string;
}
export async function getCorporationStructures(options: EsiOptions, corporation_id: number, page: number = 1) {
checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-corporations.read_structures.v1']);
return await esiFetch<CorporationStructure[]>(`/corporations/${corporation_id}/structures/?page=${page}`, {
...options,
});
}
export interface CorporationTitle {
grantable_roles: CorporationRoles[];
grantable_roles_at_base: CorporationRoles[];
grantable_roles_at_hq: CorporationRoles[];
grantable_roles_at_other: CorporationRoles[];
name: string;
roles: CorporationRoles[];
roles_at_base: CorporationRoles[];
roles_at_hq: CorporationRoles[];
roles_at_other: CorporationRoles[];
title_id: number;
}
export async function getCorporationTitles(options: EsiOptions, corporation_id: number) {
checkScopesAndGetCharacterId(options, ESI_SCOPE['esi-corporations.read_titles.v1']);
return await esiFetch<CorporationTitle[]>(`/corporations/${corporation_id}/titles/`, {
...options,
rateLimitGroup: ESI_RATE_LIMIT_GROUP.CORP_DETAIL,
});
}

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1 @@
export {};

Some files were not shown because too many files have changed in this diff Show More