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,33 @@
#/-------------------[DOTENV_PUBLIC_KEY]--------------------/
#/ public-key encryption for .env files /
#/ [how it works](https://dotenvx.com/encryption) /
#/----------------------------------------------------------/
DOTENV_PUBLIC_KEY_DEVELOPMENT="02572da3d4f3a844588a944214c0e142a5a01deaa6551456af146d34b574024416"
# .env.development
#/-------------------[DOTENV_PUBLIC_KEY]--------------------/
#/ public-key encryption for .env files /
#/ [how it works](https://dotenvx.com/encryption) /
#/----------------------------------------------------------/
DOTENV_PUBLIC_KEY="02292a330aa041b5f7efc51504e0c208accba67a6877a217ab43cbb59c3c0c3e66"
# .env
DEBUG="encrypted:BMnswwe5JnAjCOEIHrmuuuOmcSKep3PBL9i1EPP+6/luAaY5Ad696SA6G1xvXV1xSf7CaIemi9y06hBVUD2mL0F1ZWYXB6LAtjQeGzG6/HnWdatUszmLg6eEkXQa5fHC+MkgnII="
PORT="encrypted:BJxm0HAN4oxgITjzqsung1vfXRfUaHOUac1NPGeQFoKpSrGPmbvfD4fkIREyO+qsD68f+Swp0yCYiB6Z7crRepAxn9qQLK0UGAuI+JHMhRP0PuK/a3FyCMe+9L+XIaMs82SnrFc="
NODE_ENV="encrypted:BKJvsZtYfKMgKzFSA6vBXj2insCZcUZrFUIYdcfd4ze5nG4qpgiOKpiBQQ6EAsrda03/wqV8xfOGfHExVx68+35+tjazC+0Qr/+YBebBvn9MGruLDYB4BeOA8H8Cl1EwW7MCULLoMn1aaS8d"
LOG_LEVEL="encrypted:BD5xrc2gWbqC7kLwh9Nu9AeJh0+23WMARX878CEpASauF4o4udsLS4qYT1y9ayVHPvWD5Nj+yWhXhDO7/LU+MmtFH9lNS9lnJrZJcMIkTTGh8l854PdXC91RXFHx9xRvVORRcLYt"
BASE_URL="encrypted:BMHCtunmFrbJgkqEHsYvbulEUkBFfB7HU1eL1Aldm4qKB7ao445FZ4v/tfwjh2P1lG+BLnJkYM3syubLmVwdtJO9JvGlYdQC+TFCtFYiQQAMjK+2pyQljt/NvhlVioHfe/k7jjSNR3IhWEeRmzyPrCHTcUo77tuLkHvGQXFbSQ=="
EVE_CLIENT_ID="encrypted:BPZqIsv1pNA1W88QK+D4Tn2jJy6Ek/5YZT377LrG8R+zFUk/MIFQ9byCgDMIzsfjAyLwaHpiDggVKLYL7+REgsJ0+Qp8u9jWjOCFtP/Z+HqOnJi5H6ZtWCoWKVOHbMsYyjUD1u6w09hqkWDUSS859UWrno8SnA4O1gNThpawuiID"
EVE_CLIENT_SECRET="encrypted:BCPsdV2aOikGdRsIZrEm+wlYo5c5/XlpCNJYWXDy+I8jYgtHsgCFeRSe7cyjOnMMxs/Wnu54xcBIzu+eOekSe6vzFBTN4DmJKc4TAROmuppUxpyAlhi+ZH6QJXNCix4kHftXVhgolaf8EfpeQYfVSjJAmJVWCF0483KoDdtX0IU1zOkq7as1EzHA6QWP"
EVE_CALLBACK_URL="encrypted:BLWHVLxXGWYPmX/FAN0GBRq7mne/fjJRql8r0EZuHPrcNzOe9CxnplYlJrnUac6MpicWp81EDc5SKIfkKlDfMOAxQvHlAWQukEN7T4XMOr2LROdtoJPV/mg634foH670ZnIp0SoMrS2jziYCYIP15GjLlBc3uMD1G2lT/L+oYaI9chz4Gi7eJ8Uu04KMK5zTPw=="
ESI_USER_AGENT="encrypted:BDSR40896Vx8+5v1VLL+Y1g7AofntT3Hky13zAM/8bO5OizB17WuJS6AGK18FiTKs76ZT7FJd8IOf++OvcPuLLlHAcq4r8W69v1fgK74Y0cpMw3eGd9+iapHGZKQJnTfKWhW33HSl+J1GqNRXCgGfzGN2BdCjDn4t4l/l5C/oSJega3f0zwt5xcLU/cH4meXpHx6CJF4FP0r3QYYY5eWvIPe+kETA4PlSmKcXivnWQA3qLk4P51/q+w="
DISCORD_APP_ID="encrypted:BO6M18yRDBLEnb9sVWZM4KJyI6HXPEt6h+g3eJhkAPAiiiZqtftXdJ5kRWH4tbZSnyREiTyKwCZmu/79UEuEKrNM/t0IvVGcYRevkmIkQbcv7pmNdFGULWIuDiXwp7DI65o1Y7Lc+SyTZtF0w6xPn6k+fg=="
DISCORD_APP_SECRET="encrypted:BCECawQcSwdBhWZD7u7fTir3vnISlzboluoP7HM4vIcor8HLq5FniVSrrahEi6FQ9/D7GnE1accQFHw5v05DMYGDC6Pk8MteVvNDBsb26D+9ysW+lPMS475mRapiAX5uxsyful61ndlexcbqffYdCbQz3sCCZ0V9T1qL6OmfUDcZ"
DISCORD_PUBLIC_KEY="encrypted:BHiB0kNDdo7jkzGpLNLG7cZhM/x6kHR2xoo9Qq6/UptSCRdTbRIIreUh91nNzs34HKJHuIShS47F1gUF/4AgFawfvosNFWGLysQRw8/BO1jPQ7xkXkOPFvFRjtn8ofAKkLequwcq/tLTJdUnXk32pghLCnLFf+JLYkwZdIiqWNlPEm+DTa2d8GJe9j+ix6fOlI9FWsRo+cY0UPpDKXZ7KgU="
DISCORD_BOT_TOKEN="encrypted:BI+BUi9br2kQ3dkmpLA+ZJG6m0ggwWJuBfsSEFOcaXctqOV9h3obbjbFf37UrKMXk4DdGobFCHWFc2gwygKwIyYV9mt6TSTNT3R3RbxV0wVwJHn66Ln7+Of9RaXQSr3rmLwMrKm23RiBuILTeqNaYdgrj2BJfNvCH4ld73jjglbfM0nrnuNZV3bi3IOiEfDizsLTy9nnVGHyd5hjDoE+LBbN6HH7QxI="
DISCORD_TEST_GUILD_ID="encrypted:BOtjgCwLkmVwj1T5nqFpiIuf5p4HdRxk0En8PyVPjPR4ICeUCD44brUtcOZveysfadU+UVH0TfWUNmdozmEl1XDs045jaJa4tcQodrEqvUUyW0P0ZvKoRNQsVV/ZnBYcxtSLqPB2OBGKZlQuDy2M50gSPw=="
JANICE_KEY="encrypted:BLHC4JqLJ3gjIXK7voNFQV89houZhR/d+3WT6hZZWaADlrTFTcmNl6bB0xd/yCjTUQ4w1SKl5IXptlidICaJpEHqc0vSTVZ7WHzI3QpZvTeUAYPQ6OF1yYKKv5ddwFDDDDTkQkAC+Kv3WMlEaHA05CVMybceQeqgCVal9/+xTTSG"
PERPLEXITY_API_KEY="encrypted:BHagAU/l8HMkXMvgQOaz76bIv+kjPBpseTbJf9mRBbJWfGmq5zvI0kQL5OciiCSOIC9E+INPpWC5TrtACTkthrBxwQtq27bEw3QumnvLNn2S9jK/Y50u3iy9j7YL3LcsDd2SmnWSklmADPZP55TbdGOxRJO8EsI4WxYoppL/XLtT6Lwp7FjHpYX8ge7z7ROGhGANB+zf"
STAR_KITTEN_KV_DB_PATH="encrypted:BBCp6CWMlFZoHM6aWTTJxV8RFpOoSYSwzH7VkHllQgkarnr+pitwAshI4K8JvYGfGLZOmiZshMRrQ1QsK5AbN4tij+rTnRO3yuq1kX5Nbdu1/77FeBZpaA7TXVG1Bj2QovWzdgongEUlew4lJGbv"
SCOPES=encrypted:BDDahsMJtV00qzcrwCfdt4eaLikrMUQXzBU+n82EKWZDzvxPY7G6tifGQC9M3GEIpDCZQSBc5gkjiTspWg/taGRA4CxEAvmYVmiskGZ8t1ZE0RjQ24ulPzfg/R0aFfXZIsB4HTgk85PinRzT2RuxSwfzmz4bwQ1yGGVQRs46Oy5m5hS7LvnBV7jPuWIqixSkawL5n+5hrREFN3Lfpw==
CONCIERGE_DB_PATH="encrypted:BNRvsmopAUFW1qFGJr1UuV0A7+/tZeVD6jhxt2JzZsfkiB8YKFoEOJuJU6k+3NYzIzDMCKdwvTa611u4dEVlWbQQ8yyHTra1YtU5JgZYd+SsPFlA02APnPgy8NN8oMhiCFkdYb1JSq8jDjkLTYbvwwenwJP99Q=="

View File

@@ -0,0 +1,23 @@
#/-------------------[DOTENV_PUBLIC_KEY]--------------------/
#/ public-key encryption for .env files /
#/ [how it works](https://dotenvx.com/encryption) /
#/----------------------------------------------------------/
DOTENV_PUBLIC_KEY_PRODUCTION="02f0469506f6722d8fcc179c199ff159ca32f082000c8e7a1465891adb50a4c031"
# .env.production
DEBUG="encrypted:BJ05zNQ0KPsEKFER9ZlvQxEFJgc5rbw0k4j65AIPgyy/kZkV7prGEyaCtXZLmCUsqtM0NyMMEl7bbLcLeBmr2AbaR0+U7yBIqQnUi46uIV27vZO9fdfCC6Z4SiFaHUXfgl6s1S/Z"
PORT="encrypted:BLPq+hz1+WPsCKxKEKFsK+EBvlf4iBwxfsjPN+FFSV078Bk+p8ihS2i5GrXd5JTIW9NJBeT4ZhnL5GXbKY5/iwoj7MmQe9WIYJ+SKBeZXXUyxGGfsE+Hd9u5XWRdbfYJQ8QCnI4="
NODE_ENV="encrypted:BE+VACL1v6wLsm3Se5A+xajeH7a6LJ9gj1cdm5R+y6IlG92/p20XU9IBdWqJ00FnMB0cPZfPx2odnSq+TzcqOSYMCWFcgD4nC7suefU08EKa8A2YGxjO2WxkQo/EC7dQO+eIGlWzdimDS98="
LOG_LEVEL="encrypted:BNXP0WNyTPrhszuDNaRsE9jFc51VtuUqfCs/EnCaP9iRvXmbekmsQzfbrNPSmHw9HBHWjPbzJUgFfebXgR2qd65L173luLUPOHu0wF5T0zaBc6zt9VdkQbJhdaDWN3FnHkWc4oM="
BASE_URL="encrypted:BGY3awxPETBuzUz1uP+jJq4u9J9xumGjRGLtccdza6lDPCl7DNf3Tv/GjbfJPH1aXkqvOfGSs6Mj6X2Sv5cNfwJYpP67HoQuNn94g93JjkGesRpkgvLv8pOAB1RaS6F3SkGHosdGOex+wyYeVf5rNhY1vZA7B3SX"
DISCORD_APP_ID="encrypted:BBV+j87Z6p/suAQ4HRdC4VOGyGNUhltobM3yaRLOC7SF0/XBw4bMx/OOpXE5qqfQdeyAhTpkEEzwH1BzaIiAZFJouMSyKYN/Gw1rbkB9LEzN9Yz6SuCZaub4ftwzZW5pJ8adzSjfm9JLFegmMO8MzJkoZU4="
DISCORD_APP_SECRET="encrypted:BLC+chabTKr/7iLjutqOlA8r9vAn3DkGN14fUxgjRGs7udyvXUKXDYsVd8SQPC5/iraYL23+Qw8PBTnTh2P8SRq4tQq/jO0H8KHrJY+DfgjJqt2f3KK19rFvDvvEBWtvFIXwF1PrWcCrIF0T3IYn1Nq9/KRED6pbjK/HERF+q4RC"
DISCORD_PUBLIC_KEY="encrypted:BAv92AM4VtQdyAqtLiCeFRuaMZ+6cazOMCNr6454p3PLIZDiMAbYEPzzIqyYZFWzMUJDTXx6c91I7dX33UZKG5vIB/tGiUrvHIRgnXXsbz4ZwXXYRZ0gDKzAFFyDaNk95RO8gZdvRdAAVUlTO/t2wlwEmGVj61jULNIdtuQBMa8tdl9XkhSAoq8O3oiHeKCPCCKU9QuiUJReU22KwZ0rPIk="
DISCORD_BOT_TOKEN="encrypted:BOafG2kHKOp209oFlXUURj3yt7JLIOJbD9dChXFY1GKJMjV63LFBhyjZkpZsrXjcrc3r/FR026UlTiO1SIDxBLlHlla2yN3ip+jLl2wAD8jJWsch+HrQPH34G9C4Peo56wIptiUIr6ug9PqHqjAE5WClEJTFwSPPHgx1btHpoyxgWnqe7h0tbc2talmfOS/8xvJ+9TKRbL165MX/+HjPJI9+7kMqrHGNJA=="
EVE_CLIENT_ID="encrypted:BPSEia/l0Aea+iX3Q5citAbtxkTBIFlBRwNhxoV4BVT4rbmGr0heJLb9ymS3srfHhlh6kra2oZzpH15UVyISL6lTxSi72CsvgsvX9cO8GdCUywSC94XBDLV5XjGMQ4vuL3ce3KDaWW+di28+7sxttAmU/b97F+547wpFkFnjb0ir"
EVE_CLIENT_SECRET="encrypted:BGru3muBXbNenhZ+e4259tQOqELbhogNa5x0Yxxw2gbj7uiMb/KzzeaJ8WTBNFrlhS4NLKRE+EwAZp1LRKZVXMVAgCoF465c5zDZrHKaBi5SEjDnffKZOLzcpOeZgTbPMfFexGZqWuFgTtP2Bn2bu0p1MxXX5uEj++ZK2IfEONbHXzUnWRQYUts="
EVE_CALLBACK_URL="encrypted:BCHObZ6cAfm/V79x2fhqnAAzinxyFpiRiQY/wojKQ5QJS8KEJ1FCc89Ee5tVBXDDxFRPaBwkj/g4blKpKWAWLl7s3LCZJCYpPoPIkrDOOMop1JFAMnVGrLbK6Ird2agVc+SkMbjUurffQ7pLrdhNQKK0y7vW5K6DzsoMu7klq7QiUFBbcfKMW5E5"
ESI_USER_AGENT="encrypted:BC6tfyTm69Id4WT/csv81UxUUtmpZKoTwcV1HoZ1xSrx8+tJMIxnFVL4SxMcEa1pQXgpKgDDpSvoAaFZwndRHxSTZvfFvpoIZlUaljzzAdevUGh3+OhCFGvx9y8YwrlT4LB/L412sZD6NhgCX6rksU1iYREexj+7Kc54/sHsD7zYAIJ9y+PU+gvRDejx32oEhBEOdksnKplaTq2ApF3NCEBWt9eiB2rho6eltro="
AUTH_DB_PATH="encrypted:BO7oCtnSoclMPTL8j/8HqQe+6p5+u4xWtEuScqPV+u1Wr/bPls9rd8DSohIwe0Y00nnhi/oSqnExCB0ip3rmQ1YA62ZfFxvmOmHwgk1MNHfCG6bE3NurHR2NE4BdDCvh+yOQ8LcN04V+Ef+tkyjKBD241H3MYmdw"
JANICE_KEY="encrypted:BO4yawcMyniT7HH8apRtRv8uwNBFuQPRz3o8FPu7viTO+uGNMVPmoRKwI+mDjFc9JHRiIsnyOvjiOzDuojdWvoyKuilNKwpyuzkTCqjd2G7YaaYnurOkLZSllb2US/BvhN4Put04aqyGwpXyq2Ns34z080TjE0Q3oIJwgI6fSfR6"
PERPLEXITY_API_KEY="encrypted:BCY8v0hPEk0n6VhTJuJZeff9XOZrwqqK77UqgF5PLTfwfiKUHLRUbj3e84FydRFGTRvRx3a0QqP2PAf0JgqCt5B9Xdop2curOTptmc2Z9wDoshMFl0X15xXQzFz4kMmeb3P1uJtB9RVsU1BMGMtX76wNcW9+vsNzu37PW+s0OrIJlXw3QicPYybzenQT6KUl9IMTkP+X"

View File

@@ -0,0 +1,182 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Caches
.cache
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
.env.keys
.flaskenv*
!.env.project
!.env.vault
data/
db/
litefs/
brainstorming/
# Sentry Config File
.sentryclirc
cloudflared.exe

View File

@@ -0,0 +1,5 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 120
}

View File

@@ -0,0 +1,8 @@
trailingComma: all
tabWidth: 2
useTabs: false
semi: true
singleQuote: true
printWidth: 140
experimentalTernaries: true
quoteProps: consistent

View File

@@ -0,0 +1,15 @@
# concierge-bot
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.3.5. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

View File

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

View File

@@ -0,0 +1,29 @@
{
"name": "concierge-bot",
"type": "module",
"module": "src/main.ts",
"private": true,
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"@dotenvx/dotenvx": "^1.49.0",
"@types/bun": "^1.2.21",
"prettier": "^3.6.2",
"typescript": "^5"
},
"dependencies": {
"@projectdysnomia/dysnomia": "github:projectdysnomia/dysnomia#dev",
"@star-kitten/lib": "workspace:^0.0.0"
},
"scripts": {
"dev": "bunx dotenvx run -f .env.development -- bun run --watch src/main.ts",
"start": "bunx @dotenvx/dotenvx run -f .env.production -- bun src/main.ts",
"build": "mkdirp ./db && bun build --minify --sourcemap src/main.ts --target bun --outdir ./dist",
"lint": "prettier --check .",
"format": "prettier --write .",
"test": "bun test",
"encrypt": "bunx dotenvx encrypt -f .env.development && bunx dotenvx encrypt -f .env.production",
"decrypt": "bunx dotenvx decrypt -f .env.development && bunx dotenvx decrypt -f .env.production"
}
}

View File

@@ -0,0 +1,220 @@
import { componentHasIdPrefix, isModalLabel, text } from '@star-kitten/lib/discord/components';
import { createChatCommand, type CommandContext, type ExecutableInteraction } from '@star-kitten/lib/discord';
import { PageType, usePages } from '@star-kitten/lib/discord/pages';
import { StructureType, type Location } from '@/lib/db/location';
import { getDB } from '@/lib/db';
import { Constants, type ComponentInteractionSelectMenuData } from '@projectdysnomia/dysnomia';
interface LocationsState {
selected?: Location;
}
export default createChatCommand(
{
name: 'locations',
description: 'location management',
},
async (interaction: ExecutableInteraction, commandCtx: CommandContext) => {
await usePages<LocationsState>(
{
pages: {
main: {
key: 'main',
type: PageType.MESSAGE,
render: async (pageCtx) => {
const locations = getDB().getAllLocations();
console.log('Rendering locations page with locations:', locations);
const renderLocations = () => {
if (locations.length === 0) {
return 'No locations added yet.';
}
return locations
.map(
(loc) =>
`${loc.location_id}\t${loc.short_name}\t${loc.can_jf ? 'JF' : ''}${loc.can_dst ? ' DST' : ''}${loc.can_br ? ' BR' : ''}${loc.can_smb ? ' SMB' : ''}${loc.can_bridge ? ' BRIDGE' : ''}`,
)
.join('\n');
};
return (
<container accent={0x11cc33}>
<text>{`# Locations\n${renderLocations()}`}</text>
<actionRow>
<stringSelect
customId="edit-services"
placeholder="Select Location to Edit Services"
minValues={1}
maxValues={1}
>
{locations.map((loc) => (
<option label={loc.short_name} value={loc.location_id.toString()} />
))}
</stringSelect>
</actionRow>
<actionRow>
<button customId="add-location" label="Add Location" style={Constants.ButtonStyles.PRIMARY} />
</actionRow>
</container>
);
},
},
'add-location': {
key: 'add-location',
type: PageType.MODAL,
render: async (ctx) => {
return (
<modal title="Add Location" customId="add-location-modal">
<label label="Location Name">
<textInput customId="location-name" placeholder="Enter the location name" />
</label>
<label label="Location Short Name">
<textInput customId="location-short-name" placeholder="Enter the location short name" />
</label>
<label label="Structure Type">
<stringSelect
customId="structure-type"
placeholder="Select Structure Type"
minValues={1}
maxValues={1}
>
{Object.values(StructureType).map((type) => (
<option label={type} value={type} />
))}
</stringSelect>
</label>
</modal>
);
},
},
'edit-services': {
key: 'edit-services',
type: PageType.MODAL,
render: async (ctx) => {
return (
<modal
title={`Edit Location Services at ${ctx.state.data.selected?.short_name || ''}:`}
customId="edit-services-modal"
>
<label label="Can JF">
<stringSelect customId="can-jf" placeholder="Can JF" minValues={1} maxValues={1}>
<option label="Yes" value="yes" />
<option label="No" value="no" />
</stringSelect>
</label>
<label label="Can DST">
<stringSelect customId="can-dst" placeholder="Can DST" minValues={1} maxValues={1}>
<option label="Yes" value="yes" />
<option label="No" value="no" />
</stringSelect>
</label>
<label label="Can BR">
<stringSelect customId="can-br" placeholder="Can BR" minValues={1} maxValues={1}>
<option label="Yes" value="yes" />
<option label="No" value="no" />
</stringSelect>
</label>
<label label="Can SMB">
<stringSelect customId="can-smb" placeholder="Can SMB" minValues={1} maxValues={1}>
<option label="Yes" value="yes" />
<option label="No" value="no" />
</stringSelect>
</label>
<label label="Can Bridge">
<stringSelect customId="can-bridge" placeholder="Can Bridge" minValues={1} maxValues={1}>
<option label="Yes" value="yes" />
<option label="No" value="no" />
</stringSelect>
</label>
</modal>
);
},
},
},
router: (ctx) => {
if (ctx.custom_id === 'add-location-modal') {
// Handle modal submission
if (!interaction.isModalSubmit()) {
throw new Error('Expected a modal submit interaction for add-location-modal');
}
let locationName = ctx.interaction.data.components.find(
(comp) => isModalLabel(comp) && componentHasIdPrefix(comp.component, 'location-name'),
)?.component.value;
let locationShortName = ctx.interaction.data.components.find(
(comp) => isModalLabel(comp) && componentHasIdPrefix(comp.component, 'location-short-name'),
)?.component.value;
let structureTypeValue = ctx.interaction.data.components.find(
(comp) => isModalLabel(comp) && componentHasIdPrefix(comp.component, 'structure-type'),
)?.component as ComponentInteractionSelectMenuData;
let structureType = structureTypeValue ? (structureTypeValue.values[0] as StructureType) : undefined;
if (locationName && locationShortName && structureType) {
getDB().addLocation({
name: locationName,
short_name: locationShortName,
structure_type: structureType,
});
}
return 'main';
}
if (ctx.custom_id === 'add-location') {
ctx.state.data.selected = getDB().getLocationById(ctx.interaction.data.values[0]);
return 'add-location';
}
if (ctx.custom_id === 'edit-services') {
if (!ctx.interaction.isMessageComponent()) {
throw new Error('Expected a message component interaction for edit-services');
}
const data = ctx.interaction.data as ComponentInteractionSelectMenuData;
const locationId = Number.parseInt(data.values[0]);
const location = getDB().getLocationById(locationId);
if (location) {
ctx.state.data.selected = location;
return 'edit-services';
}
}
if (ctx.custom_id === 'edit-services-modal') {
// Handle modal submission
if (!interaction.isModalSubmit()) {
throw new Error('Expected a modal submit interaction for edit-services-modal');
}
const location = ctx.state.data.selected;
if (!location) {
return 'main';
}
const getServiceValue = (serviceId: string) => {
const comp = ctx.interaction.data.components.find(
(c) => isModalLabel(c) && componentHasIdPrefix(c.component, serviceId),
)?.component as ComponentInteractionSelectMenuData;
return comp ? comp.values[0] === 'yes' : false;
};
const can_jf = getServiceValue('can-jf');
const can_dst = getServiceValue('can-dst');
const can_br = getServiceValue('can-br');
const can_smb = getServiceValue('can-smb');
const can_bridge = getServiceValue('can-bridge');
getDB().updateLocation({
...location,
can_jf,
can_dst,
can_br,
can_smb,
can_bridge,
});
}
return 'main';
},
initialPage: 'main',
ephemeral: true,
},
interaction,
commandCtx,
);
},
);

View File

@@ -0,0 +1,248 @@
import {
Constants,
type ChatInputApplicationCommandStructure,
type ComponentInteractionSelectMenuData,
} from '@projectdysnomia/dysnomia';
import { appraiseItems, type Appraisal } from '@star-kitten/lib/eve/third-party/janice.js';
import {
componentHasIdPrefix,
isModalLabel,
isModalSelect,
isModalTextInput,
} from '@star-kitten/lib/discord/components';
import type { CommandContext, ExecutableInteraction } from '@star-kitten/lib/discord';
import { PageType, usePages } from '@star-kitten/lib/discord/pages';
import { serve } from 'bun';
// import { renderAppraisal } from './renderAppraisal';
// import { renderAppraisalModal } from './renderAppraisalModal';
const definition: ChatInputApplicationCommandStructure = {
type: Constants.ApplicationCommandTypes.CHAT_INPUT,
name: 'quote',
nameLocalizations: {
de: 'angebot',
'es-ES': 'cotización',
fr: 'devis',
ja: '見積もり',
ko: '견적',
ru: 'цитата',
'zh-CN': '报价',
},
description: 'Get a quote for moving your items',
descriptionLocalizations: {
de: 'Holen Sie Sie sich ein Angebot für den Umzug Ihrer Gegenstände',
'es-ES': 'Obtén una cotización para mover tus artículos',
fr: 'Obtenez un devis pour déplacer vos articles',
ja: 'アイテムを移動するための見積もりを取得します',
ko: '항목을 이동하기 위한 견적 받기',
ru: 'Получите предложение по перемещению ваших предметов',
'zh-CN': '获取移动您的物品的报价',
},
};
interface QuouteState {
serviceType: RouteType;
originId?: number;
destinationId?: number;
items?: string;
appraisal?: Appraisal;
}
// Hardcoded routes for now
interface RouteType {
id: number;
short: string;
label: string;
}
const routeTypes: Record<string, RouteType> = {
JF: { id: 1, short: 'JF', label: 'Jump Freighter' },
DST: { id: 2, short: 'DST', label: 'Deep Space Transport' },
SMB: { id: 3, short: 'SMB', label: 'Ship Maintenance Bay' },
BR: { id: 4, short: 'BR', label: 'Blockade Runner' },
CUSTOM: { id: 5, short: 'CUSTOM', label: 'Custom' },
};
interface Location {
id: number;
name: string;
supported_types: number[]; // RouteType IDs
}
const locations: Location[] = [
{ id: 1, name: 'Jita 4-4', supported_types: [1, 2, 3, 4, 5] },
{ id: 2, name: 'B-9', supported_types: [1, 2] },
{ id: 3, name: '3T7', supported_types: [1, 2, 3, 4, 5] },
{ id: 4, name: '4-H', supported_types: [1, 2] },
{ id: 5, name: 'Odebeinn', supported_types: [1, 2, 3, 4, 5] },
];
interface Route {
origin: number;
destination: number;
type: number; // RouteType ID
}
const routes: Route[] = [
{ origin: 1, destination: 2, type: routeTypes.JF.id },
{ origin: 2, destination: 1, type: routeTypes.JF.id },
{ origin: 2, destination: 3, type: routeTypes.JF.id },
{ origin: 3, destination: 2, type: routeTypes.JF.id },
{ origin: 3, destination: 4, type: routeTypes.JF.id },
{ origin: 4, destination: 3, type: routeTypes.JF.id },
{ origin: 4, destination: 5, type: routeTypes.JF.id },
{ origin: 5, destination: 4, type: routeTypes.JF.id },
{ origin: 5, destination: 2, type: routeTypes.JF.id },
{ origin: 2, destination: 3, type: routeTypes.JF.id },
{ origin: 3, destination: 4, type: routeTypes.JF.id },
{ origin: 4, destination: 5, type: routeTypes.JF.id },
{ origin: 5, destination: 2, type: routeTypes.JF.id },
{ origin: 3, destination: 2, type: routeTypes.DST.id },
{ origin: 2, destination: 4, type: routeTypes.SMB.id },
{ origin: 2, destination: 5, type: routeTypes.BR.id },
{ origin: 1, destination: 3, type: routeTypes.DST.id },
{ origin: 1, destination: 4, type: routeTypes.SMB.id },
{ origin: 1, destination: 5, type: routeTypes.BR.id },
{ origin: 2, destination: 1, type: routeTypes.JF.id },
{ origin: 3, destination: 1, type: routeTypes.DST.id },
{ origin: 4, destination: 1, type: routeTypes.SMB.id },
{ origin: 5, destination: 1, type: routeTypes.BR.id },
];
const defaultState: QuouteState = {
serviceType: routeTypes.JF,
originId: undefined,
destinationId: undefined,
items: undefined,
appraisal: undefined,
};
function uniqueDestinationForOriginAndType(typeId: number, originId?: number) {
if (!originId) {
locations.filter((loc) => loc.supported_types.includes(typeId));
}
const filtered = routes.filter((r) => r.origin === originId && r.type === typeId);
const locSet = new Set<Location>();
filtered.forEach((route, index) => {
locSet.add(locations.find((l) => l.id === route.destination)!);
});
return Array.from(locSet);
}
async function execute(interaction: ExecutableInteraction, ctx: CommandContext) {
return await usePages<QuouteState>(
{
pages: {
main: {
key: 'main',
type: PageType.MESSAGE,
render: (pageCtx) => {
console.log('Rendering main page with state:', pageCtx.state.data);
return (
<container accent={0x11cc33}>
<text>{`# Quote`}</text>
<text>{`### Service: ${pageCtx.state.data.serviceType?.label ?? ''}`}</text>
<actionRow>
{Object.keys(routeTypes).map((key) => (
<button
customId={`type-${key}`}
label={routeTypes[key].short}
style={Constants.ButtonStyles.SECONDARY}
/>
))}
</actionRow>
<text>{`### Origin: ${locations[pageCtx.state.data.originId - 1]?.name ?? ''}`}</text>
<actionRow>
<stringSelect customId="route-origin" placeholder="Select Origin">
{locations
.filter((loc) => loc.supported_types.includes(pageCtx.state.data.serviceType?.id ?? -1))
.map((loc) => {
return <option label={loc?.name ?? ''} value={loc?.id.toString() ?? ''} />;
})}
</stringSelect>
</actionRow>
<text>{`### Destination: ${locations[pageCtx.state.data.destinationId - 1]?.name ?? ''}`}</text>
<actionRow>
<stringSelect customId="route-destination" placeholder="Select Destination">
<option label="Select a destination" value="1" />
</stringSelect>
</actionRow>
<text>{`### Items:\n${pageCtx.state.data.items ?? ''}`}</text>
<actionRow>
<button customId="addItems" label="Add Items" style={Constants.ButtonStyles.PRIMARY} />
</actionRow>
</container>
);
},
},
addItems: {
key: 'add-items',
type: PageType.MODAL,
render: () => {
return (
<modal title="Add Items" customId="add-items-modal">
<label
label="Items"
description="Discord limits input to 4000 characters. Add more items by submitting multiple times."
>
<textInput
customId="items-input"
placeholder={`e.g. Tritanium 22222
Pyerite 8000
Mexallon 2444`}
isParagraph={true}
required
/>
</label>
</modal>
);
},
},
},
initialPage: 'main',
initialStateData: defaultState,
ephemeral: true,
router: (pageCtx) => {
if (pageCtx.custom_id.startsWith('type-')) {
const key = pageCtx.custom_id.replace('type-', '');
pageCtx.state.data.serviceType = routeTypes[key];
return 'main';
}
if (pageCtx.custom_id === 'route-origin' && pageCtx.interaction.isMessageComponent()) {
const data = pageCtx.interaction.data as ComponentInteractionSelectMenuData;
pageCtx.state.data.originId = Number.parseInt(data.values[0]);
return 'main';
}
if (pageCtx.custom_id === 'route-destination' && pageCtx.interaction.isMessageComponent()) {
const data = pageCtx.interaction.data as ComponentInteractionSelectMenuData;
pageCtx.state.data.destinationId = Number.parseInt(data.values[0]);
return 'main';
}
if (pageCtx.custom_id === 'addItems') {
return 'addItems';
}
if (pageCtx.custom_id === 'add-items-modal' && pageCtx.interaction.isModalSubmit()) {
let items = '';
pageCtx.interaction.data.components.forEach((comp) => {
if (isModalLabel(comp)) {
if (isModalTextInput(comp.component) && componentHasIdPrefix(comp.component, 'items-input')) {
items = comp.component.value || items;
}
}
});
pageCtx.state.data.items = pageCtx.state.data.items ? `${pageCtx.state.data.items}\n${items}` : items;
}
return 'main';
},
},
interaction,
ctx,
);
}
export default {
definition,
execute,
};

View File

@@ -0,0 +1,77 @@
import {
type ExecutableInteraction,
type CommandContext,
Locale,
type ChatCommandDefinition,
} from '@star-kitten/lib/discord';
const definition: ChatCommandDefinition = {
name: 'time',
nameLocalizations: {
[Locale.DE]: 'zeit',
[Locale.ES_ES]: 'hora',
[Locale.FR]: 'heure',
[Locale.JA]: '時間',
[Locale.KO]: '시간',
[Locale.RU]: 'время',
[Locale.ZH_CN]: '时间',
},
description: 'Get the current EVE time',
descriptionLocalizations: {
[Locale.DE]: 'Holen Sie sich die aktuelle EVE-Zeit',
[Locale.ES_ES]: 'Obtén la hora actual de EVE',
[Locale.FR]: "Obtenez l'heure actuelle d'EVE",
[Locale.JA]: '現在のEVE時間を取得します',
[Locale.KO]: '현재 EVE 시간을 가져옵니다',
[Locale.RU]: 'Получите текущее время EVE',
[Locale.ZH_CN]: '获取当前的EVE时间',
},
};
const eveTimeText = {
[Locale.EN_US]: 'EVE Time',
[Locale.EN_GB]: 'EVE Time',
[Locale.DE]: 'EVE-Zeit',
[Locale.ES_ES]: 'Hora EVE',
[Locale.FR]: "Heure d'EVE",
[Locale.JA]: 'EVE時間',
[Locale.KO]: 'EVE 시간',
[Locale.RU]: 'Время EVE',
[Locale.ZH_CN]: 'EVE时间',
};
function jsx(component: any) {
return {
flags: 2,
components: [component],
};
}
async function execute(interaction: ExecutableInteraction, ctx: CommandContext) {
if (!interaction.isApplicationCommand()) return;
const now = new Date();
const eveTime = now.toISOString().split('T')[1].split('.')[0];
const eveDate = now.toLocaleDateString(interaction.locale, {
timeZone: 'UTC',
year: 'numeric',
month: 'long',
day: '2-digit',
weekday: 'long',
});
interaction.createJSXMessage(
<container>
<text>
{`### ${eveTimeText[interaction.locale] || eveTimeText[Locale.EN_US]}
${eveTime}
${eveDate}`}
</text>
</container>,
);
}
export default {
definition,
execute,
};

View File

@@ -0,0 +1,60 @@
import { Database } from 'bun:sqlite';
import locationTables, * as locationQueries from './location';
let db: Database = undefined;
const queries = {
...locationQueries,
};
function createTables() {
locationTables.createTable(db!);
}
function dropTables() {
locationTables.dropTable(db!);
}
function close() {
if (db) {
db.close();
db = null;
}
}
function initializeDatabase(dbPath: string = process.env.CONCIERGE_DB_PATH || ':memory:') {
db = new Database(dbPath);
createTables();
Object.keys(queries).forEach((key) => {
if (typeof queries[key] === 'function') {
queries[key] = queries[key].bind(null, db);
}
});
}
type OmitFirstArg<F> = F extends (arg1: any, ...args: infer R) => infer Ret ? (...args: R) => Ret : never;
type CurriedObject<T, O> = {
[K in keyof T]: T[K] extends (arg1: O, ...args: infer R) => infer Ret ? OmitFirstArg<T[K]> : T[K];
};
export type DB = CurriedObject<Omit<typeof queries, 'default'>, Database> & {
db: Database;
createTables: () => void;
dropTables: () => void;
close: () => void;
};
export function getDB(): DB {
if (!db) {
initializeDatabase();
}
return {
...(queries as any),
default: undefined,
createTables,
dropTables,
close,
db,
} as DB;
}

View File

@@ -0,0 +1,103 @@
import type { Database } from 'bun:sqlite';
const TABLE_NAME = 'locations';
export enum StructureType {
NPC = 'NPC',
Keepstar = 'Keepstar',
Fortizar = 'Fortizar',
Astrahus = 'Astrahus',
Sotiyo = 'Sotiyo',
Azbel = 'Azbel',
Raitaru = 'Raitaru',
Athanor = 'Athanor',
Tatara = 'Tatara',
}
export interface Location {
location_id: number;
name: string;
short_name: string;
structure_type: StructureType;
can_jf?: boolean;
can_dst?: boolean;
can_br?: boolean;
can_smb?: boolean;
can_bridge?: boolean;
}
export default {
createTable: (db: Database) => {
db.run(
`CREATE TABLE IF NOT EXISTS ${TABLE_NAME} (
location_id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
short_name TEXT NOT NULL,
structure_type TEXT NOT NULL,
can_jf BOOLEAN NOT NULL DEFAULT 0,
can_dst BOOLEAN NOT NULL DEFAULT 0,
can_br BOOLEAN NOT NULL DEFAULT 0,
can_smb BOOLEAN NOT NULL DEFAULT 0,
can_bridge BOOLEAN NOT NULL DEFAULT 0
)`,
);
},
dropTable: (db: Database) => {
db.run(`DROP TABLE IF EXISTS ${TABLE_NAME}`);
},
};
export function addLocation(db: Database, location: Omit<Location, 'location_id'>) {
const stmt = db.prepare(
`INSERT INTO ${TABLE_NAME} (name, short_name, structure_type, can_jf, can_dst, can_br, can_smb, can_bridge)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
);
const result = stmt.run(
location.name,
location.short_name,
location.structure_type,
location.can_jf ? 1 : 0,
location.can_dst ? 1 : 0,
location.can_br ? 1 : 0,
location.can_smb ? 1 : 0,
location.can_bridge ? 1 : 0,
);
return result.lastInsertRowid as number;
}
export function updateLocation(db: Database, location: Location) {
const stmt = db.prepare(
`UPDATE ${TABLE_NAME}
SET name = ?, short_name = ?, structure_type = ?, can_jf = ?, can_dst = ?, can_br = ?, can_smb = ?, can_bridge = ?
WHERE location_id = ?`,
);
stmt.run(
location.name,
location.short_name,
location.structure_type,
location.can_jf ? 1 : 0,
location.can_dst ? 1 : 0,
location.can_br ? 1 : 0,
location.can_smb ? 1 : 0,
location.can_bridge ? 1 : 0,
location.location_id,
);
}
export function getLocationById(db: Database, locationId: number): Location | null {
const stmt = db.prepare(`SELECT * FROM ${TABLE_NAME} WHERE location_id = ?`);
const row = stmt.get(locationId) as Location | undefined;
return row || null;
}
export function getAllLocations(db: Database): Location[] {
const stmt = db.prepare(`SELECT * FROM ${TABLE_NAME}`);
const rows = stmt.all() as Location[];
return rows;
}
export function deleteLocation(db: Database, locationId: number) {
const stmt = db.prepare(`DELETE FROM ${TABLE_NAME} WHERE location_id = ?`);
stmt.run(locationId);
}

View File

@@ -0,0 +1,3 @@
import { startBot } from "@star-kitten/lib/discord";
startBot();

View File

@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"jsx": "react-jsx",
"jsxImportSource": "@star-kitten/lib/discord",
"moduleResolution": "bundler",
"paths": {
"@/*": ["./src/*"]
},
"typeRoots": ["src/types", "./node_modules/@types"]
},
"references": [{ "path": "../lib" }],
"include": ["src", "types"],
"exclude": ["node_modules", "dist", "build", "**/*.test.ts"]
}

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

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