forked from syntaxbullet/aurorabot
Compare commits
221 Commits
afe82c449b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
222f32d98f | ||
|
|
454ded8b26 | ||
|
|
9e85ba1fa4 | ||
|
|
2fb8d559a6 | ||
|
|
b0a103d8ce | ||
|
|
cb056e010f | ||
|
|
de15cb4206 | ||
|
|
f796cac6be | ||
|
|
31580df919 | ||
|
|
9a17209db2 | ||
|
|
04656790d2 | ||
|
|
25a0bd3431 | ||
|
|
6abbd4652a | ||
|
|
8369d10bab | ||
|
|
bdfe0d1594 | ||
|
|
034f2ead1c | ||
|
|
06c3891045 | ||
|
|
f09cbe6939 | ||
|
|
cd9e1e7242 | ||
|
|
966bad98d3 | ||
|
|
2b89fb7ede | ||
|
|
0fc88323ea | ||
|
|
96eba8270c | ||
|
|
a36c05994c | ||
|
|
ef78a85b9c | ||
|
|
f368da9e73 | ||
|
|
4f89ed3082 | ||
|
|
0d8152914a | ||
|
|
12809623c1 | ||
|
|
a29bb63a1d | ||
|
|
9e95194627 | ||
|
|
451fb206a6 | ||
|
|
e3c49effdb | ||
|
|
5c40249a18 | ||
|
|
b645f55f57 | ||
|
|
838fbe1b50 | ||
|
|
94d259e92a | ||
|
|
56db5bc998 | ||
|
|
ebac1ad6cc | ||
|
|
abca1922f2 | ||
|
|
e0dcfe6abe | ||
|
|
132f92d2d9 | ||
|
|
70a149ab82 | ||
|
|
26a0e532f6 | ||
|
|
e521d3086f | ||
|
|
9c4da51cfb | ||
|
|
24211dca14 | ||
|
|
87b66cd65d | ||
|
|
0dadc82f84 | ||
|
|
5527981fff | ||
|
|
9f105ada5e | ||
|
|
cac9fae142 | ||
|
|
b832723d6b | ||
|
|
0c3b289ba0 | ||
|
|
f4b36a745e | ||
|
|
3b53c9cb5f | ||
|
|
3bdb720e4a | ||
|
|
f290eeeb8a | ||
|
|
4b3f6590cc | ||
|
|
069c0b93ef | ||
|
|
33a1848096 | ||
|
|
55df982a0b | ||
|
|
eb7dfaf6f5 | ||
|
|
aa145592c5 | ||
|
|
37fa5fc3c8 | ||
|
|
db10ebe220 | ||
|
|
a5478dce2b | ||
|
|
29b6153777 | ||
|
|
d3e83bac66 | ||
|
|
40ae93f68b | ||
|
|
1e978dff58 | ||
|
|
3c256ba0b2 | ||
|
|
70d59a091a | ||
|
|
9569972cd6 | ||
|
|
5bd390b4ee | ||
|
|
5f8819bb46 | ||
|
|
b8cf136ff7 | ||
|
|
5188d86d61 | ||
|
|
6a1498813f | ||
|
|
e4f7c03005 | ||
|
|
38098a02ea | ||
|
|
fa09ef25e2 | ||
|
|
ba8afd144e | ||
|
|
8ef1873410 | ||
|
|
289044e26f | ||
|
|
47ea6d8620 | ||
|
|
21b5fedfc9 | ||
|
|
912ce5b942 | ||
|
|
4ead7e60b1 | ||
|
|
e64ffdc4cb | ||
|
|
1d601febcf | ||
|
|
3edda1d707 | ||
|
|
e56e133a69 | ||
|
|
0f871026eb | ||
|
|
782a138fd8 | ||
|
|
58d07a02fd | ||
|
|
01bb73f6a2 | ||
|
|
602147e961 | ||
|
|
9e6bb8b148 | ||
|
|
305a0b0553 | ||
|
|
023ff9fb1b | ||
|
|
56353a7756 | ||
|
|
86142cba6c | ||
|
|
0517cd638c | ||
|
|
b8303a7e28 | ||
|
|
d259c0c6a6 | ||
|
|
8b9ab2cd29 | ||
|
|
5d832c9601 | ||
|
|
968cc09c98 | ||
|
|
2bddab001a | ||
|
|
fc058effd5 | ||
|
|
3f99a77446 | ||
|
|
abe25e0ceb | ||
|
|
5a20ed23f4 | ||
|
|
0142508eb5 | ||
|
|
5863418ae9 | ||
|
|
a96c6caa49 | ||
|
|
22e446ff28 | ||
|
|
10c84a8478 | ||
|
|
9eba64621a | ||
|
|
7cc2f61db6 | ||
|
|
f5fecb59cb | ||
|
|
65f5663c97 | ||
| de83307adc | |||
|
|
15e01906a3 | ||
|
|
fed27c0227 | ||
|
|
9751e62e30 | ||
|
|
87d5aa259c | ||
|
|
f0bfaecb0b | ||
|
|
9471b6fdab | ||
|
|
04e5851387 | ||
| 1a59c9e796 | |||
|
|
251616fe15 | ||
|
|
fbb2e0f010 | ||
|
|
dc10ad5c37 | ||
|
|
2381f073ba | ||
|
|
121c242168 | ||
|
|
942875e8d0 | ||
|
|
878e3306eb | ||
|
|
aca5538d57 | ||
|
|
f822d90dd3 | ||
|
|
141c3098f8 | ||
|
|
0c67a8754f | ||
|
|
bf20c61190 | ||
|
|
099601ce6d | ||
|
|
55d2376ca1 | ||
|
|
6eb4a32a12 | ||
|
|
2d35a5eabb | ||
|
|
570cdc69c1 | ||
|
|
c2b1fb6db1 | ||
|
|
d15d53e839 | ||
|
|
58374d1746 | ||
|
|
ae6a068197 | ||
|
|
43d32918ab | ||
|
|
0bc254b728 | ||
|
|
610d97bde3 | ||
|
|
babccfd08a | ||
|
|
ee7d63df3e | ||
|
|
5f107d03a7 | ||
|
|
1ff24b0f7f | ||
|
|
a5e3534260 | ||
|
|
228005322e | ||
|
|
67a3aa4b0f | ||
|
|
64804f7066 | ||
|
|
73ad889018 | ||
|
|
9c7f1e4418 | ||
|
|
efb50916b2 | ||
|
|
6abb52694e | ||
|
|
76968e31a6 | ||
|
|
29bf0e6f1c | ||
|
|
8c306fbd23 | ||
|
|
b0c3baf5b7 | ||
|
|
f575588b9a | ||
|
|
553b9b4952 | ||
|
|
073348fa55 | ||
|
|
4232674494 | ||
|
|
fbf1e52c28 | ||
|
|
20284dc57b | ||
|
|
36f9c76fa9 | ||
|
|
46e95ce7b3 | ||
|
|
9acd3f3d76 | ||
|
|
5e8683a19f | ||
|
|
ee088ad84b | ||
|
|
b18b5fab62 | ||
|
|
0b56486ab2 | ||
|
|
11c589b01c | ||
|
|
e4169d9dd5 | ||
|
|
1929f0dd1f | ||
|
|
db4e7313c3 | ||
|
|
1ffe397fbb | ||
|
|
34958aa220 | ||
|
|
109b36ffe2 | ||
|
|
cd954afe36 | ||
|
|
2b60883173 | ||
|
|
c2d67d7435 | ||
|
|
e252d6e00a | ||
|
|
95f1b4e04a | ||
|
|
62c6ca5e87 | ||
|
|
aac9be19f2 | ||
|
|
bb823c86c1 | ||
|
|
119301f1c3 | ||
|
|
9a2fc101da | ||
|
|
7049cbfd9d | ||
|
|
db859e8f12 | ||
|
|
5ff3fa9ab5 | ||
|
|
c8bf69a969 | ||
|
|
fee4969910 | ||
|
|
dabcb4cab3 | ||
|
|
1a3f5c6654 | ||
|
|
422db6479b | ||
|
|
35ecea16f7 | ||
|
|
9ff679ee5c | ||
|
|
ebefd8c0df | ||
|
|
73531f38ae | ||
|
|
5a6356d271 | ||
|
|
f9dafeac3b | ||
|
|
1a2bbb011c | ||
|
|
2ead35789d | ||
|
|
c1da71227d | ||
|
|
17e636c4e5 | ||
|
|
d7543d9f48 |
7
.citrine
Normal file
7
.citrine
Normal file
@@ -0,0 +1,7 @@
|
||||
### Frontend
|
||||
[8bb0] [>] implement items page
|
||||
[de51] [ ] implement classes page
|
||||
[d108] [ ] implement quests page
|
||||
[8bbe] [ ] implement lootdrops page
|
||||
[094e] [ ] implement moderation page
|
||||
[220d] [ ] implement transactions page
|
||||
39
.dockerignore
Normal file
39
.dockerignore
Normal file
@@ -0,0 +1,39 @@
|
||||
# Dependencies - handled inside container
|
||||
node_modules
|
||||
web/node_modules
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Logs and data
|
||||
logs
|
||||
*.log
|
||||
shared/db/data
|
||||
shared/db/log
|
||||
|
||||
# Development tools
|
||||
.env
|
||||
.env.example
|
||||
.opencode
|
||||
.agent
|
||||
|
||||
# Documentation
|
||||
docs
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Build artifacts
|
||||
dist
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
26
.env.example
26
.env.example
@@ -1,12 +1,34 @@
|
||||
# =============================================================================
|
||||
# Aurora Environment Configuration
|
||||
# =============================================================================
|
||||
# Copy this file to .env and update with your values
|
||||
# For production, see .env.prod.example with security recommendations
|
||||
# =============================================================================
|
||||
|
||||
# Database
|
||||
# For production: use a strong password (openssl rand -base64 32)
|
||||
DB_USER=aurora
|
||||
DB_PASSWORD=aurora
|
||||
DB_NAME=aurora
|
||||
DB_PORT=5432
|
||||
DB_HOST=db
|
||||
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
|
||||
|
||||
# Discord
|
||||
# Get from: https://discord.com/developers/applications
|
||||
DISCORD_BOT_TOKEN=your-discord-bot-token
|
||||
DISCORD_CLIENT_ID=your-discord-client-id
|
||||
DISCORD_GUILD_ID=your-discord-guild-id
|
||||
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
|
||||
|
||||
VPS_USER=your-vps-user
|
||||
# Admin Panel (Discord OAuth)
|
||||
# Get client secret from: https://discord.com/developers/applications → OAuth2
|
||||
DISCORD_CLIENT_SECRET=your-discord-client-secret
|
||||
SESSION_SECRET=change-me-to-a-random-string
|
||||
ADMIN_USER_IDS=123456789012345678
|
||||
PANEL_BASE_URL=http://localhost:3000
|
||||
|
||||
# Server (for remote access scripts)
|
||||
# Use a non-root user (see shared/scripts/setup-server.sh)
|
||||
VPS_USER=deploy
|
||||
VPS_HOST=your-vps-ip
|
||||
SESSION_SECRET=change-me-to-a-random-string
|
||||
38
.env.prod.example
Normal file
38
.env.prod.example
Normal file
@@ -0,0 +1,38 @@
|
||||
# =============================================================================
|
||||
# Aurora Production Environment Template
|
||||
# =============================================================================
|
||||
# Copy this file to .env and fill in the values
|
||||
# IMPORTANT: Use strong, unique passwords in production!
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
# Generate strong password: openssl rand -base64 32
|
||||
DB_USER=aurora_prod
|
||||
DB_PASSWORD=CHANGE_ME_USE_STRONG_PASSWORD
|
||||
DB_NAME=aurora_prod
|
||||
DB_PORT=5432
|
||||
DB_HOST=localhost
|
||||
|
||||
# Constructed database URL (used by Drizzle)
|
||||
DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_NAME}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Discord Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
# Get these from Discord Developer Portal: https://discord.com/developers
|
||||
DISCORD_BOT_TOKEN=your_bot_token_here
|
||||
DISCORD_CLIENT_ID=your_client_id_here
|
||||
DISCORD_GUILD_ID=your_guild_id_here
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Server Configuration (for SSH deployment scripts)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Use a non-root user for security!
|
||||
VPS_USER=deploy
|
||||
VPS_HOST=your_server_ip_here
|
||||
|
||||
# Optional: Custom ports for remote access
|
||||
# DASHBOARD_PORT=3000
|
||||
# STUDIO_PORT=4983
|
||||
6
.env.test
Normal file
6
.env.test
Normal file
@@ -0,0 +1,6 @@
|
||||
DATABASE_URL="postgresql://auroradev:auroradev123@localhost:5432/aurora_test"
|
||||
DISCORD_BOT_TOKEN="test_token"
|
||||
DISCORD_CLIENT_ID="123456789"
|
||||
DISCORD_GUILD_ID="123456789"
|
||||
ADMIN_TOKEN="admin_token_123"
|
||||
LOG_LEVEL="error"
|
||||
132
.gitea/workflows/ci-deploy.yml
Normal file
132
.gitea/workflows/ci-deploy.yml
Normal file
@@ -0,0 +1,132 @@
|
||||
name: CI / Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: aurora_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
run: |
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
echo "$HOME/.bun/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Create Config File
|
||||
run: |
|
||||
mkdir -p shared/config
|
||||
cat <<EOF > shared/config/config.json
|
||||
{
|
||||
"leveling": { "base": 100, "exponent": 2.5, "chat": { "cooldownMs": 60000, "minXp": 15, "maxXp": 25 } },
|
||||
"economy": {
|
||||
"daily": { "amount": "100", "streakBonus": "10", "weeklyBonus": "50", "cooldownMs": 86400000 },
|
||||
"transfers": { "allowSelfTransfer": false, "minAmount": "1" },
|
||||
"exam": { "multMin": 0.05, "multMax": 0.03 }
|
||||
},
|
||||
"inventory": { "maxStackSize": "99", "maxSlots": 50 },
|
||||
"commands": {},
|
||||
"lootdrop": {
|
||||
"activityWindowMs": 120000, "minMessages": 1, "spawnChance": 1, "cooldownMs": 3000,
|
||||
"reward": { "min": 40, "max": 150, "currency": "Astral Units" }
|
||||
},
|
||||
"studentRole": "123", "visitorRole": "456", "colorRoles": [],
|
||||
"moderation": {
|
||||
"prune": { "maxAmount": 100, "confirmThreshold": 50, "batchSize": 100, "batchDelayMs": 1000 },
|
||||
"cases": { "dmOnWarn": false }
|
||||
},
|
||||
"trivia": {
|
||||
"entryFee": "50", "rewardMultiplier": 1.5, "timeoutSeconds": 30, "cooldownMs": 60000,
|
||||
"categories": [], "difficulty": "random"
|
||||
},
|
||||
"system": {}
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Typecheck
|
||||
run: bunx tsc --noEmit
|
||||
|
||||
- name: Build Panel
|
||||
run: bun run panel:build
|
||||
|
||||
- name: Setup Test Database
|
||||
run: bun run db:push:local
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/aurora_test
|
||||
DISCORD_BOT_TOKEN: test_token
|
||||
DISCORD_CLIENT_ID: "123"
|
||||
DISCORD_GUILD_ID: "123"
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
cat <<EOF > .env.test
|
||||
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/aurora_test"
|
||||
DISCORD_BOT_TOKEN="test_token"
|
||||
DISCORD_CLIENT_ID="123456789"
|
||||
DISCORD_GUILD_ID="123456789"
|
||||
DISCORD_CLIENT_SECRET="test-client-secret"
|
||||
SESSION_SECRET="test-session-secret"
|
||||
ADMIN_TOKEN="admin_token_123"
|
||||
LOG_LEVEL="error"
|
||||
EOF
|
||||
bash shared/scripts/test-isolated.sh --integration
|
||||
env:
|
||||
NODE_ENV: test
|
||||
|
||||
deploy:
|
||||
needs: test
|
||||
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Configure SSH
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||
run: |
|
||||
install -m 700 -d ~/.ssh
|
||||
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Deploy on VPS
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||
VPS_USER: ${{ secrets.VPS_USER }}
|
||||
VPS_PROJECT_PATH: ${{ secrets.VPS_PROJECT_PATH }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REMOTE_DIR="${VPS_PROJECT_PATH:-~/Aurora}"
|
||||
ssh -o BatchMode=yes "$VPS_USER@$VPS_HOST" "cd $REMOTE_DIR && bash shared/scripts/deploy.sh"
|
||||
|
||||
- name: Post-deploy Health Check
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||
VPS_USER: ${{ secrets.VPS_USER }}
|
||||
VPS_PROJECT_PATH: ${{ secrets.VPS_PROJECT_PATH }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REMOTE_DIR="${VPS_PROJECT_PATH:-~/Aurora}"
|
||||
ssh -o BatchMode=yes "$VPS_USER@$VPS_HOST" "cd $REMOTE_DIR && curl -fsS http://127.0.0.1:3000/api/health >/dev/null"
|
||||
100
.github/workflows/deploy.yml
vendored
Normal file
100
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
# Aurora CI/CD Pipeline
|
||||
# Builds, tests, and deploys to production server
|
||||
|
||||
name: Deploy to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
# ==========================================================================
|
||||
# Test Job
|
||||
# ==========================================================================
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: aurora_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
run: |
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
echo "$HOME/.bun/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Install Dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Create Config File
|
||||
run: |
|
||||
mkdir -p shared/config
|
||||
cat <<EOF > shared/config/config.json
|
||||
{
|
||||
"leveling": { "base": 100, "exponent": 2.5, "chat": { "cooldownMs": 60000, "minXp": 15, "maxXp": 25 } },
|
||||
"economy": {
|
||||
"daily": { "amount": "100", "streakBonus": "10", "weeklyBonus": "50", "cooldownMs": 86400000 },
|
||||
"transfers": { "allowSelfTransfer": false, "minAmount": "1" },
|
||||
"exam": { "multMin": 0.05, "multMax": 0.03 }
|
||||
},
|
||||
"inventory": { "maxStackSize": "99", "maxSlots": 50 },
|
||||
"commands": {},
|
||||
"lootdrop": {
|
||||
"activityWindowMs": 120000, "minMessages": 1, "spawnChance": 1, "cooldownMs": 3000,
|
||||
"reward": { "min": 40, "max": 150, "currency": "Astral Units" }
|
||||
},
|
||||
"studentRole": "123", "visitorRole": "456", "colorRoles": [],
|
||||
"moderation": {
|
||||
"prune": { "maxAmount": 100, "confirmThreshold": 50, "batchSize": 100, "batchDelayMs": 1000 },
|
||||
"cases": { "dmOnWarn": false }
|
||||
},
|
||||
"trivia": {
|
||||
"entryFee": "50", "rewardMultiplier": 1.5, "timeoutSeconds": 30, "cooldownMs": 60000,
|
||||
"categories": [], "difficulty": "random"
|
||||
},
|
||||
"system": {}
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Setup Test Database
|
||||
run: bun run db:push:local
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/aurora_test
|
||||
# Create .env.test for implicit usage by bun
|
||||
DISCORD_BOT_TOKEN: test_token
|
||||
DISCORD_CLIENT_ID: 123
|
||||
DISCORD_GUILD_ID: 123
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
# Create .env.test for the isolated test runner / bun test
|
||||
cat <<EOF > .env.test
|
||||
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/aurora_test"
|
||||
DISCORD_BOT_TOKEN="test_token"
|
||||
DISCORD_CLIENT_ID="123456789"
|
||||
DISCORD_GUILD_ID="123456789"
|
||||
ADMIN_TOKEN="admin_token_123"
|
||||
LOG_LEVEL="error"
|
||||
EOF
|
||||
bash shared/scripts/test-isolated.sh --integration
|
||||
env:
|
||||
NODE_ENV: test
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -3,6 +3,7 @@ node_modules
|
||||
docker-compose.override.yml
|
||||
shared/db-logs
|
||||
shared/db/data
|
||||
shared/db/backups
|
||||
shared/db/loga
|
||||
.cursor
|
||||
# dependencies (bun install)
|
||||
@@ -46,3 +47,8 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
src/db/data
|
||||
src/db/log
|
||||
scratchpad/
|
||||
bot/assets/graphics/items
|
||||
tickets/
|
||||
.citrine.local
|
||||
.worktrees/
|
||||
.superpowers/
|
||||
|
||||
293
AGENTS.md
293
AGENTS.md
@@ -1,238 +1,135 @@
|
||||
# AGENTS.md - AI Coding Agent Guidelines
|
||||
# AGENTS.md
|
||||
|
||||
## Project Overview
|
||||
This file documents the current implementation shape of the Aurora repository.
|
||||
|
||||
AuroraBot is a Discord bot with a web dashboard built using Bun, Discord.js, React, and PostgreSQL with Drizzle ORM.
|
||||
|
||||
## Build/Lint/Test Commands
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
bun --watch bot/index.ts # Run bot with hot reload
|
||||
bun --hot web/src/index.ts # Run web dashboard with hot reload
|
||||
# App
|
||||
bun run dev # bot + API in one Bun process with watch mode
|
||||
docker compose up # app + db
|
||||
docker compose up app # app only
|
||||
docker compose up db # database only
|
||||
|
||||
# Testing
|
||||
bun test # Run all tests
|
||||
bun test path/to/file.test.ts # Run single test file
|
||||
bun test --watch # Watch mode
|
||||
bun test shared/modules/economy # Run tests in directory
|
||||
bun test # Bun's native runner
|
||||
bun run test # repo test wrapper script
|
||||
bun run test:ci # include CI/integration path
|
||||
|
||||
# Database
|
||||
bun run generate # Generate Drizzle migrations (Docker)
|
||||
bun run migrate # Run migrations (Docker)
|
||||
bun run db:push # Push schema changes (Docker)
|
||||
bun run db:push:local # Push schema changes (local)
|
||||
bun run db:studio # Open Drizzle Studio
|
||||
bun run db:push # drizzle-kit push via Docker
|
||||
bun run db:push:local # drizzle-kit push locally
|
||||
bun run db:generate # drizzle-kit generate via Docker
|
||||
bun run db:migrate # drizzle-kit migrate via Docker
|
||||
bun run db:studio # local Drizzle Studio on :4983
|
||||
|
||||
# Web Dashboard
|
||||
cd web && bun run build # Build production web assets
|
||||
cd web && bun run dev # Development server
|
||||
# Panel
|
||||
bun run panel:dev # Vite dev server on :5173
|
||||
bun run panel:build # build panel/dist
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
## Architecture
|
||||
|
||||
```
|
||||
bot/ # Discord bot
|
||||
├── commands/ # Slash commands by category
|
||||
├── events/ # Discord event handlers
|
||||
├── lib/ # Bot core (BotClient, handlers, loaders)
|
||||
├── modules/ # Feature modules (views, interactions)
|
||||
└── graphics/ # Canvas image generation
|
||||
Aurora is a single-process Bun application:
|
||||
|
||||
shared/ # Shared between bot and web
|
||||
├── db/ # Database schema and migrations
|
||||
├── lib/ # Utils, config, errors, types
|
||||
└── modules/ # Domain services (economy, user, etc.)
|
||||
- `bot/index.ts` boots shared config, registers domain listeners, starts the API server, then logs into Discord.
|
||||
- `api/src/server.ts` hosts REST routes, WebSocket traffic, and built panel assets.
|
||||
- `shared/modules/*` contains the business logic used by both the bot and the API.
|
||||
- `shared/games/*` contains reusable game plugins; `api/src/games/*` runs rooms and WebSocket orchestration.
|
||||
|
||||
web/ # React dashboard
|
||||
├── src/pages/ # React pages
|
||||
├── src/components/ # UI components (ShadCN/Radix)
|
||||
└── src/hooks/ # React hooks
|
||||
Current high-level layout:
|
||||
|
||||
```text
|
||||
bot/ Discord commands, events, views, interactions
|
||||
api/ Bun HTTP + WebSocket server
|
||||
panel/ React dashboard
|
||||
shared/db/ Drizzle client and schema
|
||||
shared/lib/ config, env, errors, logger, events, constants
|
||||
shared/modules/ domain services
|
||||
shared/games/ game plugins shared by API and panel
|
||||
```
|
||||
|
||||
## Import Conventions
|
||||
## Import conventions
|
||||
|
||||
Use path aliases defined in tsconfig.json:
|
||||
Use path aliases from the repo `tsconfig.json`:
|
||||
|
||||
```typescript
|
||||
// External packages first
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
- `@/*` -> `bot/*`
|
||||
- `@commands/*` -> `bot/commands/*`
|
||||
- `@db/*` -> `shared/db/*`
|
||||
- `@lib/*` -> `bot/lib/*`
|
||||
- `@modules/*` -> `bot/modules/*`
|
||||
- `@shared/*` -> `shared/*`
|
||||
|
||||
// Path aliases second
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { users } from "@db/schema";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { handleTradeInteraction } from "@modules/trade/trade.interaction";
|
||||
Import order in the repo is generally:
|
||||
|
||||
// Relative imports last
|
||||
import { localHelper } from "./helper";
|
||||
```
|
||||
1. external packages
|
||||
2. aliases
|
||||
3. relative imports
|
||||
|
||||
**Available Aliases:**
|
||||
- `@/*` - bot/
|
||||
- `@shared/*` - shared/
|
||||
- `@db/*` - shared/db/
|
||||
- `@lib/*` - bot/lib/
|
||||
- `@modules/*` - bot/modules/
|
||||
- `@commands/*` - bot/commands/
|
||||
## File patterns
|
||||
|
||||
## Naming Conventions
|
||||
- `*.service.ts`: domain/business logic, usually in `shared/modules/*`
|
||||
- `*.view.ts`: Discord message/view construction
|
||||
- `*.interaction.ts`: component interaction handlers
|
||||
- `*.types.ts`: local types and custom ID helpers
|
||||
- `*.handler.ts`: bot-side orchestration around services/views
|
||||
- `*.test.ts`: colocated tests
|
||||
|
||||
| Element | Convention | Example |
|
||||
|---------|------------|---------|
|
||||
| Files | camelCase or kebab-case | `BotClient.ts`, `economy.service.ts` |
|
||||
| Classes | PascalCase | `CommandHandler`, `UserError` |
|
||||
| Functions | camelCase | `createCommand`, `handleShopInteraction` |
|
||||
| Constants | UPPER_SNAKE_CASE | `EVENTS`, `BRANDING` |
|
||||
| Enums | PascalCase | `TimerType`, `TransactionType` |
|
||||
| Services | camelCase singleton | `economyService`, `userService` |
|
||||
| Types/Interfaces | PascalCase | `Command`, `Event`, `GameConfigType` |
|
||||
| DB tables | snake_case | `users`, `moderation_cases` |
|
||||
| Custom IDs | snake_case with prefix | `shop_buy_`, `trade_accept_` |
|
||||
## Runtime config
|
||||
|
||||
## Code Patterns
|
||||
- Global game settings live in `game_settings` and are loaded into `shared/lib/config.ts`.
|
||||
- Guild-specific settings live in `guild_settings`; `getGuildConfig()` adds a 60-second cache on top of DB reads.
|
||||
- Most numeric DB values exposed through runtime config are converted to `bigint` in `shared/lib/config.ts`.
|
||||
|
||||
### Command Definition
|
||||
## Interaction routing
|
||||
|
||||
```typescript
|
||||
export const commandName = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("commandname")
|
||||
.setDescription("Description"),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
// Implementation
|
||||
}
|
||||
});
|
||||
```
|
||||
Global component routing is defined in `bot/lib/interaction.routes.ts` and consumed by `ComponentInteractionHandler`.
|
||||
|
||||
### Service Pattern (Singleton Object)
|
||||
Current route table:
|
||||
|
||||
```typescript
|
||||
export const serviceName = {
|
||||
methodName: async (params: ParamType): Promise<ReturnType> => {
|
||||
return await withTransaction(async (tx) => {
|
||||
// Database operations
|
||||
});
|
||||
},
|
||||
};
|
||||
```
|
||||
- `trade_` and `amount` -> `bot/modules/trade/trade.interaction.ts`
|
||||
- `shop_buy_` -> `bot/modules/economy/shop.interaction.ts`
|
||||
- `lootdrop_` -> `bot/modules/economy/lootdrop.interaction.ts`
|
||||
- `trivia_` -> `bot/modules/trivia/trivia.interaction.ts`
|
||||
- `createitem_` -> `bot/modules/admin/item_wizard.ts`
|
||||
- `enrollment` -> `bot/modules/user/enrollment.interaction.ts`
|
||||
- `feedback_` -> `bot/modules/feedback/feedback.interaction.ts`
|
||||
|
||||
### Module File Organization
|
||||
Some features still use local collectors instead of the global route table, notably inventory.
|
||||
|
||||
- `*.view.ts` - Creates Discord embeds/components
|
||||
- `*.interaction.ts` - Handles button/select/modal interactions
|
||||
- `*.types.ts` - Module-specific TypeScript types
|
||||
- `*.service.ts` - Business logic (in shared/modules/)
|
||||
- `*.test.ts` - Test files (co-located with source)
|
||||
## Commands and access control
|
||||
|
||||
## Error Handling
|
||||
- Slash command execution is centralized in `bot/lib/handlers/CommandHandler.ts`.
|
||||
- `withCommandErrorHandling()` is the normal command wrapper for defer/reply/error behavior.
|
||||
- Beta commands rely on `featureFlagsService.hasAccess()`.
|
||||
- `ADMIN_USER_IDS` controls admin panel access, not Discord permissions inside command code.
|
||||
|
||||
### Custom Error Classes
|
||||
## API and panel
|
||||
|
||||
```typescript
|
||||
import { UserError, SystemError } from "@shared/lib/errors";
|
||||
- API routes are prefix-matched in `api/src/routes/index.ts`.
|
||||
- `/auth/*` and `/api/health` are public.
|
||||
- Players may access `/api/stats`, `/api/health`, `/api/me`, and `/api/me/inventory`.
|
||||
- Remaining `/api/*` routes are admin-only.
|
||||
- The panel dev server proxies back to the Bun server; the integrated server serves `panel/dist` when built.
|
||||
|
||||
// User-facing errors (shown to user)
|
||||
throw new UserError("You don't have enough coins!");
|
||||
## Database notes
|
||||
|
||||
// System errors (logged, generic message shown)
|
||||
throw new SystemError("Database connection failed");
|
||||
```
|
||||
|
||||
### Standard Error Pattern
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const result = await service.method();
|
||||
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
||||
} catch (error) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Unexpected error:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Database Patterns
|
||||
|
||||
### Transaction Usage
|
||||
|
||||
```typescript
|
||||
import { withTransaction } from "@/lib/db";
|
||||
|
||||
return await withTransaction(async (tx) => {
|
||||
const user = await tx.query.users.findFirst({
|
||||
where: eq(users.id, discordId)
|
||||
});
|
||||
|
||||
await tx.update(users).set({ coins: newBalance }).where(eq(users.id, discordId));
|
||||
await tx.insert(transactions).values({ userId: discordId, amount, type });
|
||||
|
||||
return user;
|
||||
}, existingTx); // Pass existing tx if in nested transaction
|
||||
```
|
||||
|
||||
### Schema Notes
|
||||
|
||||
- Use `bigint` mode for Discord IDs and currency amounts
|
||||
- Relations defined separately from table definitions
|
||||
- Schema location: `shared/db/schema.ts`
|
||||
- Docker Compose uses PostgreSQL 17.
|
||||
- Discord IDs and currency/xp values are stored as `bigint`.
|
||||
- `withTransaction()` lives in `bot/lib/db.ts` and is the normal way shared services compose DB work.
|
||||
|
||||
## Testing
|
||||
|
||||
### Test File Structure
|
||||
- Tests use `bun:test`.
|
||||
- Mock modules before importing the unit under test.
|
||||
- Most service tests stub `DrizzleClient` or `withTransaction()` rather than hitting the real database.
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||
## Key entrypoints
|
||||
|
||||
// Mock modules BEFORE imports
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: { query: mockQuery }
|
||||
}));
|
||||
|
||||
describe("serviceName", () => {
|
||||
beforeEach(() => {
|
||||
mockFn.mockClear();
|
||||
});
|
||||
|
||||
it("should handle expected case", async () => {
|
||||
// Arrange
|
||||
mockFn.mockResolvedValue(testData);
|
||||
|
||||
// Act
|
||||
const result = await service.method(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(expected);
|
||||
expect(mockFn).toHaveBeenCalledWith(expectedArgs);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime:** Bun 1.0+
|
||||
- **Bot:** Discord.js 14.x
|
||||
- **Web:** React 19 + Bun HTTP Server
|
||||
- **Database:** PostgreSQL 16+ with Drizzle ORM
|
||||
- **UI:** Tailwind CSS v4 + ShadCN/Radix
|
||||
- **Validation:** Zod
|
||||
- **Testing:** Bun Test
|
||||
- **Container:** Docker
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
| Purpose | File |
|
||||
|---------|------|
|
||||
| Bot entry | `bot/index.ts` |
|
||||
| DB schema | `shared/db/schema.ts` |
|
||||
| Error classes | `shared/lib/errors.ts` |
|
||||
| Config loader | `shared/lib/config.ts` |
|
||||
| Environment | `shared/lib/env.ts` |
|
||||
| Embed helpers | `bot/lib/embeds.ts` |
|
||||
| Command utils | `shared/lib/utils.ts` |
|
||||
- `bot/index.ts`
|
||||
- `bot/lib/BotClient.ts`
|
||||
- `api/src/server.ts`
|
||||
- `api/src/routes/index.ts`
|
||||
- `shared/lib/config.ts`
|
||||
- `shared/db/DrizzleClient.ts`
|
||||
- `shared/db/schema/index.ts`
|
||||
|
||||
73
Dockerfile
73
Dockerfile
@@ -1,22 +1,77 @@
|
||||
# ============================================
|
||||
# Base stage - shared configuration
|
||||
# ============================================
|
||||
FROM oven/bun:latest AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
||||
# Install system dependencies with cleanup in same layer
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends git && \
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# Install root project dependencies
|
||||
# ============================================
|
||||
# Dependencies stage - installs all deps
|
||||
# ============================================
|
||||
FROM base AS deps
|
||||
|
||||
# Copy only package files first (better layer caching)
|
||||
COPY package.json bun.lock ./
|
||||
COPY panel/package.json panel/
|
||||
|
||||
# Install dependencies
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Install web project dependencies
|
||||
COPY web/package.json web/bun.lock ./web/
|
||||
RUN cd web && bun install --frozen-lockfile
|
||||
# ============================================
|
||||
# Development stage - for local dev with volume mounts
|
||||
# ============================================
|
||||
FROM base AS development
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
# Copy dependencies from deps stage
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
# Expose ports (3000 for web dashboard)
|
||||
# Expose ports
|
||||
EXPOSE 3000
|
||||
|
||||
# Default command
|
||||
CMD ["bun", "run", "dev"]
|
||||
|
||||
# ============================================
|
||||
# Builder stage - copies source for production
|
||||
# ============================================
|
||||
FROM base AS builder
|
||||
|
||||
# Copy source code first, then deps on top (so node_modules aren't overwritten)
|
||||
COPY . .
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
# Install panel deps and build
|
||||
RUN cd panel && bun install --frozen-lockfile && bun run build
|
||||
|
||||
# ============================================
|
||||
# Production stage - minimal runtime image
|
||||
# ============================================
|
||||
FROM oven/bun:latest AS production
|
||||
WORKDIR /app
|
||||
|
||||
# Copy only what's needed for production
|
||||
COPY --from=builder --chown=bun:bun /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=bun:bun /app/api/src ./api/src
|
||||
COPY --from=builder --chown=bun:bun /app/bot ./bot
|
||||
COPY --from=builder --chown=bun:bun /app/shared ./shared
|
||||
COPY --from=builder --chown=bun:bun /app/panel/dist ./panel/dist
|
||||
COPY --from=builder --chown=bun:bun /app/package.json .
|
||||
COPY --from=builder --chown=bun:bun /app/drizzle.config.ts .
|
||||
COPY --from=builder --chown=bun:bun /app/tsconfig.json .
|
||||
|
||||
# Switch to non-root user
|
||||
USER bun
|
||||
|
||||
# Expose web dashboard port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD bun -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
|
||||
|
||||
# Run in production mode
|
||||
CMD ["bun", "run", "bot/index.ts"]
|
||||
|
||||
271
README.md
271
README.md
@@ -1,160 +1,181 @@
|
||||
# Aurora
|
||||
|
||||
> A comprehensive, feature-rich Discord RPG bot built with modern technologies.
|
||||
Aurora is a Discord RPG bot, admin/player panel, and REST/WebSocket API that run as one Bun application. The Discord bot and HTTP server share the same database client, config, services, and domain events.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
## What exists today
|
||||
|
||||
Aurora is a powerful Discord bot designed to facilitate RPG-like elements within a Discord server. It features a robust economy, class system, inventory management, quests, and more, all built on top of a high-performance stack using Bun and Drizzle ORM.
|
||||
- Discord slash commands for economy, inventory, quests, moderation, feedback, user profiles, and admin tooling.
|
||||
- A Bun HTTP API under `/api/*`, Discord OAuth under `/auth/*`, and a WebSocket endpoint at `/ws`.
|
||||
- A React panel for both admins and enrolled players.
|
||||
- Shared domain services in `shared/modules/*` and reusable game plugins in `shared/games/*`.
|
||||
- Built-in real-time games: chess and blackjack.
|
||||
|
||||
**New in v1.0:** Aurora now includes a fully integrated **Web Dashboard** for managing the bot, viewing statistics, and configuring settings, running alongside the bot in a single process.
|
||||
## Architecture
|
||||
|
||||
## ✨ Features
|
||||
```text
|
||||
bot/ Discord bot entrypoint, commands, events, Discord-facing views/interactions
|
||||
api/ Bun HTTP server, route modules, WebSocket/game room server
|
||||
panel/ React 19 + Vite + Tailwind v4 dashboard
|
||||
shared/ Shared DB schema, services, config, events, utilities, game plugins
|
||||
docs/ Product and design notes
|
||||
```
|
||||
|
||||
### Discord Bot
|
||||
* **Class System**: Users can join different classes.
|
||||
* **Economy**: Complete economy system with balance, transactions, and daily rewards.
|
||||
* **Inventory & Items**: Sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management.
|
||||
* **Leveling**: XP-based leveling system to track user activity and progress.
|
||||
* **Quests**: Quest system with requirements and rewards.
|
||||
* **Trading**: Secure trading system between users.
|
||||
* **Lootdrops**: Random loot drops in channels to engage users.
|
||||
* **Admin Tools**: Administrative commands for server management.
|
||||
Important points:
|
||||
|
||||
### Web Dashboard
|
||||
* **Live Analytics**: View real-time activity charts (commands, transactions).
|
||||
* **Configuration Management**: Update bot settings without restarting.
|
||||
* **Database Inspection**: Integrated Drizzle Studio access.
|
||||
* **State Monitoring**: View internal bot state (Lootdrops, etc.).
|
||||
- `bot/index.ts` initializes DB-backed config, wires domain events, starts the API server, then logs into Discord.
|
||||
- The API server also serves built panel assets from `panel/dist` when they exist.
|
||||
- Bot commands, API routes, and the panel all rely on the same service layer in `shared/modules/*`.
|
||||
- Runtime game config is loaded from the `game_settings` table into `shared/lib/config.ts`.
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
Aurora uses a **Single Process Monolith** architecture to maximize performance and simplify resource sharing.
|
||||
|
||||
* **Unified Runtime**: Both the Discord Client and the Web Dashboard run within the same Bun process.
|
||||
* **Shared State**: This allows the Dashboard to access live bot memory (caches, gateways) directly without complex inter-process communication (IPC).
|
||||
* **Simplified Deployment**: You only need to deploy a single Docker container.
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
* **Runtime**: [Bun](https://bun.sh/)
|
||||
* **Bot Framework**: [Discord.js](https://discord.js.org/)
|
||||
* **Web Framework**: [React 19](https://react.dev/) + [Vite](https://vitejs.dev/) (served via Bun)
|
||||
* **Styling**: [Tailwind CSS v4](https://tailwindcss.com/) + [Radix UI](https://www.radix-ui.com/)
|
||||
* **Database**: [PostgreSQL](https://www.postgresql.org/)
|
||||
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
||||
* **Validation**: [Zod](https://zod.dev/)
|
||||
* **Containerization**: [Docker](https://www.docker.com/)
|
||||
|
||||
## 🚀 Getting Started
|
||||
## Getting started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
* [Bun](https://bun.sh/) (latest version)
|
||||
* [Docker](https://www.docker.com/) & Docker Compose
|
||||
- Bun
|
||||
- Docker and Docker Compose
|
||||
- A Discord application with bot token, client ID, and client secret
|
||||
|
||||
### Installation
|
||||
### Setup
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd aurora
|
||||
```
|
||||
1. Install dependencies.
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
3. **Environment Setup**
|
||||
Copy the example environment file and configure it:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
Edit `.env` with your Discord bot token, Client ID, and database credentials.
|
||||
2. Create your environment file.
|
||||
|
||||
> **Note**: The `DATABASE_URL` in `.env.example` is pre-configured for Docker.
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
4. **Start the Database**
|
||||
Run the database service using Docker Compose:
|
||||
```bash
|
||||
docker compose up -d db
|
||||
```
|
||||
3. Start PostgreSQL.
|
||||
|
||||
5. **Run Migrations**
|
||||
```bash
|
||||
bun run migrate
|
||||
```
|
||||
OR
|
||||
```bash
|
||||
bun run db:push
|
||||
```
|
||||
```bash
|
||||
docker compose up -d db
|
||||
```
|
||||
|
||||
### Running the Bot & Dashboard
|
||||
4. Initialize the schema.
|
||||
|
||||
```bash
|
||||
bun run db:push:local
|
||||
```
|
||||
|
||||
If you prefer running schema changes through Docker:
|
||||
|
||||
```bash
|
||||
bun run migrate
|
||||
```
|
||||
|
||||
5. Start the bot and API.
|
||||
|
||||
**Development Mode** (with hot reload):
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
* Bot: Online in Discord
|
||||
* Dashboard: http://localhost:3000
|
||||
|
||||
**Production Mode**:
|
||||
Build and run with Docker (recommended):
|
||||
The Bun server listens on `http://localhost:3000`.
|
||||
|
||||
### Panel development
|
||||
|
||||
The Bun server can serve a built panel, but day-to-day panel work is done with Vite:
|
||||
|
||||
```bash
|
||||
docker compose up -d app
|
||||
bun run panel:dev
|
||||
```
|
||||
|
||||
### 🔐 Accessing Production Services (SSH Tunnel)
|
||||
The panel dev server runs on `http://localhost:5173` and proxies `/api`, `/auth`, `/assets`, and `/ws` to `http://localhost:3000`.
|
||||
|
||||
For security, the Production Database and Dashboard are **not exposed** to the public internet by default. They are only accessible via localhost on the server.
|
||||
To build the panel for the integrated Bun server:
|
||||
|
||||
To access them from your local machine, use the included SSH tunnel script.
|
||||
|
||||
1. Add your VPS details to your local `.env` file:
|
||||
```env
|
||||
VPS_USER=root
|
||||
VPS_HOST=123.45.67.89
|
||||
```
|
||||
|
||||
2. Run the remote connection script:
|
||||
```bash
|
||||
bun run remote
|
||||
```
|
||||
|
||||
This will establish secure tunnels for:
|
||||
* **Dashboard**: http://localhost:3000
|
||||
* **Drizzle Studio**: http://localhost:4983
|
||||
|
||||
## 📜 Scripts
|
||||
|
||||
* `bun run dev`: Start the bot and dashboard in watch mode.
|
||||
* `bun run remote`: Open SSH tunnel to production services.
|
||||
* `bun run generate`: Generate Drizzle migrations.
|
||||
* `bun run migrate`: Apply migrations (via Docker).
|
||||
* `bun run db:studio`: Open Drizzle Studio to inspect the database.
|
||||
* `bun test`: Run tests.
|
||||
|
||||
## 📂 Project Structure
|
||||
|
||||
```
|
||||
├── bot # Discord Bot logic & entry point
|
||||
├── web # React Web Dashboard (Frontend + Server)
|
||||
├── shared # Shared code (Database, Config, Types)
|
||||
├── drizzle # Drizzle migration files
|
||||
├── scripts # Utility scripts
|
||||
├── docker-compose.yml
|
||||
└── package.json
|
||||
```bash
|
||||
bun run panel:build
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
## Useful scripts
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
```bash
|
||||
# App
|
||||
bun run dev
|
||||
docker compose up
|
||||
docker compose up app
|
||||
docker compose up db
|
||||
|
||||
## 📄 License
|
||||
# Database
|
||||
bun run db:push
|
||||
bun run db:push:local
|
||||
bun run db:generate
|
||||
bun run db:migrate
|
||||
bun run db:studio
|
||||
bun run db:backup
|
||||
bun run db:restore
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
# Panel
|
||||
bun run panel:dev
|
||||
bun run panel:build
|
||||
|
||||
# Tests
|
||||
bun test
|
||||
bun run test
|
||||
bun run test:ci
|
||||
|
||||
# Ops
|
||||
bun run remote
|
||||
bun run deploy
|
||||
bun run deploy:remote
|
||||
```
|
||||
|
||||
## Environment notes
|
||||
|
||||
The main variables you need in `.env` are:
|
||||
|
||||
- `DISCORD_BOT_TOKEN`
|
||||
- `DISCORD_CLIENT_ID`
|
||||
- `DISCORD_CLIENT_SECRET`
|
||||
- `DISCORD_GUILD_ID`
|
||||
- `ADMIN_USER_IDS`
|
||||
- `SESSION_SECRET`
|
||||
- `DB_USER`
|
||||
- `DB_PASSWORD`
|
||||
- `DB_NAME`
|
||||
- `DATABASE_URL`
|
||||
- `PANEL_BASE_URL`
|
||||
|
||||
Players can authenticate into the panel only after they exist in the `users` table. Admin access is determined by `ADMIN_USER_IDS`, and panel sessions are stored in signed cookies keyed by `SESSION_SECRET`.
|
||||
|
||||
## API and panel summary
|
||||
|
||||
- Public routes: `/auth/*`, `/api/health`
|
||||
- Player-accessible API routes: `/api/stats`, `/api/health`, `/api/me`, `/api/me/inventory`
|
||||
- Admin-only API routes: the rest of `/api/*`
|
||||
- WebSocket: `/ws` with cookie-based auth
|
||||
- Static assets: `/assets/*`
|
||||
|
||||
## Project structure
|
||||
|
||||
```text
|
||||
bot/
|
||||
commands/
|
||||
events/
|
||||
lib/
|
||||
modules/
|
||||
|
||||
api/
|
||||
src/
|
||||
routes/
|
||||
games/
|
||||
|
||||
panel/
|
||||
src/
|
||||
|
||||
shared/
|
||||
db/
|
||||
games/
|
||||
lib/
|
||||
modules/
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [AGENTS.md](AGENTS.md): repo-wide implementation guidance
|
||||
- [api/README.md](api/README.md): API surface and auth model
|
||||
- [docs/new-design/DESIGN.md](docs/new-design/DESIGN.md): current panel design language
|
||||
|
||||
0
web/.gitignore → api/.gitignore
vendored
0
web/.gitignore → api/.gitignore
vendored
130
api/README.md
Normal file
130
api/README.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Aurora API
|
||||
|
||||
Aurora's API is a Bun server that runs inside the same process as the Discord bot. It serves REST routes, the authenticated WebSocket endpoint, static assets, and built panel files.
|
||||
|
||||
## Runtime model
|
||||
|
||||
- Entry point: `api/src/server.ts`
|
||||
- Route dispatcher: `api/src/routes/index.ts`
|
||||
- Auth: Discord OAuth with signed session cookies
|
||||
- WebSocket: `/ws`
|
||||
- Static assets: `/assets/*`
|
||||
- Built panel fallback: `panel/dist`
|
||||
|
||||
## Access model
|
||||
|
||||
Public:
|
||||
|
||||
- `GET /api/health`
|
||||
- `/auth/discord`
|
||||
- `/auth/callback`
|
||||
- `POST /auth/logout`
|
||||
- `GET /auth/me`
|
||||
|
||||
Player-accessible API routes:
|
||||
|
||||
- `GET /api/stats`
|
||||
- `GET /api/health`
|
||||
- `GET /api/me`
|
||||
- `GET /api/me/inventory`
|
||||
|
||||
Admin-only API routes:
|
||||
|
||||
- everything else under `/api/*`
|
||||
|
||||
Admin vs player is derived from `ADMIN_USER_IDS`. A user must already exist in the `users` table to complete panel login.
|
||||
|
||||
## Route summary
|
||||
|
||||
### Auth
|
||||
|
||||
- `GET /auth/discord`
|
||||
- `GET /auth/callback`
|
||||
- `POST /auth/logout`
|
||||
- `GET /auth/me`
|
||||
|
||||
### Dashboard and system
|
||||
|
||||
- `GET /api/health`
|
||||
- `GET /api/stats`
|
||||
- `GET /api/stats/activity`
|
||||
- `POST /api/actions/reload-commands`
|
||||
- `POST /api/actions/clear-cache`
|
||||
- `POST /api/actions/maintenance-mode`
|
||||
|
||||
### Settings
|
||||
|
||||
- `GET /api/settings`
|
||||
- `POST /api/settings`
|
||||
- `GET /api/settings/meta`
|
||||
- `GET /api/guilds/:guildId/settings`
|
||||
- `PUT|PATCH /api/guilds/:guildId/settings`
|
||||
- `DELETE /api/guilds/:guildId/settings`
|
||||
|
||||
### Users, classes, and inventory
|
||||
|
||||
- `GET /api/me`
|
||||
- `GET /api/me/inventory`
|
||||
- `GET /api/users`
|
||||
- `GET /api/users/:id`
|
||||
- `PUT /api/users/:id`
|
||||
- `GET /api/users/:id/inventory`
|
||||
- `POST /api/users/:id/inventory`
|
||||
- `DELETE /api/users/:id/inventory/:itemId`
|
||||
- `GET /api/classes`
|
||||
- `POST /api/classes`
|
||||
- `PUT /api/classes/:id`
|
||||
- `DELETE /api/classes/:id`
|
||||
|
||||
### Game content
|
||||
|
||||
- `GET /api/items`
|
||||
- `POST /api/items`
|
||||
- `GET /api/items/:id`
|
||||
- `PUT /api/items/:id`
|
||||
- `DELETE /api/items/:id`
|
||||
- `POST /api/items/:id/icon`
|
||||
- `GET /api/quests`
|
||||
- `POST /api/quests`
|
||||
- `PUT /api/quests/:id`
|
||||
- `DELETE /api/quests/:id`
|
||||
- `GET /api/lootdrops`
|
||||
- `POST /api/lootdrops`
|
||||
- `DELETE /api/lootdrops/:messageId`
|
||||
|
||||
### Moderation and economy history
|
||||
|
||||
- `GET /api/moderation`
|
||||
- `POST /api/moderation`
|
||||
- `GET /api/transactions`
|
||||
|
||||
## WebSocket
|
||||
|
||||
`/ws` requires a valid `aurora_session` cookie.
|
||||
|
||||
Current behavior:
|
||||
|
||||
- dashboard clients subscribe to `dashboard`
|
||||
- game clients also use lobby and room-scoped traffic through `GameServer`
|
||||
- `PING` from the client returns `PONG`
|
||||
- dashboard stats are broadcast every 5 seconds while at least one client is connected
|
||||
- hard limits in `api/src/server.ts`:
|
||||
- 200 concurrent connections
|
||||
- 16 KB max payload
|
||||
- 60 second idle timeout
|
||||
|
||||
## Development
|
||||
|
||||
Start the backend:
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Optional panel dev server:
|
||||
|
||||
```bash
|
||||
bun run panel:dev
|
||||
```
|
||||
|
||||
Panel dev runs on `http://localhost:5173` and proxies API/auth/assets/WebSocket requests to `http://localhost:3000`.
|
||||
0
web/bun-env.d.ts → api/bun-env.d.ts
vendored
0
web/bun-env.d.ts → api/bun-env.d.ts
vendored
68
api/src/AGENTS.md
Normal file
68
api/src/AGENTS.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# API layer
|
||||
|
||||
## Server shape
|
||||
|
||||
- Aurora uses Bun's native `serve()` API in `api/src/server.ts`.
|
||||
- Route modules are aggregated in `api/src/routes/index.ts`.
|
||||
- A route module returns `null` when it does not match so the dispatcher can continue.
|
||||
- After route handling, the server tries `panel/dist` for SPA/static files.
|
||||
|
||||
## Authentication and authorization
|
||||
|
||||
- OAuth routes live in `api/src/routes/auth.routes.ts`.
|
||||
- Sessions are stored in signed `aurora_session` cookies.
|
||||
- Session TTL is 7 days.
|
||||
- Login succeeds only for users already present in the `users` table.
|
||||
- Role is `admin` if the Discord ID is in `ADMIN_USER_IDS`, otherwise `player`.
|
||||
- Redirects after login are intentionally restricted to localhost or relative paths.
|
||||
|
||||
Current access rules from `api/src/routes/index.ts`:
|
||||
|
||||
- public: `/auth/*`, `/api/health`
|
||||
- player allow-list: `/api/stats`, `/api/health`, `/api/me`
|
||||
- everything else under `/api/*`: admin-only
|
||||
|
||||
`/api/me/inventory` is handled by `users.routes.ts` and still depends on a valid session.
|
||||
|
||||
## Response conventions
|
||||
|
||||
- `jsonResponse()` serializes `bigint` values as strings.
|
||||
- `errorResponse()` returns `{ error, details? }`.
|
||||
- `parseBody()` and `parseQuery()` validate with Zod and return a `Response` on failure.
|
||||
- The API does not use a framework-level middleware stack; each route handles its own parsing and branching.
|
||||
|
||||
## WebSocket
|
||||
|
||||
- Endpoint: `/ws`
|
||||
- Requires an authenticated session
|
||||
- Dashboard channel: `dashboard`
|
||||
- Lobby channel: `lobby`
|
||||
- Room-specific messaging is handled inside `GameServer`
|
||||
- Dashboard broadcasts `STATS_UPDATE` every 5 seconds while clients are connected
|
||||
- `NEW_EVENT` broadcasts are wired from `shared/lib/events`
|
||||
|
||||
Hard limits:
|
||||
|
||||
- max connections: 200
|
||||
- max payload: 16 KB
|
||||
- idle timeout: 60 seconds
|
||||
|
||||
## Static files
|
||||
|
||||
- Built panel assets are served from `panel/dist`
|
||||
- `/assets/*` serves files from `bot/assets/graphics`
|
||||
- `/api/*`, `/auth/*`, `/ws`, and `/assets/*` bypass the SPA fallback
|
||||
|
||||
## Route notes
|
||||
|
||||
- `items.routes.ts` supports both JSON and multipart form data for item creation.
|
||||
- `settings.routes.ts` writes DB-backed game settings and emits the reload-commands event.
|
||||
- `guild-settings.routes.ts` invalidates the guild config cache after writes.
|
||||
- `lootdrops.routes.ts` delegates spawning/deletion to bot-side handlers because Discord message creation happens there.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Some runtime caches are in-memory only and are lost on restart.
|
||||
- The server registers game plugins at startup; duplicate registration throws.
|
||||
- BigInt-safe JSON matters for nearly every domain route.
|
||||
- The panel's auth flow depends on `PANEL_BASE_URL` matching the OAuth callback origin.
|
||||
540
api/src/games/GameServer.ts
Normal file
540
api/src/games/GameServer.ts
Normal file
@@ -0,0 +1,540 @@
|
||||
import { RoomManager } from "./RoomManager";
|
||||
import { GameWsClientSchema } from "./types";
|
||||
import type { PlayerInfo } from "./types";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { TransactionType } from "@shared/lib/constants";
|
||||
import { gameRegistry } from "@shared/games/registry";
|
||||
import type { Server, ServerWebSocket } from "bun";
|
||||
|
||||
export interface WsConnectionData {
|
||||
session: { discordId: string; username: string; role: string };
|
||||
rooms: Set<string>;
|
||||
}
|
||||
|
||||
export class GameServer {
|
||||
readonly roomManager = new RoomManager();
|
||||
private connections = new Map<string, ServerWebSocket<WsConnectionData>>();
|
||||
private replacedConnections = new Map<string, ServerWebSocket<WsConnectionData>>();
|
||||
private bunServer: Server<WsConnectionData> | null = null;
|
||||
|
||||
constructor() {
|
||||
// Subscribe to room events and route them to the right clients
|
||||
|
||||
this.roomManager.emitter.on("room:created", ({ roomId, gameSlug }) => {
|
||||
// The creating connection will subscribe itself; just broadcast room list
|
||||
this.publishRoomListUpdate();
|
||||
});
|
||||
|
||||
this.roomManager.emitter.on("game:started", ({ roomId, spectatorView, playerViews }) => {
|
||||
// Send personalised state to each player
|
||||
for (const [playerId, view] of playerViews) {
|
||||
this.sendToPlayer(playerId, {
|
||||
type: "GAME_STATE",
|
||||
roomId,
|
||||
state: view,
|
||||
});
|
||||
}
|
||||
// Broadcast started event with spectator view to the room channel
|
||||
this.publish(`room:${roomId}`, {
|
||||
type: "GAME_STARTED",
|
||||
roomId,
|
||||
state: spectatorView,
|
||||
});
|
||||
});
|
||||
|
||||
this.roomManager.emitter.on("game:updated", ({ roomId, spectatorView, playerViews }) => {
|
||||
// Each player gets their personalised view directly
|
||||
for (const [playerId, view] of playerViews) {
|
||||
this.sendToPlayer(playerId, {
|
||||
type: "GAME_STATE",
|
||||
roomId,
|
||||
state: view,
|
||||
});
|
||||
}
|
||||
// Spectators/others get the spectator view via pub/sub
|
||||
this.publish(`room:${roomId}`, {
|
||||
type: "GAME_UPDATE",
|
||||
roomId,
|
||||
state: spectatorView,
|
||||
});
|
||||
});
|
||||
|
||||
this.roomManager.emitter.on("game:ended", ({ roomId, winner, reason, payouts }) => {
|
||||
const room = this.roomManager.getRoom(roomId);
|
||||
const betAmount = room?.betAmount ?? 0;
|
||||
|
||||
// Handle bet payouts asynchronously — broadcast happens after settlement
|
||||
if (betAmount > 0) {
|
||||
this.settleBets(roomId, winner, betAmount, payouts).then((payout) => {
|
||||
this.publish(`room:${roomId}`, {
|
||||
type: "GAME_ENDED",
|
||||
roomId,
|
||||
winner,
|
||||
reason,
|
||||
payout,
|
||||
});
|
||||
this.publishRoomListUpdate();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.publish(`room:${roomId}`, {
|
||||
type: "GAME_ENDED",
|
||||
roomId,
|
||||
winner,
|
||||
reason,
|
||||
});
|
||||
this.publishRoomListUpdate();
|
||||
});
|
||||
|
||||
this.roomManager.emitter.on("round:settled", async ({ roomId, roundSettlements }) => {
|
||||
const room = this.roomManager.getRoom(roomId);
|
||||
if (!room || room.betAmount <= 0) return;
|
||||
const gameName = gameRegistry.get(room.gameSlug)?.name ?? "Game";
|
||||
const settlementDetails: typeof roundSettlements = {};
|
||||
|
||||
for (const [playerId, settlement] of Object.entries(roundSettlements)) {
|
||||
try {
|
||||
if (settlement.payout > 0) {
|
||||
await economyService.modifyUserBalance(
|
||||
playerId,
|
||||
BigInt(settlement.payout),
|
||||
TransactionType.GAME_WIN,
|
||||
`${gameName} round payout (room ${roomId.slice(0, 8)})`,
|
||||
);
|
||||
}
|
||||
settlementDetails[playerId] = settlement;
|
||||
} catch (err) {
|
||||
logger.error("web", `Round payout failed for ${playerId} in room ${roomId}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(settlementDetails).length > 0) {
|
||||
this.publish(`room:${roomId}`, { type: "ROUND_SETTLED", roomId, settlements: settlementDetails });
|
||||
}
|
||||
});
|
||||
|
||||
this.roomManager.emitter.on("player:left", ({ roomId, playerId }) => {
|
||||
this.publish(`room:${roomId}`, {
|
||||
type: "PLAYER_LEFT",
|
||||
roomId,
|
||||
playerId,
|
||||
});
|
||||
});
|
||||
|
||||
this.roomManager.emitter.on("room:deleted", ({ roomId }) => {
|
||||
const channel = `room:${roomId}`;
|
||||
for (const [, ws] of this.connections) {
|
||||
if (ws.data.rooms.has(roomId)) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: "Room not found" }));
|
||||
ws.unsubscribe(channel);
|
||||
ws.data.rooms.delete(roomId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.roomManager.emitter.on("room:list:changed", () => {
|
||||
this.publishRoomListUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
setServer(server: Server<WsConnectionData>): void {
|
||||
this.bunServer = server;
|
||||
}
|
||||
|
||||
handleOpen(ws: ServerWebSocket<WsConnectionData>): void {
|
||||
const discordId = ws.data.session.discordId;
|
||||
const existing = this.connections.get(discordId);
|
||||
if (existing && existing !== ws) {
|
||||
this.replacedConnections.set(discordId, existing);
|
||||
}
|
||||
this.connections.set(discordId, ws);
|
||||
ws.send(JSON.stringify({ type: "ROOM_LIST_UPDATE", rooms: this.roomManager.listRooms() }));
|
||||
}
|
||||
|
||||
async handleMessage(ws: ServerWebSocket<WsConnectionData>, raw: unknown): Promise<void> {
|
||||
const parsed = GameWsClientSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: "Invalid message format" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = parsed.data;
|
||||
const { discordId, username, role } = ws.data.session;
|
||||
|
||||
switch (msg.type) {
|
||||
case "CREATE_ROOM": {
|
||||
// Solo mode forces betAmount to 0
|
||||
const options = msg.options ? { ...msg.options } : {};
|
||||
if (options.soloMode) options.betAmount = 0;
|
||||
|
||||
const result = this.roomManager.createRoom(msg.gameType, discordId, options);
|
||||
if (!result.ok) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: result.error }));
|
||||
return;
|
||||
}
|
||||
ws.subscribe(`room:${result.roomId}`);
|
||||
ws.data.rooms.add(result.roomId);
|
||||
ws.send(JSON.stringify({ type: "ROOM_CREATED", roomId: result.roomId, gameSlug: msg.gameType }));
|
||||
logger.info("web", `Room created: ${result.roomId} (${msg.gameType}) by ${discordId}`);
|
||||
|
||||
// Solo mode: auto-fill and start immediately
|
||||
if (options.soloMode) {
|
||||
const fillResult = this.roomManager.fillRoom(result.roomId, discordId);
|
||||
if (!fillResult.ok) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: fillResult.error }));
|
||||
}
|
||||
// fillRoom with betAmount=0 calls startGame internally
|
||||
}
|
||||
|
||||
// Auto-start if room is immediately full (e.g. maxPlayers: 1) — skip for manualStart games
|
||||
const plugin = gameRegistry.get(msg.gameType);
|
||||
const createdRoom = this.roomManager.getRoom(result.roomId);
|
||||
if (!options.soloMode && plugin && !plugin.manualStart && createdRoom && createdRoom.players.length >= plugin.maxPlayers && createdRoom.status === "waiting") {
|
||||
if (createdRoom.betAmount > 0) {
|
||||
this.deductBetsAndStart(result.roomId, createdRoom.betAmount, createdRoom.players, ws);
|
||||
} else {
|
||||
this.roomManager.startGame(result.roomId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "JOIN_ROOM": {
|
||||
const result = this.roomManager.joinRoom(msg.roomId, discordId, msg.preferAs, msg.role ?? role);
|
||||
if (!result.ok) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: result.error }));
|
||||
return;
|
||||
}
|
||||
|
||||
ws.subscribe(`room:${msg.roomId}`);
|
||||
ws.data.rooms.add(msg.roomId);
|
||||
|
||||
const room = this.roomManager.getRoom(msg.roomId);
|
||||
const roomStatus = room?.status ?? "waiting";
|
||||
|
||||
// Determine the current state to send to this client
|
||||
let state: unknown = undefined;
|
||||
if (room && room.status === "playing") {
|
||||
state = result.joinedAs === "spectator"
|
||||
? this.roomManager.getSpectatorView(msg.roomId)
|
||||
: this.roomManager.getPlayerView(msg.roomId, discordId);
|
||||
}
|
||||
|
||||
// Build player/spectator lists for JOIN_RESULT
|
||||
const resolveInfo = (ids: string[]): PlayerInfo[] =>
|
||||
ids.map(id => ({
|
||||
discordId: id,
|
||||
username: this.connections.get(id)?.data.session.username ?? id,
|
||||
}));
|
||||
|
||||
const players = resolveInfo(room?.players ?? []);
|
||||
const spectators = resolveInfo(Array.from(room?.spectators ?? new Set()));
|
||||
|
||||
// Notify replaced connection in the same room (multi-tab detection)
|
||||
const replacedWs = this.replacedConnections.get(discordId);
|
||||
if (replacedWs && replacedWs.data.rooms.has(msg.roomId)) {
|
||||
replacedWs.send(JSON.stringify({ type: "SESSION_REPLACED", roomId: msg.roomId }));
|
||||
replacedWs.data.rooms.delete(msg.roomId);
|
||||
replacedWs.unsubscribe(`room:${msg.roomId}`);
|
||||
}
|
||||
this.replacedConnections.delete(discordId);
|
||||
|
||||
// Build room options for the client
|
||||
const roomOptions = room
|
||||
? {
|
||||
...(room.betAmount > 0 ? { betAmount: room.betAmount } : {}),
|
||||
...(typeof room.options?.timeControl === "string" ? { timeControl: room.options.timeControl } : {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Respond with JOIN_RESULT
|
||||
ws.send(JSON.stringify({
|
||||
type: "JOIN_RESULT",
|
||||
roomId: msg.roomId,
|
||||
joinedAs: result.joinedAs,
|
||||
roomStatus,
|
||||
players,
|
||||
spectators,
|
||||
state,
|
||||
roomOptions,
|
||||
}));
|
||||
|
||||
// Notify other room members
|
||||
const playerInfo: PlayerInfo = { discordId, username };
|
||||
this.publish(`room:${msg.roomId}`, {
|
||||
type: "PLAYER_JOINED",
|
||||
roomId: msg.roomId,
|
||||
player: playerInfo,
|
||||
joinedAs: result.joinedAs,
|
||||
});
|
||||
|
||||
logger.info("web", `${discordId} joined room ${msg.roomId} as ${result.joinedAs}`);
|
||||
|
||||
// Handle async bet deduction when room is ready to start
|
||||
if (result.readyToStart && room) {
|
||||
this.deductBetsAndStart(msg.roomId, room.betAmount, room.players, ws);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "LEAVE_ROOM": {
|
||||
this.roomManager.leaveRoom(msg.roomId, discordId);
|
||||
ws.unsubscribe(`room:${msg.roomId}`);
|
||||
ws.data.rooms.delete(msg.roomId);
|
||||
break;
|
||||
}
|
||||
|
||||
case "GAME_ACTION": {
|
||||
// Action cost pre-check: deduct bet before processing split/double/place_bet
|
||||
const actionRoom = this.roomManager.getRoom(msg.roomId);
|
||||
if (actionRoom && actionRoom.betAmount > 0 && actionRoom.state) {
|
||||
const actionPlugin = gameRegistry.get(actionRoom.gameSlug);
|
||||
if (actionPlugin?.getActionCost) {
|
||||
const cost = actionPlugin.getActionCost(actionRoom.state, msg.action, discordId);
|
||||
if (cost > 0) {
|
||||
const amount = actionRoom.betAmount * cost;
|
||||
const gameName = actionPlugin.name ?? actionRoom.gameSlug;
|
||||
try {
|
||||
await economyService.modifyUserBalance(
|
||||
discordId,
|
||||
-BigInt(amount),
|
||||
TransactionType.GAME_BET,
|
||||
`${gameName} action bet (room ${msg.roomId.slice(0, 8)})`,
|
||||
);
|
||||
} catch {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: "Insufficient funds for this action" }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = this.roomManager.handleAction(msg.roomId, discordId, msg.action);
|
||||
if (!result.ok) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: result.error }));
|
||||
return;
|
||||
}
|
||||
// game:updated event handler will dispatch views to players and spectators
|
||||
break;
|
||||
}
|
||||
|
||||
case "START_GAME": {
|
||||
const room = this.roomManager.getRoom(msg.roomId);
|
||||
if (!room) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: "Room not found" }));
|
||||
return;
|
||||
}
|
||||
if (room.host !== discordId) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: "Only the host can start the game" }));
|
||||
return;
|
||||
}
|
||||
if (room.status !== "waiting") {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: "Game is not in waiting state" }));
|
||||
return;
|
||||
}
|
||||
const startPlugin = gameRegistry.get(room.gameSlug);
|
||||
if (startPlugin && room.players.length < startPlugin.minPlayers) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: `Need at least ${startPlugin.minPlayers} player(s) to start` }));
|
||||
return;
|
||||
}
|
||||
if (room.betAmount > 0) {
|
||||
this.deductBetsAndStart(msg.roomId, room.betAmount, room.players, ws);
|
||||
} else {
|
||||
const startResult = this.roomManager.startGame(msg.roomId);
|
||||
if (!startResult.ok) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: startResult.error }));
|
||||
}
|
||||
}
|
||||
logger.info("web", `Host ${discordId} started game in room ${msg.roomId}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "FILL_ROOM": {
|
||||
if (role !== "admin") {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: "Only admins can fill a room for solo testing" }));
|
||||
return;
|
||||
}
|
||||
const fillResult = this.roomManager.fillRoom(msg.roomId, discordId);
|
||||
if (!fillResult.ok) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: fillResult.error }));
|
||||
return;
|
||||
}
|
||||
if (fillResult.readyToStart) {
|
||||
const room = this.roomManager.getRoom(msg.roomId);
|
||||
if (room) this.deductBetsAndStart(msg.roomId, room.betAmount, room.players, ws);
|
||||
}
|
||||
logger.info("web", `Admin ${discordId} filled room ${msg.roomId} for solo testing`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleClose(ws: ServerWebSocket<WsConnectionData>): void {
|
||||
// If this is a replaced (displaced) connection, just clean up the registry and stop
|
||||
for (const [id, prevWs] of this.replacedConnections) {
|
||||
if (prevWs === ws) {
|
||||
this.replacedConnections.delete(id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (const roomId of ws.data.rooms) {
|
||||
this.roomManager.leaveRoom(roomId, ws.data.session.discordId);
|
||||
}
|
||||
this.connections.delete(ws.data.session.discordId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduct bet amounts from all players, then start the game.
|
||||
* If any player can't afford the bet, refund already-deducted players
|
||||
* and remove the failing player from the room.
|
||||
*/
|
||||
private async deductBetsAndStart(
|
||||
roomId: string,
|
||||
betAmount: number,
|
||||
playerIds: string[],
|
||||
triggeringWs: ServerWebSocket<WsConnectionData>,
|
||||
): Promise<void> {
|
||||
const room = this.roomManager.getRoom(roomId);
|
||||
if (!room || room.betsPending) return;
|
||||
|
||||
// Games with getActionCost handle per-round betting themselves (e.g., blackjack).
|
||||
// Skip the upfront deduction — just start the game.
|
||||
const plugin = gameRegistry.get(room.gameSlug);
|
||||
if (plugin?.getActionCost) {
|
||||
const startResult = this.roomManager.startGame(roomId);
|
||||
if (!startResult.ok) {
|
||||
triggeringWs.send(JSON.stringify({ type: "ERROR", message: startResult.error }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
room.betsPending = true;
|
||||
|
||||
const uniquePlayers = [...new Set(playerIds)];
|
||||
const deducted: string[] = [];
|
||||
|
||||
try {
|
||||
const gameName = gameRegistry.get(room.gameSlug)?.name ?? room.gameSlug;
|
||||
for (const pid of uniquePlayers) {
|
||||
await economyService.modifyUserBalance(
|
||||
pid,
|
||||
-BigInt(betAmount),
|
||||
TransactionType.GAME_BET,
|
||||
`${gameName} wager (room ${roomId.slice(0, 8)})`,
|
||||
);
|
||||
deducted.push(pid);
|
||||
}
|
||||
|
||||
// All deductions succeeded — start the game
|
||||
const startResult = this.roomManager.startGame(roomId);
|
||||
if (!startResult.ok) {
|
||||
// Shouldn't happen, but refund if it does
|
||||
await this.refundPlayers(deducted, betAmount, roomId);
|
||||
}
|
||||
} catch (err) {
|
||||
// Refund anyone already deducted
|
||||
await this.refundPlayers(deducted, betAmount, roomId);
|
||||
|
||||
// Find the player who couldn't afford the bet
|
||||
const failedPlayer = uniquePlayers.find(p => !deducted.includes(p));
|
||||
if (failedPlayer) {
|
||||
this.roomManager.removePlayer(roomId, failedPlayer);
|
||||
this.sendToPlayer(failedPlayer, {
|
||||
type: "ERROR",
|
||||
message: "Insufficient funds for the bet. You have been removed from the room.",
|
||||
});
|
||||
this.publish(`room:${roomId}`, {
|
||||
type: "PLAYER_LEFT",
|
||||
roomId,
|
||||
playerId: failedPlayer,
|
||||
});
|
||||
}
|
||||
|
||||
logger.warn("web", `Bet deduction failed for room ${roomId}: ${err}`);
|
||||
} finally {
|
||||
if (room) room.betsPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Pay out winnings or refund bets on game end. */
|
||||
private async settleBets(
|
||||
roomId: string,
|
||||
winner: string | null,
|
||||
betAmount: number,
|
||||
payouts?: Record<string, number>,
|
||||
): Promise<{ amount: number; refunded?: boolean }> {
|
||||
const room = this.roomManager.getRoom(roomId);
|
||||
const uniquePlayers = [...new Set(room?.players ?? [])];
|
||||
const gameName = gameRegistry.get(room?.gameSlug ?? "")?.name ?? "Game";
|
||||
|
||||
try {
|
||||
// Custom payouts override default pot logic (used by house-edge games like blackjack)
|
||||
if (payouts) {
|
||||
let totalPaid = 0;
|
||||
for (const [playerId, multiplier] of Object.entries(payouts)) {
|
||||
if (multiplier <= 0) continue;
|
||||
const amount = Math.floor(betAmount * multiplier);
|
||||
await economyService.modifyUserBalance(
|
||||
playerId,
|
||||
BigInt(amount),
|
||||
TransactionType.GAME_WIN,
|
||||
`${gameName} payout (room ${roomId.slice(0, 8)})`,
|
||||
);
|
||||
totalPaid = Math.max(totalPaid, amount);
|
||||
}
|
||||
const isRefund = !winner && totalPaid === betAmount;
|
||||
return { amount: totalPaid, refunded: isRefund };
|
||||
}
|
||||
|
||||
// Default pot logic: winner takes all, draw refunds everyone
|
||||
const pot = betAmount * uniquePlayers.length;
|
||||
if (winner) {
|
||||
await economyService.modifyUserBalance(
|
||||
winner,
|
||||
BigInt(pot),
|
||||
TransactionType.GAME_WIN,
|
||||
`${gameName} wager won (room ${roomId.slice(0, 8)})`,
|
||||
);
|
||||
return { amount: pot };
|
||||
} else {
|
||||
await this.refundPlayers(uniquePlayers, betAmount, roomId, gameName);
|
||||
return { amount: betAmount, refunded: true };
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("web", `Bet settlement failed for room ${roomId}: ${err}`);
|
||||
return { amount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
private async refundPlayers(playerIds: string[], betAmount: number, roomId: string, gameName = "Game"): Promise<void> {
|
||||
for (const pid of playerIds) {
|
||||
try {
|
||||
await economyService.modifyUserBalance(
|
||||
pid,
|
||||
BigInt(betAmount),
|
||||
TransactionType.GAME_WIN,
|
||||
`${gameName} wager refund (room ${roomId.slice(0, 8)})`,
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error("web", `Failed to refund ${pid} for room ${roomId}: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private publish(channel: string, message: unknown): void {
|
||||
this.bunServer?.publish(channel, JSON.stringify(message));
|
||||
}
|
||||
|
||||
private sendToPlayer(discordId: string, message: unknown): void {
|
||||
this.connections.get(discordId)?.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
private publishRoomListUpdate(): void {
|
||||
this.publish("lobby", { type: "ROOM_LIST_UPDATE", rooms: this.roomManager.listRooms() });
|
||||
}
|
||||
}
|
||||
|
||||
export const gameServer = new GameServer();
|
||||
187
api/src/games/RoomManager.test.ts
Normal file
187
api/src/games/RoomManager.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect, beforeEach } from "bun:test";
|
||||
import { RoomManager } from "./RoomManager";
|
||||
import { gameRegistry } from "@shared/games/registry";
|
||||
import type { GamePlugin } from "@shared/games/types";
|
||||
|
||||
// Minimal stub plugin for testing the room system
|
||||
const stubPlugin: GamePlugin<{ turn: number }, { type: string }> = {
|
||||
slug: "stub",
|
||||
name: "Stub Game",
|
||||
minPlayers: 2,
|
||||
maxPlayers: 2,
|
||||
createInitialState: (players) => ({ turn: 0 }),
|
||||
handleAction: (state, action, playerId) => ({ ok: true, state: { ...state, turn: state.turn + 1 } }),
|
||||
getPlayerView: (state) => state,
|
||||
getSpectatorView: (state) => state,
|
||||
};
|
||||
|
||||
if (!gameRegistry.get("stub")) {
|
||||
gameRegistry.register(stubPlugin);
|
||||
}
|
||||
|
||||
describe("RoomManager", () => {
|
||||
let manager: RoomManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new RoomManager();
|
||||
});
|
||||
|
||||
describe("createRoom", () => {
|
||||
it("should create a room and return its id", () => {
|
||||
const result = manager.createRoom("stub", "player1");
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.roomId).toBeDefined();
|
||||
expect(typeof result.roomId).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject unknown game type", () => {
|
||||
const result = manager.createRoom("unknown-game", "player1");
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("should add creator as first player", () => {
|
||||
const result = manager.createRoom("stub", "player1");
|
||||
if (result.ok) {
|
||||
const room = manager.getRoom(result.roomId);
|
||||
expect(room?.players).toContain("player1");
|
||||
expect(room?.host).toBe("player1");
|
||||
expect(room?.status).toBe("waiting");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("joinRoom", () => {
|
||||
it("should add a player to a waiting room", () => {
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
const join = manager.joinRoom(create.roomId, "player2", "player");
|
||||
expect(join.ok).toBe(true);
|
||||
if (join.ok) {
|
||||
expect(join.joinedAs).toBe("player");
|
||||
}
|
||||
});
|
||||
|
||||
it("should auto-start when room reaches maxPlayers", () => {
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
manager.joinRoom(create.roomId, "player2", "player");
|
||||
const room = manager.getRoom(create.roomId);
|
||||
expect(room?.status).toBe("playing");
|
||||
expect(room?.state).toBeDefined();
|
||||
});
|
||||
|
||||
it("should allow joining as spectator when game is playing", () => {
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
manager.joinRoom(create.roomId, "player2", "player");
|
||||
const spec = manager.joinRoom(create.roomId, "spectator1", "spectator");
|
||||
expect(spec.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("should downgrade to spectator when joining full room as player", () => {
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
manager.joinRoom(create.roomId, "player2", "player");
|
||||
const result = manager.joinRoom(create.roomId, "player3", "player");
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.joinedAs).toBe("spectator");
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject joining nonexistent room", () => {
|
||||
const result = manager.joinRoom("fake-id", "player1", "player");
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAction", () => {
|
||||
it("should apply a valid game action", () => {
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
manager.joinRoom(create.roomId, "player2", "player");
|
||||
const result = manager.handleAction(create.roomId, "player1", { type: "action" });
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject action from spectator", () => {
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
manager.joinRoom(create.roomId, "player2", "player");
|
||||
manager.joinRoom(create.roomId, "spectator1", "spectator");
|
||||
const result = manager.handleAction(create.roomId, "spectator1", { type: "action" });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("leaveRoom", () => {
|
||||
it("should remove a player from the room", () => {
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
manager.leaveRoom(create.roomId, "player1");
|
||||
const room = manager.getRoom(create.roomId);
|
||||
expect(room).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should remove a spectator from the room", () => {
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
manager.joinRoom(create.roomId, "player2", "player");
|
||||
manager.joinRoom(create.roomId, "spec1", "spectator");
|
||||
manager.leaveRoom(create.roomId, "spec1");
|
||||
const room = manager.getRoom(create.roomId);
|
||||
expect(room?.spectators.has("spec1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listRooms", () => {
|
||||
it("should return summaries of all rooms", () => {
|
||||
manager.createRoom("stub", "player1");
|
||||
manager.createRoom("stub", "player2");
|
||||
const rooms = manager.listRooms();
|
||||
expect(rooms.length).toBe(2);
|
||||
expect(rooms[0].gameSlug).toBe("stub");
|
||||
expect(rooms[0].status).toBe("waiting");
|
||||
});
|
||||
|
||||
it("should filter by game type", () => {
|
||||
manager.createRoom("stub", "player1");
|
||||
const rooms = manager.listRooms("stub");
|
||||
expect(rooms.length).toBe(1);
|
||||
const empty = manager.listRooms("blackjack");
|
||||
expect(empty.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("waiting room cleanup", () => {
|
||||
it("should remove waiting rooms after the configured timeout", async () => {
|
||||
const shortLivedManager = new RoomManager({ WAITING_CLEANUP_MS: 20 });
|
||||
const create = shortLivedManager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 35));
|
||||
|
||||
expect(shortLivedManager.getRoom(create.roomId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should refresh the waiting room timeout when the room is active", async () => {
|
||||
const shortLivedManager = new RoomManager({ WAITING_CLEANUP_MS: 25 });
|
||||
const create = shortLivedManager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 15));
|
||||
|
||||
const spectatorJoin = shortLivedManager.joinRoom(create.roomId, "spectator1", "spectator");
|
||||
expect(spectatorJoin.ok).toBe(true);
|
||||
expect(shortLivedManager.getRoom(create.roomId)).toBeDefined();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 15));
|
||||
expect(shortLivedManager.getRoom(create.roomId)).toBeDefined();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
expect(shortLivedManager.getRoom(create.roomId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
327
api/src/games/RoomManager.ts
Normal file
327
api/src/games/RoomManager.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import mitt from "mitt";
|
||||
import { gameRegistry } from "@shared/games/registry";
|
||||
import type { Room, RoomSummary } from "./types";
|
||||
import type { RoundSettlement } from "@shared/games/types";
|
||||
|
||||
const DEFAULT_ROOM_CONFIG = {
|
||||
WAITING_CLEANUP_MS: 15 * 60_000,
|
||||
FINISHED_CLEANUP_MS: 60_000,
|
||||
PLAYING_MAX_MS: 30 * 60_000, // 30 minutes — safety net for stuck games
|
||||
} as const;
|
||||
|
||||
type RoomManagerConfig = typeof DEFAULT_ROOM_CONFIG;
|
||||
|
||||
type ActionResult =
|
||||
| { ok: true; state: unknown; gameOver: { winner: string | null; reason: string } | null; roundSettlements?: Record<string, RoundSettlement> }
|
||||
| { ok: false; error: string };
|
||||
|
||||
type CreateResult = { ok: true; roomId: string } | { ok: false; error: string };
|
||||
type JoinResult =
|
||||
| { ok: true; joinedAs: "player" | "spectator"; started: boolean; readyToStart?: boolean }
|
||||
| { ok: false; error: string };
|
||||
type FillResult = { ok: true; readyToStart?: boolean } | { ok: false; error: string };
|
||||
|
||||
type RoomEvents = {
|
||||
"room:created": { roomId: string; gameSlug: string; hostId: string };
|
||||
"player:joined": { roomId: string; playerId: string; username: string; joinedAs: "player" | "spectator" };
|
||||
"game:started": { roomId: string; spectatorView: unknown; playerViews: Map<string, unknown> };
|
||||
"game:updated": { roomId: string; spectatorView: unknown; playerViews: Map<string, unknown> };
|
||||
"game:ended": { roomId: string; winner: string | null; reason: string; payouts?: Record<string, number> };
|
||||
"round:settled": { roomId: string; roundSettlements: Record<string, RoundSettlement> };
|
||||
"player:left": { roomId: string; playerId: string };
|
||||
"room:deleted": { roomId: string };
|
||||
"room:list:changed": void;
|
||||
};
|
||||
|
||||
export class RoomManager {
|
||||
private rooms = new Map<string, Room>();
|
||||
private cleanupTimers = new Map<string, Timer>();
|
||||
private readonly config: RoomManagerConfig;
|
||||
readonly emitter = mitt<RoomEvents>();
|
||||
|
||||
constructor(config: Partial<RoomManagerConfig> = {}) {
|
||||
this.config = { ...DEFAULT_ROOM_CONFIG, ...config };
|
||||
}
|
||||
|
||||
createRoom(gameSlug: string, hostId: string, options?: Record<string, unknown>): CreateResult {
|
||||
const plugin = gameRegistry.get(gameSlug);
|
||||
if (!plugin) return { ok: false, error: `Unknown game type: ${gameSlug}` };
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const betAmount = typeof options?.betAmount === "number" && options.betAmount > 0 ? options.betAmount : 0;
|
||||
const room: Room = {
|
||||
id,
|
||||
gameSlug,
|
||||
host: hostId,
|
||||
players: [hostId],
|
||||
spectators: new Set(),
|
||||
state: null,
|
||||
status: "waiting",
|
||||
createdAt: Date.now(),
|
||||
options,
|
||||
betAmount,
|
||||
};
|
||||
|
||||
this.rooms.set(id, room);
|
||||
this.refreshWaitingCleanup(id, room);
|
||||
|
||||
this.emitter.emit("room:created", { roomId: id, gameSlug, hostId });
|
||||
this.emitter.emit("room:list:changed");
|
||||
|
||||
return { ok: true, roomId: id };
|
||||
}
|
||||
|
||||
joinRoom(roomId: string, playerId: string, preferAs: "player" | "spectator", role?: string): JoinResult {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return { ok: false, error: "Room not found" };
|
||||
|
||||
// Reconnecting player: must be checked before the in-progress spectator guard.
|
||||
if (preferAs !== "spectator" && room.players.includes(playerId)) {
|
||||
room.spectators.delete(playerId);
|
||||
this.refreshWaitingCleanup(roomId, room);
|
||||
return { ok: true, joinedAs: "player", started: room.status === "playing" };
|
||||
}
|
||||
|
||||
if (preferAs === "spectator" || room.status !== "waiting") {
|
||||
room.spectators.add(playerId);
|
||||
this.refreshWaitingCleanup(roomId, room);
|
||||
return { ok: true, joinedAs: "spectator", started: room.status === "playing" };
|
||||
}
|
||||
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
const isAdmin = role === "admin";
|
||||
|
||||
if (room.players.length >= plugin.maxPlayers && !isAdmin) {
|
||||
// Downgrade to spectator — room is full but still waiting, so game hasn't started
|
||||
room.spectators.add(playerId);
|
||||
return { ok: true, joinedAs: "spectator", started: false };
|
||||
}
|
||||
|
||||
room.players.push(playerId);
|
||||
|
||||
if (room.players.length >= plugin.maxPlayers && !plugin.manualStart) {
|
||||
// Defer start when bets are involved — GameServer handles async deduction first
|
||||
if (room.betAmount > 0) {
|
||||
this.refreshWaitingCleanup(roomId, room);
|
||||
this.emitter.emit("room:list:changed");
|
||||
return { ok: true, joinedAs: "player", started: false, readyToStart: true };
|
||||
}
|
||||
this.startGame(roomId);
|
||||
return { ok: true, joinedAs: "player", started: true };
|
||||
}
|
||||
|
||||
this.refreshWaitingCleanup(roomId, room);
|
||||
this.emitter.emit("room:list:changed");
|
||||
return { ok: true, joinedAs: "player", started: false };
|
||||
}
|
||||
|
||||
handleAction(roomId: string, playerId: string, action: unknown): ActionResult {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return { ok: false, error: "Room not found" };
|
||||
if (room.status !== "playing") return { ok: false, error: "Game is not in progress" };
|
||||
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
|
||||
// Spectator-to-player promotion for actions like "sit_down"
|
||||
if (!room.players.includes(playerId)) {
|
||||
if (room.spectators.has(playerId) && plugin.isSpectatorAction?.(action as any)) {
|
||||
room.spectators.delete(playerId);
|
||||
room.players.push(playerId);
|
||||
} else {
|
||||
return { ok: false, error: "You are not a player in this game" };
|
||||
}
|
||||
}
|
||||
|
||||
const result = plugin.handleAction(room.state, action, playerId);
|
||||
if (!result.ok) return result;
|
||||
|
||||
room.state = result.state;
|
||||
const gameOver = plugin.isGameOver?.(room.state) ?? null;
|
||||
if (gameOver) {
|
||||
room.status = "finished";
|
||||
this.scheduleCleanup(roomId, this.config.FINISHED_CLEANUP_MS);
|
||||
}
|
||||
|
||||
const spectatorView = plugin.getSpectatorView(room.state);
|
||||
const playerViews = new Map<string, unknown>();
|
||||
for (const pid of new Set(room.players)) {
|
||||
playerViews.set(pid, plugin.getPlayerView(room.state, pid));
|
||||
}
|
||||
this.emitter.emit("game:updated", { roomId, spectatorView, playerViews });
|
||||
|
||||
// Emit round payouts for mid-game settlement (continuous-play games)
|
||||
if (result.roundSettlements && !gameOver) {
|
||||
this.emitter.emit("round:settled", { roomId, roundSettlements: result.roundSettlements });
|
||||
}
|
||||
|
||||
if (gameOver) {
|
||||
this.emitter.emit("game:ended", { roomId, winner: gameOver.winner, reason: gameOver.reason, payouts: gameOver.payouts });
|
||||
this.emitter.emit("room:list:changed");
|
||||
}
|
||||
|
||||
return { ok: true, state: room.state, gameOver, roundSettlements: result.roundSettlements };
|
||||
}
|
||||
|
||||
leaveRoom(roomId: string, playerId: string): void {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return;
|
||||
|
||||
room.spectators.delete(playerId);
|
||||
|
||||
const playerIdx = room.players.indexOf(playerId);
|
||||
if (playerIdx !== -1) {
|
||||
room.players.splice(playerIdx, 1);
|
||||
|
||||
if (room.status === "playing") {
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
if (plugin.onPlayerDisconnect) {
|
||||
room.state = plugin.onPlayerDisconnect(room.state, playerId);
|
||||
const gameOver = plugin.isGameOver?.(room.state) ?? null;
|
||||
if (gameOver) {
|
||||
room.status = "finished";
|
||||
this.scheduleCleanup(roomId, this.config.FINISHED_CLEANUP_MS);
|
||||
this.emitter.emit("game:ended", { roomId, winner: gameOver.winner, reason: gameOver.reason, payouts: gameOver.payouts });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (room.players.length === 0 && room.status === "waiting") {
|
||||
this.deleteRoom(roomId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.refreshWaitingCleanup(roomId, room);
|
||||
|
||||
this.emitter.emit("player:left", { roomId, playerId });
|
||||
this.emitter.emit("room:list:changed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills empty seats with the admin's own ID for solo testing.
|
||||
* This means `createInitialState` will receive duplicate player IDs
|
||||
* (e.g. ["alice", "alice"]). Plugin authors should be aware that
|
||||
* solo-test mode produces non-unique player arrays.
|
||||
*/
|
||||
fillRoom(roomId: string, adminId: string): FillResult {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return { ok: false, error: "Room not found" };
|
||||
if (room.status !== "waiting") return { ok: false, error: "Game is not in waiting state" };
|
||||
if (!room.players.includes(adminId)) return { ok: false, error: "You are not in this room" };
|
||||
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
while (room.players.length < plugin.maxPlayers) {
|
||||
room.players.push(adminId);
|
||||
}
|
||||
|
||||
// Defer start when bets are involved
|
||||
if (room.betAmount > 0) {
|
||||
this.refreshWaitingCleanup(roomId, room);
|
||||
this.emitter.emit("room:list:changed");
|
||||
return { ok: true, readyToStart: true };
|
||||
}
|
||||
|
||||
this.startGame(roomId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Initialize game state and transition room to playing. */
|
||||
startGame(roomId: string): { ok: true } | { ok: false; error: string } {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return { ok: false, error: "Room not found" };
|
||||
if (room.status !== "waiting") return { ok: false, error: "Game is not in waiting state" };
|
||||
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
room.state = plugin.createInitialState(room.players, room.options);
|
||||
room.status = "playing";
|
||||
this.scheduleCleanup(roomId, this.config.PLAYING_MAX_MS);
|
||||
|
||||
const spectatorView = plugin.getSpectatorView(room.state);
|
||||
const playerViews = new Map<string, unknown>();
|
||||
for (const pid of new Set(room.players)) {
|
||||
playerViews.set(pid, plugin.getPlayerView(room.state, pid));
|
||||
}
|
||||
this.emitter.emit("game:started", { roomId, spectatorView, playerViews });
|
||||
this.emitter.emit("room:list:changed");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Remove a player from a waiting room (used when bet deduction fails). */
|
||||
removePlayer(roomId: string, playerId: string): void {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room || room.status !== "waiting") return;
|
||||
const idx = room.players.indexOf(playerId);
|
||||
if (idx !== -1) room.players.splice(idx, 1);
|
||||
if (room.players.length === 0) {
|
||||
this.deleteRoom(roomId);
|
||||
return;
|
||||
}
|
||||
this.refreshWaitingCleanup(roomId, room);
|
||||
this.emitter.emit("room:list:changed");
|
||||
}
|
||||
|
||||
getRoom(roomId: string): Room | undefined {
|
||||
return this.rooms.get(roomId);
|
||||
}
|
||||
|
||||
listRooms(gameSlug?: string): RoomSummary[] {
|
||||
const summaries: RoomSummary[] = [];
|
||||
for (const room of this.rooms.values()) {
|
||||
if (gameSlug && room.gameSlug !== gameSlug) continue;
|
||||
const plugin = gameRegistry.get(room.gameSlug);
|
||||
summaries.push({
|
||||
id: room.id,
|
||||
gameSlug: room.gameSlug,
|
||||
gameName: plugin?.name ?? room.gameSlug,
|
||||
host: room.host,
|
||||
playerCount: room.players.length,
|
||||
maxPlayers: plugin?.maxPlayers ?? 0,
|
||||
spectatorCount: room.spectators.size,
|
||||
status: room.status,
|
||||
betAmount: room.betAmount,
|
||||
});
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
getPlayerView(roomId: string, playerId: string): unknown {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room || !room.state) return null;
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
return plugin.getPlayerView(room.state, playerId);
|
||||
}
|
||||
|
||||
getSpectatorView(roomId: string): unknown {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room || !room.state) return null;
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
return plugin.getSpectatorView(room.state);
|
||||
}
|
||||
|
||||
private scheduleCleanup(roomId: string, ms: number): void {
|
||||
this.clearCleanup(roomId);
|
||||
const timer = setTimeout(() => this.deleteRoom(roomId), ms);
|
||||
this.cleanupTimers.set(roomId, timer);
|
||||
}
|
||||
|
||||
private refreshWaitingCleanup(roomId: string, room: Room): void {
|
||||
if (room.status !== "waiting") return;
|
||||
this.scheduleCleanup(roomId, this.config.WAITING_CLEANUP_MS);
|
||||
}
|
||||
|
||||
private clearCleanup(roomId: string): void {
|
||||
const existing = this.cleanupTimers.get(roomId);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
this.cleanupTimers.delete(roomId);
|
||||
}
|
||||
}
|
||||
|
||||
private deleteRoom(roomId: string): void {
|
||||
this.clearCleanup(roomId);
|
||||
if (this.rooms.delete(roomId)) {
|
||||
this.emitter.emit("room:deleted", { roomId });
|
||||
this.emitter.emit("room:list:changed");
|
||||
}
|
||||
}
|
||||
}
|
||||
65
api/src/games/types.ts
Normal file
65
api/src/games/types.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { RoundSettlement } from "@shared/games/types";
|
||||
import { z } from "zod";
|
||||
|
||||
export interface Room {
|
||||
id: string;
|
||||
gameSlug: string;
|
||||
host: string;
|
||||
players: string[];
|
||||
spectators: Set<string>;
|
||||
state: unknown;
|
||||
status: "waiting" | "playing" | "finished";
|
||||
createdAt: number;
|
||||
options?: Record<string, unknown>;
|
||||
betAmount: number;
|
||||
/** Guard against double bet-deduction when two joins race */
|
||||
betsPending?: boolean;
|
||||
}
|
||||
|
||||
export interface RoomSummary {
|
||||
id: string;
|
||||
gameSlug: string;
|
||||
gameName: string;
|
||||
host: string;
|
||||
playerCount: number;
|
||||
maxPlayers: number;
|
||||
spectatorCount: number;
|
||||
status: "waiting" | "playing" | "finished";
|
||||
betAmount: number;
|
||||
}
|
||||
|
||||
export interface PlayerInfo {
|
||||
discordId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export const GameWsClientSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("CREATE_ROOM"), gameType: z.string(), options: z.looseObject({}).optional() }),
|
||||
z.object({
|
||||
type: z.literal("JOIN_ROOM"),
|
||||
roomId: z.string(),
|
||||
preferAs: z.enum(["player", "spectator"]),
|
||||
role: z.enum(["player", "admin"]).optional(),
|
||||
}),
|
||||
z.object({ type: z.literal("LEAVE_ROOM"), roomId: z.string() }),
|
||||
// Use looseObject for GAME_ACTION to avoid Zod bug with record()
|
||||
z.object({ type: z.literal("GAME_ACTION"), roomId: z.string(), action: z.looseObject({}, { message: "Invalid action" }) }),
|
||||
z.object({ type: z.literal("FILL_ROOM"), roomId: z.string() }),
|
||||
z.object({ type: z.literal("START_GAME"), roomId: z.string() }),
|
||||
]);
|
||||
|
||||
export type GameWsClientMessage = z.infer<typeof GameWsClientSchema>;
|
||||
|
||||
export type GameWsServerMessage =
|
||||
| { type: "ROOM_LIST_UPDATE"; rooms: RoomSummary[] }
|
||||
| { type: "GAME_STATE"; roomId: string; state: unknown }
|
||||
| { type: "GAME_UPDATE"; roomId: string; state: unknown }
|
||||
| { type: "PLAYER_JOINED"; roomId: string; player: PlayerInfo; joinedAs: "player" | "spectator" }
|
||||
| { type: "PLAYER_LEFT"; roomId: string; playerId: string }
|
||||
| { type: "GAME_STARTED"; roomId: string; state: unknown }
|
||||
| { type: "GAME_ENDED"; roomId: string; winner: string | null; reason: string; payout?: { amount: number; refunded?: boolean } }
|
||||
| { type: "ROOM_CREATED"; roomId: string; gameSlug: string }
|
||||
| { type: "JOIN_RESULT"; roomId: string; joinedAs: "player" | "spectator"; roomStatus: "waiting" | "playing" | "finished"; players: PlayerInfo[]; spectators: PlayerInfo[]; state?: unknown; roomOptions?: { betAmount?: number; timeControl?: string } }
|
||||
| { type: "ROUND_SETTLED"; roomId: string; settlements: Record<string, RoundSettlement> }
|
||||
| { type: "SESSION_REPLACED"; roomId: string }
|
||||
| { type: "ERROR"; message: string };
|
||||
106
api/src/routes/actions.routes.ts
Normal file
106
api/src/routes/actions.routes.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @fileoverview Administrative action endpoints for Aurora API.
|
||||
* Provides endpoints for system administration tasks like cache clearing
|
||||
* and maintenance mode toggling.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import { jsonResponse, errorResponse, parseBody, withErrorHandling } from "./utils";
|
||||
import { MaintenanceModeSchema } from "./schemas";
|
||||
|
||||
/**
|
||||
* Admin actions routes handler.
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /api/actions/reload-commands - Reload bot slash commands
|
||||
* - POST /api/actions/clear-cache - Clear internal caches
|
||||
* - POST /api/actions/maintenance-mode - Toggle maintenance mode
|
||||
*/
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method, req } = ctx;
|
||||
|
||||
// Only handle POST requests to /api/actions/*
|
||||
if (!pathname.startsWith("/api/actions/") || method !== "POST") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { actionService } = await import("@shared/modules/admin/action.service");
|
||||
|
||||
/**
|
||||
* @route POST /api/actions/reload-commands
|
||||
* @description Triggers a reload of all Discord slash commands.
|
||||
* Useful after modifying command configurations.
|
||||
* @response 200 - `{ success: true, message: string }`
|
||||
* @response 500 - Error reloading commands
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* POST /api/actions/reload-commands
|
||||
*
|
||||
* // Response
|
||||
* { "success": true, "message": "Commands reloaded" }
|
||||
*/
|
||||
if (pathname === "/api/actions/reload-commands") {
|
||||
return withErrorHandling(async () => {
|
||||
const result = await actionService.reloadCommands();
|
||||
return jsonResponse(result);
|
||||
}, "reload commands");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route POST /api/actions/clear-cache
|
||||
* @description Clears all internal application caches.
|
||||
* Useful for forcing fresh data fetches.
|
||||
* @response 200 - `{ success: true, message: string }`
|
||||
* @response 500 - Error clearing cache
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* POST /api/actions/clear-cache
|
||||
*
|
||||
* // Response
|
||||
* { "success": true, "message": "Cache cleared" }
|
||||
*/
|
||||
if (pathname === "/api/actions/clear-cache") {
|
||||
return withErrorHandling(async () => {
|
||||
const result = await actionService.clearCache();
|
||||
return jsonResponse(result);
|
||||
}, "clear cache");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route POST /api/actions/maintenance-mode
|
||||
* @description Toggles bot maintenance mode on or off.
|
||||
* When enabled, the bot will respond with a maintenance message.
|
||||
*
|
||||
* @body { enabled: boolean, reason?: string }
|
||||
* @response 200 - `{ success: true, enabled: boolean }`
|
||||
* @response 400 - Invalid payload with validation errors
|
||||
* @response 500 - Error toggling maintenance mode
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* POST /api/actions/maintenance-mode
|
||||
* Content-Type: application/json
|
||||
* { "enabled": true, "reason": "Deploying updates..." }
|
||||
*
|
||||
* // Response
|
||||
* { "success": true, "enabled": true }
|
||||
*/
|
||||
if (pathname === "/api/actions/maintenance-mode") {
|
||||
return withErrorHandling(async () => {
|
||||
const data = await parseBody(req, MaintenanceModeSchema);
|
||||
if (data instanceof Response) return data;
|
||||
|
||||
const result = await actionService.toggleMaintenanceMode(data.enabled, data.reason);
|
||||
return jsonResponse(result);
|
||||
}, "toggle maintenance mode");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const actionsRoutes: RouteModule = {
|
||||
name: "actions",
|
||||
handler
|
||||
};
|
||||
83
api/src/routes/assets.routes.ts
Normal file
83
api/src/routes/assets.routes.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @fileoverview Static asset serving for Aurora API.
|
||||
* Serves item images and other assets from the local filesystem.
|
||||
*/
|
||||
|
||||
import { join, resolve, dirname } from "path";
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
|
||||
// Resolve assets root directory
|
||||
const currentDir = dirname(new URL(import.meta.url).pathname);
|
||||
const assetsRoot = resolve(currentDir, "../../../bot/assets/graphics");
|
||||
|
||||
/** MIME types for supported image formats */
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
"png": "image/png",
|
||||
"jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
"webp": "image/webp",
|
||||
"gif": "image/gif",
|
||||
};
|
||||
|
||||
/**
|
||||
* Assets routes handler.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /assets/* - Serve static files from the assets directory
|
||||
*/
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method } = ctx;
|
||||
|
||||
/**
|
||||
* @route GET /assets/*
|
||||
* @description Serves static asset files (images) with caching headers.
|
||||
* Assets are served from the bot's graphics directory.
|
||||
*
|
||||
* Path security: Path traversal attacks are prevented by validating
|
||||
* that the resolved path stays within the assets root.
|
||||
*
|
||||
* @response 200 - File content with appropriate MIME type
|
||||
* @response 403 - Forbidden (path traversal attempt)
|
||||
* @response 404 - File not found
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* GET /assets/items/1.png
|
||||
*
|
||||
* // Response Headers
|
||||
* Content-Type: image/png
|
||||
* Cache-Control: public, max-age=86400
|
||||
*/
|
||||
if (pathname.startsWith("/assets/") && method === "GET") {
|
||||
const assetPath = pathname.replace("/assets/", "");
|
||||
|
||||
// Security: prevent path traversal attacks
|
||||
const safePath = join(assetsRoot, assetPath);
|
||||
if (!safePath.startsWith(assetsRoot)) {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const file = Bun.file(safePath);
|
||||
if (await file.exists()) {
|
||||
// Determine MIME type based on extension
|
||||
const ext = safePath.split(".").pop()?.toLowerCase();
|
||||
const contentType = MIME_TYPES[ext || ""] || "application/octet-stream";
|
||||
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "no-cache",
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const assetsRoutes: RouteModule = {
|
||||
name: "assets",
|
||||
handler
|
||||
};
|
||||
103
api/src/routes/auth.routes.test.ts
Normal file
103
api/src/routes/auth.routes.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
|
||||
|
||||
const findFirst = mock(async () => ({ id: 123n }));
|
||||
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: {
|
||||
query: {
|
||||
users: {
|
||||
findFirst,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("@shared/lib/logger", () => ({
|
||||
logger: {
|
||||
error: () => { },
|
||||
info: () => { },
|
||||
warn: () => { },
|
||||
debug: () => { },
|
||||
},
|
||||
}));
|
||||
|
||||
import { authRoutes, getSession } from "./auth.routes";
|
||||
|
||||
describe("Auth Routes", () => {
|
||||
let fetchSpy: ReturnType<typeof spyOn> | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.DISCORD_CLIENT_ID = "client-id";
|
||||
process.env.DISCORD_CLIENT_SECRET = "client-secret";
|
||||
process.env.SESSION_SECRET = "session-secret";
|
||||
process.env.PANEL_BASE_URL = "http://localhost:3000";
|
||||
process.env.ADMIN_USER_IDS = "123";
|
||||
findFirst.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy?.mockRestore();
|
||||
fetchSpy = null;
|
||||
});
|
||||
|
||||
it("creates a signed session cookie during OAuth callback", async () => {
|
||||
const loginUrl = new URL("http://localhost/auth/discord?return_to=http://localhost:5173/admin");
|
||||
const loginRes = await authRoutes.handler({
|
||||
req: new Request(loginUrl, { method: "GET" }),
|
||||
url: loginUrl,
|
||||
method: "GET",
|
||||
pathname: "/auth/discord",
|
||||
});
|
||||
|
||||
expect(loginRes?.status).toBe(302);
|
||||
|
||||
const redirectLocation = loginRes?.headers.get("Location");
|
||||
expect(redirectLocation).not.toBeNull();
|
||||
|
||||
const state = new URL(redirectLocation!).searchParams.get("state");
|
||||
expect(state).not.toBeNull();
|
||||
|
||||
fetchSpy = spyOn(globalThis, "fetch");
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ access_token: "discord-token" }), { status: 200 }));
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
id: "123",
|
||||
username: "aurora-admin",
|
||||
avatar: null,
|
||||
}), { status: 200 }));
|
||||
|
||||
const callbackUrl = new URL(`http://localhost/auth/callback?code=oauth-code&state=${encodeURIComponent(state!)}`);
|
||||
const callbackRes = await authRoutes.handler({
|
||||
req: new Request(callbackUrl, { method: "GET" }),
|
||||
url: callbackUrl,
|
||||
method: "GET",
|
||||
pathname: "/auth/callback",
|
||||
});
|
||||
|
||||
expect(callbackRes?.status).toBe(302);
|
||||
expect(callbackRes?.headers.get("Location")).toBe("/admin");
|
||||
|
||||
const setCookie = callbackRes?.headers.get("Set-Cookie");
|
||||
expect(setCookie).toContain("aurora_session=");
|
||||
|
||||
const sessionCookie = setCookie!.split(";")[0]!;
|
||||
const session = getSession(new Request("http://localhost/api/me", {
|
||||
headers: { cookie: sessionCookie },
|
||||
}));
|
||||
|
||||
expect(session).toEqual({
|
||||
discordId: "123",
|
||||
username: "aurora-admin",
|
||||
avatar: null,
|
||||
role: "admin",
|
||||
expiresAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects tampered session cookies", () => {
|
||||
const session = getSession(new Request("http://localhost/api/me", {
|
||||
headers: { cookie: "aurora_session=not-a-valid-token" },
|
||||
}));
|
||||
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
});
|
||||
349
api/src/routes/auth.routes.ts
Normal file
349
api/src/routes/auth.routes.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* @fileoverview Discord OAuth2 authentication routes for the admin panel.
|
||||
* Handles login flow, callback, logout, and session management.
|
||||
*/
|
||||
|
||||
import { Buffer } from "node:buffer";
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import { jsonResponse, errorResponse } from "./utils";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { users } from "@shared/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
// Signed session payload stored in the aurora_session cookie.
|
||||
export interface Session {
|
||||
discordId: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
role: "admin" | "player";
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface SessionTokenPayload extends Session {
|
||||
v: 1;
|
||||
}
|
||||
|
||||
interface OAuthStatePayload {
|
||||
exp: number;
|
||||
returnTo: string;
|
||||
v: 1;
|
||||
}
|
||||
|
||||
const COOKIE_NAME = "aurora_session";
|
||||
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
const OAUTH_STATE_MAX_AGE = 10 * 60 * 1000; // 10 minutes
|
||||
const TOKEN_NAMESPACE = "aurora.auth";
|
||||
const TOKEN_VERSION = "v1";
|
||||
|
||||
function getEnv(key: string): string {
|
||||
const val = process.env[key];
|
||||
if (!val) throw new Error(`Missing env: ${key}`);
|
||||
return val;
|
||||
}
|
||||
|
||||
function getSessionSecret(required: boolean = false): string | null {
|
||||
const secret = process.env.SESSION_SECRET ?? process.env.DISCORD_CLIENT_SECRET ?? null;
|
||||
if (!secret && required) {
|
||||
throw new Error("Missing env: SESSION_SECRET or DISCORD_CLIENT_SECRET");
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
function requireSessionSecret(): string {
|
||||
return getSessionSecret(true)!;
|
||||
}
|
||||
|
||||
function getAdminIds(): string[] {
|
||||
const raw = process.env.ADMIN_USER_IDS ?? "";
|
||||
return raw.split(",").map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function encodeBase64Url(value: string): string {
|
||||
return Buffer.from(value, "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
function decodeBase64Url(value: string): string {
|
||||
return Buffer.from(value, "base64url").toString("utf8");
|
||||
}
|
||||
|
||||
function signValue(kind: string, encodedPayload: string, secret: string): string {
|
||||
return createHmac("sha256", secret)
|
||||
.update(`${TOKEN_NAMESPACE}.${kind}.${encodedPayload}`)
|
||||
.digest("base64url");
|
||||
}
|
||||
|
||||
function serializeSignedToken(kind: string, payload: SessionTokenPayload | OAuthStatePayload, secret: string): string {
|
||||
const encodedPayload = encodeBase64Url(JSON.stringify(payload));
|
||||
const signature = signValue(kind, encodedPayload, secret);
|
||||
return `${TOKEN_VERSION}.${encodedPayload}.${signature}`;
|
||||
}
|
||||
|
||||
function parseSignedToken<T>(token: string | undefined, kind: string): T | null {
|
||||
if (!token) return null;
|
||||
|
||||
const secret = getSessionSecret();
|
||||
if (!secret) return null;
|
||||
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const version = parts[0];
|
||||
const encodedPayload = parts[1];
|
||||
const providedSignature = parts[2];
|
||||
if (version !== TOKEN_VERSION) return null;
|
||||
if (!encodedPayload || !providedSignature) return null;
|
||||
|
||||
const expectedSignature = signValue(kind, encodedPayload, secret);
|
||||
const providedBuffer = Buffer.from(providedSignature);
|
||||
const expectedBuffer = Buffer.from(expectedSignature);
|
||||
|
||||
if (providedBuffer.length !== expectedBuffer.length) return null;
|
||||
if (!timingSafeEqual(providedBuffer, expectedBuffer)) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(decodeBase64Url(encodedPayload)) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getBaseUrl(): string {
|
||||
return process.env.PANEL_BASE_URL ?? `http://localhost:3000`;
|
||||
}
|
||||
|
||||
function parseCookies(header: string | null): Record<string, string> {
|
||||
if (!header) return {};
|
||||
const cookies: Record<string, string> = {};
|
||||
for (const pair of header.split(";")) {
|
||||
const [key, ...rest] = pair.trim().split("=");
|
||||
if (key) cookies[key] = rest.join("=");
|
||||
}
|
||||
return cookies;
|
||||
}
|
||||
|
||||
function sanitizeReturnTo(rawReturnTo: string | null, baseUrl: string): string {
|
||||
if (!rawReturnTo || rawReturnTo.length > 1024) return "/";
|
||||
|
||||
try {
|
||||
if (rawReturnTo.startsWith("/") && !rawReturnTo.startsWith("//")) {
|
||||
return rawReturnTo;
|
||||
}
|
||||
|
||||
const parsed = new URL(rawReturnTo, baseUrl);
|
||||
const allowedBase = new URL(baseUrl);
|
||||
const isLocalhostRedirect = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
||||
|
||||
if (parsed.origin === allowedBase.origin || isLocalhostRedirect) {
|
||||
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
}
|
||||
} catch {
|
||||
return "/";
|
||||
}
|
||||
|
||||
return "/";
|
||||
}
|
||||
|
||||
function buildCookieAttributes(maxAgeSeconds?: number): string {
|
||||
const attrs = [
|
||||
"Path=/",
|
||||
"HttpOnly",
|
||||
"SameSite=Lax",
|
||||
];
|
||||
|
||||
try {
|
||||
if (new URL(getBaseUrl()).protocol === "https:") {
|
||||
attrs.push("Secure");
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid PANEL_BASE_URL here; handlers that need it will fail explicitly.
|
||||
}
|
||||
|
||||
if (typeof maxAgeSeconds === "number") {
|
||||
attrs.push(`Max-Age=${maxAgeSeconds}`);
|
||||
}
|
||||
|
||||
return attrs.join("; ");
|
||||
}
|
||||
|
||||
/** Get session from request cookie */
|
||||
export function getSession(req: Request): Session | null {
|
||||
const cookies = parseCookies(req.headers.get("cookie"));
|
||||
const payload = parseSignedToken<SessionTokenPayload>(cookies[COOKIE_NAME], "session");
|
||||
|
||||
if (!payload || payload.v !== 1) return null;
|
||||
if (Date.now() > payload.expiresAt) return null;
|
||||
|
||||
return {
|
||||
discordId: payload.discordId,
|
||||
username: payload.username,
|
||||
avatar: payload.avatar,
|
||||
role: payload.role,
|
||||
expiresAt: payload.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Check if request is authenticated as admin */
|
||||
export function isAuthenticated(req: Request): boolean {
|
||||
return getSession(req) !== null;
|
||||
}
|
||||
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method } = ctx;
|
||||
|
||||
// GET /auth/discord — redirect to Discord OAuth
|
||||
if (pathname === "/auth/discord" && method === "GET") {
|
||||
try {
|
||||
const clientId = getEnv("DISCORD_CLIENT_ID");
|
||||
const baseUrl = getBaseUrl();
|
||||
const redirectUri = encodeURIComponent(`${baseUrl}/auth/callback`);
|
||||
const scope = "identify+email";
|
||||
const secret = requireSessionSecret();
|
||||
|
||||
// Store return_to URL in signed OAuth state
|
||||
const returnTo = sanitizeReturnTo(ctx.url.searchParams.get("return_to"), baseUrl);
|
||||
const state = serializeSignedToken("oauth", {
|
||||
exp: Date.now() + OAUTH_STATE_MAX_AGE,
|
||||
returnTo,
|
||||
v: 1,
|
||||
}, secret);
|
||||
|
||||
const url = `https://discord.com/oauth2/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&scope=${scope}&state=${encodeURIComponent(state)}`;
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: url,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("auth", "Failed to initiate OAuth", e);
|
||||
return errorResponse("OAuth not configured", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /auth/callback — handle Discord OAuth callback
|
||||
if (pathname === "/auth/callback" && method === "GET") {
|
||||
const code = ctx.url.searchParams.get("code");
|
||||
if (!code) return errorResponse("Missing code parameter", 400);
|
||||
|
||||
try {
|
||||
const clientId = getEnv("DISCORD_CLIENT_ID");
|
||||
const clientSecret = getEnv("DISCORD_CLIENT_SECRET");
|
||||
const baseUrl = getBaseUrl();
|
||||
const redirectUri = `${baseUrl}/auth/callback`;
|
||||
const secret = requireSessionSecret();
|
||||
const statePayload = parseSignedToken<OAuthStatePayload>(ctx.url.searchParams.get("state") ?? undefined, "oauth");
|
||||
|
||||
if (!statePayload || statePayload.v !== 1 || Date.now() > statePayload.exp) {
|
||||
return errorResponse("Invalid OAuth state", 400);
|
||||
}
|
||||
|
||||
const tokenRes = await fetch("https://discord.com/api/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
logger.error("auth", `Token exchange failed: ${tokenRes.status}`);
|
||||
return errorResponse("OAuth token exchange failed", 401);
|
||||
}
|
||||
|
||||
const tokenData = await tokenRes.json() as { access_token: string };
|
||||
|
||||
// Fetch user info
|
||||
const userRes = await fetch("https://discord.com/api/users/@me", {
|
||||
headers: { Authorization: `Bearer ${tokenData.access_token}` },
|
||||
});
|
||||
|
||||
if (!userRes.ok) {
|
||||
return errorResponse("Failed to fetch Discord user", 401);
|
||||
}
|
||||
|
||||
const user = await userRes.json() as { id: string; username: string; avatar: string | null };
|
||||
|
||||
// Check enrollment — user must exist in the users table
|
||||
const dbUser = await DrizzleClient.query.users.findFirst({
|
||||
where: eq(users.id, BigInt(user.id)),
|
||||
});
|
||||
|
||||
if (!dbUser) {
|
||||
logger.info("auth", `Non-enrolled login attempt by ${user.username} (${user.id})`);
|
||||
return new Response(
|
||||
`<html><body><h1>Not Enrolled</h1><p>You need to use the Aurora bot in Discord before you can access this panel.</p><a href="/">Go back</a></body></html>`,
|
||||
{ status: 403, headers: { "Content-Type": "text/html" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Determine role
|
||||
const adminIds = getAdminIds();
|
||||
const role: "admin" | "player" = adminIds.includes(user.id) ? "admin" : "player";
|
||||
|
||||
// Create signed session cookie
|
||||
const sessionToken = serializeSignedToken("session", {
|
||||
discordId: user.id,
|
||||
username: user.username,
|
||||
avatar: user.avatar,
|
||||
role,
|
||||
expiresAt: Date.now() + SESSION_MAX_AGE,
|
||||
v: 1,
|
||||
}, secret);
|
||||
|
||||
logger.info("auth", `Login: ${user.username} (${user.id}) as ${role}`);
|
||||
|
||||
// Redirect to panel with session cookie
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: sanitizeReturnTo(statePayload.returnTo, baseUrl),
|
||||
"Set-Cookie": `${COOKIE_NAME}=${sessionToken}; ${buildCookieAttributes(SESSION_MAX_AGE / 1000)}`,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("auth", "OAuth callback error", e);
|
||||
return errorResponse("Authentication failed", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /auth/logout — clear session
|
||||
if (pathname === "/auth/logout" && method === "POST") {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Set-Cookie": `${COOKIE_NAME}=; ${buildCookieAttributes(0)}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// GET /auth/me — return current session info
|
||||
if (pathname === "/auth/me" && method === "GET") {
|
||||
const session = getSession(ctx.req);
|
||||
if (!session) return jsonResponse({ authenticated: false, enrolled: true });
|
||||
return jsonResponse({
|
||||
authenticated: true,
|
||||
enrolled: true,
|
||||
user: {
|
||||
discordId: session.discordId,
|
||||
username: session.username,
|
||||
avatar: session.avatar,
|
||||
role: session.role,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const authRoutes: RouteModule = {
|
||||
name: "auth",
|
||||
handler,
|
||||
};
|
||||
155
api/src/routes/classes.routes.ts
Normal file
155
api/src/routes/classes.routes.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @fileoverview Class management endpoints for Aurora API.
|
||||
* Provides CRUD operations for player classes/guilds.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import {
|
||||
jsonResponse,
|
||||
errorResponse,
|
||||
parseBody,
|
||||
parseStringIdFromPath,
|
||||
withErrorHandling
|
||||
} from "./utils";
|
||||
import { CreateClassSchema, UpdateClassSchema } from "./schemas";
|
||||
|
||||
/**
|
||||
* Classes routes handler.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/classes - List all classes
|
||||
* - POST /api/classes - Create a new class
|
||||
* - PUT /api/classes/:id - Update a class
|
||||
* - DELETE /api/classes/:id - Delete a class
|
||||
*/
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method, req } = ctx;
|
||||
|
||||
// Only handle requests to /api/classes*
|
||||
if (!pathname.startsWith("/api/classes")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { classService } = await import("@shared/modules/class/class.service");
|
||||
|
||||
/**
|
||||
* @route GET /api/classes
|
||||
* @description Returns all classes/guilds in the system.
|
||||
*
|
||||
* @response 200 - `{ classes: Class[] }`
|
||||
* @response 500 - Error fetching classes
|
||||
*
|
||||
* @example
|
||||
* // Response
|
||||
* {
|
||||
* "classes": [
|
||||
* { "id": "1", "name": "Warrior", "balance": "5000", "roleId": "123456789" }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
if (pathname === "/api/classes" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const classes = await classService.getAllClasses();
|
||||
return jsonResponse({ classes });
|
||||
}, "fetch classes");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route POST /api/classes
|
||||
* @description Creates a new class/guild.
|
||||
*
|
||||
* @body {
|
||||
* id: string | number (required) - Unique class identifier,
|
||||
* name: string (required) - Class display name,
|
||||
* balance?: string | number - Initial class balance (default: 0),
|
||||
* roleId?: string - Associated Discord role ID
|
||||
* }
|
||||
* @response 201 - `{ success: true, class: Class }`
|
||||
* @response 400 - Missing required fields
|
||||
* @response 500 - Error creating class
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* POST /api/classes
|
||||
* { "id": "2", "name": "Mage", "balance": "0", "roleId": "987654321" }
|
||||
*/
|
||||
if (pathname === "/api/classes" && method === "POST") {
|
||||
return withErrorHandling(async () => {
|
||||
const data = await req.json() as Record<string, any>;
|
||||
|
||||
if (!data.id || !data.name || typeof data.name !== 'string') {
|
||||
return errorResponse("Missing required fields: id and name are required", 400);
|
||||
}
|
||||
|
||||
const newClass = await classService.createClass({
|
||||
id: BigInt(data.id),
|
||||
name: data.name,
|
||||
balance: data.balance ? BigInt(data.balance) : 0n,
|
||||
roleId: data.roleId || null,
|
||||
});
|
||||
|
||||
return jsonResponse({ success: true, class: newClass }, 201);
|
||||
}, "create class");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route PUT /api/classes/:id
|
||||
* @description Updates an existing class.
|
||||
*
|
||||
* @param id - Class ID
|
||||
* @body {
|
||||
* name?: string - Updated class name,
|
||||
* balance?: string | number - Updated balance,
|
||||
* roleId?: string - Updated Discord role ID
|
||||
* }
|
||||
* @response 200 - `{ success: true, class: Class }`
|
||||
* @response 404 - Class not found
|
||||
* @response 500 - Error updating class
|
||||
*/
|
||||
if (pathname.match(/^\/api\/classes\/\d+$/) && method === "PUT") {
|
||||
const id = parseStringIdFromPath(pathname);
|
||||
if (!id) return null;
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const data = await req.json() as Record<string, any>;
|
||||
|
||||
const updateData: any = {};
|
||||
if (data.name !== undefined) updateData.name = data.name;
|
||||
if (data.balance !== undefined) updateData.balance = BigInt(data.balance);
|
||||
if (data.roleId !== undefined) updateData.roleId = data.roleId;
|
||||
|
||||
const updatedClass = await classService.updateClass(BigInt(id), updateData);
|
||||
|
||||
if (!updatedClass) {
|
||||
return errorResponse("Class not found", 404);
|
||||
}
|
||||
|
||||
return jsonResponse({ success: true, class: updatedClass });
|
||||
}, "update class");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route DELETE /api/classes/:id
|
||||
* @description Deletes a class. Users assigned to this class will need to be reassigned.
|
||||
*
|
||||
* @param id - Class ID
|
||||
* @response 204 - Class deleted (no content)
|
||||
* @response 500 - Error deleting class
|
||||
*/
|
||||
if (pathname.match(/^\/api\/classes\/\d+$/) && method === "DELETE") {
|
||||
const id = parseStringIdFromPath(pathname);
|
||||
if (!id) return null;
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
await classService.deleteClass(BigInt(id));
|
||||
return new Response(null, { status: 204 });
|
||||
}, "delete class");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const classesRoutes: RouteModule = {
|
||||
name: "classes",
|
||||
handler
|
||||
};
|
||||
64
api/src/routes/guild-settings.routes.ts
Normal file
64
api/src/routes/guild-settings.routes.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @fileoverview Guild settings endpoints for Aurora API.
|
||||
* Provides endpoints for reading and updating per-guild configuration
|
||||
* stored in the database.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
|
||||
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
|
||||
import { invalidateGuildConfigCache } from "@shared/lib/config";
|
||||
|
||||
const GUILD_SETTINGS_PATTERN = /^\/api\/guilds\/(\d+)\/settings$/;
|
||||
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method, req } = ctx;
|
||||
|
||||
const match = pathname.match(GUILD_SETTINGS_PATTERN);
|
||||
if (!match || !match[1]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const guildId = match[1];
|
||||
|
||||
if (method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const settings = await guildSettingsService.getSettings(guildId);
|
||||
if (!settings) {
|
||||
return jsonResponse({ guildId, configured: false });
|
||||
}
|
||||
return jsonResponse({ ...settings, guildId, configured: true });
|
||||
}, "fetch guild settings");
|
||||
}
|
||||
|
||||
if (method === "PUT" || method === "PATCH") {
|
||||
try {
|
||||
const body = await req.json() as Record<string, unknown>;
|
||||
const { guildId: _, ...settings } = body;
|
||||
const result = await guildSettingsService.upsertSettings({
|
||||
guildId,
|
||||
...settings,
|
||||
} as Parameters<typeof guildSettingsService.upsertSettings>[0]);
|
||||
invalidateGuildConfigCache(guildId);
|
||||
return jsonResponse(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return errorResponse("Failed to save guild settings", 400, message);
|
||||
}
|
||||
}
|
||||
|
||||
if (method === "DELETE") {
|
||||
return withErrorHandling(async () => {
|
||||
await guildSettingsService.deleteSettings(guildId);
|
||||
invalidateGuildConfigCache(guildId);
|
||||
return jsonResponse({ success: true });
|
||||
}, "delete guild settings");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const guildSettingsRoutes: RouteModule = {
|
||||
name: "guild-settings",
|
||||
handler
|
||||
};
|
||||
36
api/src/routes/health.routes.ts
Normal file
36
api/src/routes/health.routes.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @fileoverview Health check endpoint for Aurora API.
|
||||
* Provides a simple health status endpoint for monitoring and load balancers.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
|
||||
/**
|
||||
* Health routes handler.
|
||||
*
|
||||
* @route GET /api/health
|
||||
* @description Returns server health status with timestamp.
|
||||
* @response 200 - `{ status: "ok", timestamp: number }`
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* GET /api/health
|
||||
*
|
||||
* // Response
|
||||
* { "status": "ok", "timestamp": 1707408000000 }
|
||||
*/
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
if (ctx.pathname === "/api/health" && ctx.method === "GET") {
|
||||
return Response.json({
|
||||
status: "ok",
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const healthRoutes: RouteModule = {
|
||||
name: "health",
|
||||
handler
|
||||
};
|
||||
114
api/src/routes/index.authz.test.ts
Normal file
114
api/src/routes/index.authz.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
|
||||
let currentSession: { discordId: string; username: string; role: "admin" | "player"; expiresAt: number } | null = null;
|
||||
|
||||
mock.module("./auth.routes", () => ({
|
||||
authRoutes: { name: "auth", handler: () => null },
|
||||
getSession: () => currentSession,
|
||||
}));
|
||||
|
||||
mock.module("./health.routes", () => ({
|
||||
healthRoutes: {
|
||||
name: "health",
|
||||
handler: ({ pathname }: { pathname: string }) =>
|
||||
pathname === "/api/health"
|
||||
? Response.json({ status: "ok" }, { status: 200 })
|
||||
: null,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("./stats.routes", () => ({
|
||||
statsRoutes: {
|
||||
name: "stats",
|
||||
handler: ({ pathname }: { pathname: string }) =>
|
||||
pathname === "/api/stats"
|
||||
? Response.json({ ok: true }, { status: 200 })
|
||||
: null,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("./actions.routes", () => ({ actionsRoutes: { name: "actions", handler: () => null } }));
|
||||
mock.module("./quests.routes", () => ({ questsRoutes: { name: "quests", handler: () => null } }));
|
||||
mock.module("./settings.routes", () => ({ settingsRoutes: { name: "settings", handler: () => null } }));
|
||||
mock.module("./guild-settings.routes", () => ({ guildSettingsRoutes: { name: "guild-settings", handler: () => null } }));
|
||||
mock.module("./items.routes", () => ({ itemsRoutes: { name: "items", handler: () => null } }));
|
||||
mock.module("./classes.routes", () => ({ classesRoutes: { name: "classes", handler: () => null } }));
|
||||
mock.module("./moderation.routes", () => ({ moderationRoutes: { name: "moderation", handler: () => null } }));
|
||||
mock.module("./transactions.routes", () => ({ transactionsRoutes: { name: "transactions", handler: () => null } }));
|
||||
mock.module("./lootdrops.routes", () => ({ lootdropsRoutes: { name: "lootdrops", handler: () => null } }));
|
||||
mock.module("./assets.routes", () => ({ assetsRoutes: { name: "assets", handler: () => null } }));
|
||||
mock.module("@shared/modules/user/user.service", () => ({
|
||||
userService: {
|
||||
getUserById: async (id: string) => ({ id, username: `user-${id}` }),
|
||||
},
|
||||
}));
|
||||
mock.module("@shared/modules/inventory/inventory.service", () => ({
|
||||
inventoryService: {
|
||||
getInventory: async (id: string) => [{ userId: id, itemId: 1, quantity: 1n }],
|
||||
},
|
||||
}));
|
||||
mock.module("@shared/lib/logger", () => ({
|
||||
logger: {
|
||||
error: () => { },
|
||||
info: () => { },
|
||||
warn: () => { },
|
||||
debug: () => { },
|
||||
},
|
||||
}));
|
||||
|
||||
import { handleRequest } from "./index";
|
||||
|
||||
describe("Route Authorization", () => {
|
||||
beforeEach(() => {
|
||||
currentSession = null;
|
||||
});
|
||||
|
||||
it("rejects unauthenticated protected API requests", async () => {
|
||||
const url = new URL("http://localhost/api/users/123");
|
||||
const res = await handleRequest(new Request(url, { method: "GET" }), url);
|
||||
|
||||
expect(res?.status).toBe(401);
|
||||
});
|
||||
|
||||
it("blocks players from admin user routes", async () => {
|
||||
currentSession = {
|
||||
discordId: "123",
|
||||
username: "player",
|
||||
role: "player",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
};
|
||||
|
||||
const url = new URL("http://localhost/api/users/456");
|
||||
const res = await handleRequest(new Request(url, { method: "GET" }), url);
|
||||
|
||||
expect(res?.status).toBe(403);
|
||||
});
|
||||
|
||||
it("allows players to access self-service API routes", async () => {
|
||||
currentSession = {
|
||||
discordId: "123",
|
||||
username: "player",
|
||||
role: "player",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
};
|
||||
|
||||
const url = new URL("http://localhost/api/me/inventory");
|
||||
const res = await handleRequest(new Request(url, { method: "GET" }), url);
|
||||
|
||||
expect(res?.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows admins to access admin user routes", async () => {
|
||||
currentSession = {
|
||||
discordId: "1",
|
||||
username: "admin",
|
||||
role: "admin",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
};
|
||||
|
||||
const url = new URL("http://localhost/api/users/456");
|
||||
const res = await handleRequest(new Request(url, { method: "GET" }), url);
|
||||
|
||||
expect(res?.status).toBe(200);
|
||||
});
|
||||
});
|
||||
102
api/src/routes/index.ts
Normal file
102
api/src/routes/index.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @fileoverview Route registration module for Aurora API.
|
||||
* Aggregates all route handlers and provides a unified request handler.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import { authRoutes, getSession } from "./auth.routes";
|
||||
import { healthRoutes } from "./health.routes";
|
||||
import { statsRoutes } from "./stats.routes";
|
||||
import { actionsRoutes } from "./actions.routes";
|
||||
import { questsRoutes } from "./quests.routes";
|
||||
import { settingsRoutes } from "./settings.routes";
|
||||
import { guildSettingsRoutes } from "./guild-settings.routes";
|
||||
import { itemsRoutes } from "./items.routes";
|
||||
import { usersRoutes } from "./users.routes";
|
||||
import { classesRoutes } from "./classes.routes";
|
||||
import { moderationRoutes } from "./moderation.routes";
|
||||
import { transactionsRoutes } from "./transactions.routes";
|
||||
import { lootdropsRoutes } from "./lootdrops.routes";
|
||||
import { assetsRoutes } from "./assets.routes";
|
||||
import { errorResponse } from "./utils";
|
||||
|
||||
/** Routes that do NOT require authentication */
|
||||
const publicRoutes: RouteModule[] = [
|
||||
authRoutes,
|
||||
healthRoutes,
|
||||
];
|
||||
|
||||
/** Routes that require an authenticated admin session */
|
||||
const protectedRoutes: RouteModule[] = [
|
||||
statsRoutes,
|
||||
actionsRoutes,
|
||||
questsRoutes,
|
||||
settingsRoutes,
|
||||
guildSettingsRoutes,
|
||||
itemsRoutes,
|
||||
usersRoutes,
|
||||
classesRoutes,
|
||||
moderationRoutes,
|
||||
transactionsRoutes,
|
||||
lootdropsRoutes,
|
||||
assetsRoutes,
|
||||
];
|
||||
|
||||
/**
|
||||
* Main request handler that routes requests to appropriate handlers.
|
||||
*
|
||||
* @param req - The incoming HTTP request
|
||||
* @param url - Parsed URL object
|
||||
* @returns Response from matching route handler, or null if no match
|
||||
*
|
||||
* @example
|
||||
* const response = await handleRequest(req, url);
|
||||
* if (response) return response;
|
||||
* return new Response("Not Found", { status: 404 });
|
||||
*/
|
||||
export async function handleRequest(req: Request, url: URL): Promise<Response | null> {
|
||||
const ctx: RouteContext = {
|
||||
req,
|
||||
url,
|
||||
method: req.method,
|
||||
pathname: url.pathname,
|
||||
};
|
||||
|
||||
// Try public routes first (auth, health)
|
||||
for (const module of publicRoutes) {
|
||||
const response = await module.handler(ctx);
|
||||
if (response !== null) return response;
|
||||
}
|
||||
|
||||
// For API routes, enforce authentication
|
||||
if (ctx.pathname.startsWith("/api/")) {
|
||||
const session = getSession(req);
|
||||
if (!session) {
|
||||
return errorResponse("Unauthorized", 401);
|
||||
}
|
||||
|
||||
// Player routes are explicitly allow-listed. Everything else is admin-only.
|
||||
const playerAllowedPrefixes = ["/api/stats", "/api/health", "/api/me"];
|
||||
const isPlayerAllowed = playerAllowedPrefixes.some(p => ctx.pathname.startsWith(p));
|
||||
|
||||
if (session.role === "player" && !isPlayerAllowed) {
|
||||
return errorResponse("Admin access required", 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Try protected routes
|
||||
for (const module of protectedRoutes) {
|
||||
const response = await module.handler(ctx);
|
||||
if (response !== null) return response;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all registered route module names.
|
||||
* Useful for debugging and documentation.
|
||||
*/
|
||||
export function getRegisteredRoutes(): string[] {
|
||||
return [...publicRoutes, ...protectedRoutes].map(m => m.name);
|
||||
}
|
||||
371
api/src/routes/items.routes.ts
Normal file
371
api/src/routes/items.routes.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* @fileoverview Items management endpoints for Aurora API.
|
||||
* Provides CRUD operations for game items with image upload support.
|
||||
*/
|
||||
|
||||
import { join, resolve, dirname } from "path";
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import type { CreateItemDTO, UpdateItemDTO } from "@shared/modules/items/items.service";
|
||||
import {
|
||||
jsonResponse,
|
||||
errorResponse,
|
||||
parseBody,
|
||||
parseIdFromPath,
|
||||
parseQuery,
|
||||
withErrorHandling
|
||||
} from "./utils";
|
||||
import { CreateItemSchema, UpdateItemSchema, ItemQuerySchema } from "./schemas";
|
||||
|
||||
// Resolve assets directory path
|
||||
const currentDir = dirname(new URL(import.meta.url).pathname);
|
||||
const assetsDir = resolve(currentDir, "../../../bot/assets/graphics/items");
|
||||
|
||||
/**
|
||||
* Validates image file by checking magic bytes.
|
||||
* Supports PNG, JPEG, WebP, and GIF formats.
|
||||
*/
|
||||
function validateImageFormat(bytes: Uint8Array): boolean {
|
||||
const isPNG = bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47;
|
||||
const isJPEG = bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF;
|
||||
const isWebP = bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50;
|
||||
const isGIF = bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46;
|
||||
|
||||
return isPNG || isJPEG || isWebP || isGIF;
|
||||
}
|
||||
|
||||
/** Maximum image file size: 15MB */
|
||||
const MAX_IMAGE_SIZE = 15 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Items routes handler.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/items - List items with filters
|
||||
* - POST /api/items - Create item (JSON or multipart with image)
|
||||
* - GET /api/items/:id - Get single item
|
||||
* - PUT /api/items/:id - Update item
|
||||
* - DELETE /api/items/:id - Delete item and asset
|
||||
* - POST /api/items/:id/icon - Upload/replace item icon
|
||||
*/
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method, req, url } = ctx;
|
||||
|
||||
// Only handle requests to /api/items*
|
||||
if (!pathname.startsWith("/api/items")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { itemsService } = await import("@shared/modules/items/items.service");
|
||||
|
||||
/**
|
||||
* @route GET /api/items
|
||||
* @description Returns a paginated list of items with optional filtering.
|
||||
*
|
||||
* @query search - Filter by name/description (partial match)
|
||||
* @query type - Filter by item type (CONSUMABLE, EQUIPMENT, etc.)
|
||||
* @query rarity - Filter by rarity (C, R, SR, SSR)
|
||||
* @query limit - Max results per page (default: 100, max: 100)
|
||||
* @query offset - Pagination offset (default: 0)
|
||||
*
|
||||
* @response 200 - `{ items: Item[], total: number }`
|
||||
* @response 500 - Error fetching items
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* GET /api/items?type=CONSUMABLE&rarity=R&limit=10
|
||||
*
|
||||
* // Response
|
||||
* {
|
||||
* "items": [{ "id": 1, "name": "Health Potion", ... }],
|
||||
* "total": 25
|
||||
* }
|
||||
*/
|
||||
if (pathname === "/api/items" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const filters = {
|
||||
search: url.searchParams.get("search") || undefined,
|
||||
type: url.searchParams.get("type") || undefined,
|
||||
rarity: url.searchParams.get("rarity") || undefined,
|
||||
limit: url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 100,
|
||||
offset: url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0,
|
||||
};
|
||||
|
||||
const result = await itemsService.getAllItems(filters);
|
||||
return jsonResponse(result);
|
||||
}, "fetch items");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route POST /api/items
|
||||
* @description Creates a new item. Supports JSON or multipart/form-data with image.
|
||||
*
|
||||
* @body (JSON) {
|
||||
* name: string (required),
|
||||
* type: string (required),
|
||||
* description?: string,
|
||||
* rarity?: "C" | "R" | "SR" | "SSR",
|
||||
* price?: string | number,
|
||||
* usageData?: object
|
||||
* }
|
||||
*
|
||||
* @body (Multipart) {
|
||||
* data: JSON string with item fields,
|
||||
* image?: File (PNG, JPEG, WebP, GIF - max 15MB)
|
||||
* }
|
||||
*
|
||||
* @response 201 - `{ success: true, item: Item }`
|
||||
* @response 400 - Missing required fields or invalid image
|
||||
* @response 409 - Item name already exists
|
||||
* @response 500 - Error creating item
|
||||
*/
|
||||
if (pathname === "/api/items" && method === "POST") {
|
||||
return withErrorHandling(async () => {
|
||||
const contentType = req.headers.get("content-type") || "";
|
||||
|
||||
let itemData: CreateItemDTO | null = null;
|
||||
let imageFile: File | null = null;
|
||||
|
||||
if (contentType.includes("multipart/form-data")) {
|
||||
const formData = await req.formData();
|
||||
const jsonData = formData.get("data");
|
||||
imageFile = formData.get("image") as File | null;
|
||||
|
||||
if (typeof jsonData === "string") {
|
||||
itemData = JSON.parse(jsonData) as CreateItemDTO;
|
||||
} else {
|
||||
return errorResponse("Missing item data", 400);
|
||||
}
|
||||
} else {
|
||||
itemData = await req.json() as CreateItemDTO;
|
||||
}
|
||||
|
||||
if (!itemData) {
|
||||
return errorResponse("Missing item data", 400);
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!itemData.name || !itemData.type) {
|
||||
return errorResponse("Missing required fields: name and type are required", 400);
|
||||
}
|
||||
|
||||
// Check for duplicate name
|
||||
if (await itemsService.isNameTaken(itemData.name)) {
|
||||
return errorResponse("An item with this name already exists", 409);
|
||||
}
|
||||
|
||||
// Set placeholder URLs if image will be uploaded
|
||||
const placeholderUrl = "/assets/items/placeholder.png";
|
||||
const createData = {
|
||||
name: itemData.name,
|
||||
description: itemData.description || null,
|
||||
rarity: itemData.rarity || "C",
|
||||
type: itemData.type,
|
||||
price: itemData.price ? BigInt(itemData.price) : null,
|
||||
iconUrl: itemData.iconUrl || placeholderUrl,
|
||||
imageUrl: itemData.imageUrl || placeholderUrl,
|
||||
usageData: itemData.usageData || null,
|
||||
};
|
||||
|
||||
// Create the item
|
||||
const item = await itemsService.createItem(createData);
|
||||
|
||||
// If image was provided, save it and update the item
|
||||
if (imageFile && item) {
|
||||
const buffer = await imageFile.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
if (!validateImageFormat(bytes)) {
|
||||
await itemsService.deleteItem(item.id);
|
||||
return errorResponse("Invalid image file. Only PNG, JPEG, WebP, and GIF are allowed.", 400);
|
||||
}
|
||||
|
||||
if (buffer.byteLength > MAX_IMAGE_SIZE) {
|
||||
await itemsService.deleteItem(item.id);
|
||||
return errorResponse("Image file too large. Maximum size is 15MB.", 400);
|
||||
}
|
||||
|
||||
const fileName = `${item.id}.png`;
|
||||
const filePath = join(assetsDir, fileName);
|
||||
await Bun.write(filePath, buffer);
|
||||
|
||||
const assetUrl = `/assets/items/${fileName}?v=${Date.now()}`;
|
||||
await itemsService.updateItem(item.id, {
|
||||
iconUrl: assetUrl,
|
||||
imageUrl: assetUrl,
|
||||
});
|
||||
|
||||
const updatedItem = await itemsService.getItemById(item.id);
|
||||
return jsonResponse({ success: true, item: updatedItem }, 201);
|
||||
}
|
||||
|
||||
return jsonResponse({ success: true, item }, 201);
|
||||
}, "create item");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route GET /api/items/:id
|
||||
* @description Returns a single item by ID.
|
||||
*
|
||||
* @param id - Item ID (numeric)
|
||||
* @response 200 - Full item object
|
||||
* @response 404 - Item not found
|
||||
* @response 500 - Error fetching item
|
||||
*/
|
||||
if (pathname.match(/^\/api\/items\/\d+$/) && method === "GET") {
|
||||
const id = parseIdFromPath(pathname);
|
||||
if (!id) return null;
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const item = await itemsService.getItemById(id);
|
||||
if (!item) {
|
||||
return errorResponse("Item not found", 404);
|
||||
}
|
||||
return jsonResponse(item);
|
||||
}, "fetch item");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route PUT /api/items/:id
|
||||
* @description Updates an existing item.
|
||||
*
|
||||
* @param id - Item ID (numeric)
|
||||
* @body Partial item fields to update
|
||||
* @response 200 - `{ success: true, item: Item }`
|
||||
* @response 404 - Item not found
|
||||
* @response 409 - Name already taken by another item
|
||||
* @response 500 - Error updating item
|
||||
*/
|
||||
if (pathname.match(/^\/api\/items\/\d+$/) && method === "PUT") {
|
||||
const id = parseIdFromPath(pathname);
|
||||
if (!id) return null;
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const data = await req.json() as Partial<UpdateItemDTO>;
|
||||
|
||||
const existing = await itemsService.getItemById(id);
|
||||
if (!existing) {
|
||||
return errorResponse("Item not found", 404);
|
||||
}
|
||||
|
||||
// Check for duplicate name (if name is being changed)
|
||||
if (data.name && data.name !== existing.name) {
|
||||
if (await itemsService.isNameTaken(data.name, id)) {
|
||||
return errorResponse("An item with this name already exists", 409);
|
||||
}
|
||||
}
|
||||
|
||||
// Build update data
|
||||
const updateData: Partial<UpdateItemDTO> = {};
|
||||
if (data.name !== undefined) updateData.name = data.name;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
if (data.rarity !== undefined) updateData.rarity = data.rarity;
|
||||
if (data.type !== undefined) updateData.type = data.type;
|
||||
if (data.price !== undefined) updateData.price = data.price ? BigInt(data.price) : null;
|
||||
if (data.iconUrl !== undefined) updateData.iconUrl = data.iconUrl;
|
||||
if (data.imageUrl !== undefined) updateData.imageUrl = data.imageUrl;
|
||||
if (data.usageData !== undefined) updateData.usageData = data.usageData;
|
||||
|
||||
const updatedItem = await itemsService.updateItem(id, updateData);
|
||||
return jsonResponse({ success: true, item: updatedItem });
|
||||
}, "update item");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route DELETE /api/items/:id
|
||||
* @description Deletes an item and its associated asset file.
|
||||
*
|
||||
* @param id - Item ID (numeric)
|
||||
* @response 204 - Item deleted (no content)
|
||||
* @response 404 - Item not found
|
||||
* @response 500 - Error deleting item
|
||||
*/
|
||||
if (pathname.match(/^\/api\/items\/\d+$/) && method === "DELETE") {
|
||||
const id = parseIdFromPath(pathname);
|
||||
if (!id) return null;
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const existing = await itemsService.getItemById(id);
|
||||
if (!existing) {
|
||||
return errorResponse("Item not found", 404);
|
||||
}
|
||||
|
||||
await itemsService.deleteItem(id);
|
||||
|
||||
// Try to delete associated asset file
|
||||
const assetPath = join(assetsDir, `${id}.png`);
|
||||
try {
|
||||
const assetFile = Bun.file(assetPath);
|
||||
if (await assetFile.exists()) {
|
||||
const { unlink } = await import("node:fs/promises");
|
||||
await unlink(assetPath);
|
||||
}
|
||||
} catch (e) {
|
||||
// Non-critical: log but don't fail
|
||||
const { logger } = await import("@shared/lib/logger");
|
||||
logger.warn("web", `Could not delete asset file for item ${id}`, e);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}, "delete item");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route POST /api/items/:id/icon
|
||||
* @description Uploads or replaces an item's icon image.
|
||||
*
|
||||
* @param id - Item ID (numeric)
|
||||
* @body (Multipart) { image: File }
|
||||
* @response 200 - `{ success: true, item: Item }`
|
||||
* @response 400 - No image file or invalid format
|
||||
* @response 404 - Item not found
|
||||
* @response 500 - Error uploading icon
|
||||
*/
|
||||
if (pathname.match(/^\/api\/items\/\d+\/icon$/) && method === "POST") {
|
||||
const id = parseInt(pathname.split("/")[3] || "0");
|
||||
if (!id) return null;
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const existing = await itemsService.getItemById(id);
|
||||
if (!existing) {
|
||||
return errorResponse("Item not found", 404);
|
||||
}
|
||||
|
||||
const formData = await req.formData();
|
||||
const imageFile = formData.get("image") as File | null;
|
||||
|
||||
if (!imageFile) {
|
||||
return errorResponse("No image file provided", 400);
|
||||
}
|
||||
|
||||
const buffer = await imageFile.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
if (!validateImageFormat(bytes)) {
|
||||
return errorResponse("Invalid image file. Only PNG, JPEG, WebP, and GIF are allowed.", 400);
|
||||
}
|
||||
|
||||
if (buffer.byteLength > MAX_IMAGE_SIZE) {
|
||||
return errorResponse("Image file too large. Maximum size is 15MB.", 400);
|
||||
}
|
||||
|
||||
const fileName = `${id}.png`;
|
||||
const filePath = join(assetsDir, fileName);
|
||||
await Bun.write(filePath, buffer);
|
||||
|
||||
const assetUrl = `/assets/items/${fileName}?v=${Date.now()}`;
|
||||
const updatedItem = await itemsService.updateItem(id, {
|
||||
iconUrl: assetUrl,
|
||||
imageUrl: assetUrl,
|
||||
});
|
||||
|
||||
return jsonResponse({ success: true, item: updatedItem });
|
||||
}, "upload item icon");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const itemsRoutes: RouteModule = {
|
||||
name: "items",
|
||||
handler
|
||||
};
|
||||
130
api/src/routes/lootdrops.routes.ts
Normal file
130
api/src/routes/lootdrops.routes.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @fileoverview Lootdrop management endpoints for Aurora API.
|
||||
* Provides endpoints for viewing, spawning, and canceling lootdrops.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import {
|
||||
jsonResponse,
|
||||
errorResponse,
|
||||
parseStringIdFromPath,
|
||||
withErrorHandling
|
||||
} from "./utils";
|
||||
|
||||
/**
|
||||
* Lootdrops routes handler.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/lootdrops - List lootdrops
|
||||
* - POST /api/lootdrops - Spawn a lootdrop
|
||||
* - DELETE /api/lootdrops/:messageId - Cancel/delete a lootdrop
|
||||
*/
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method, req, url } = ctx;
|
||||
|
||||
// Only handle requests to /api/lootdrops*
|
||||
if (!pathname.startsWith("/api/lootdrops")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @route GET /api/lootdrops
|
||||
* @description Returns recent lootdrops, sorted by newest first.
|
||||
*
|
||||
* @query limit - Max results (default: 50)
|
||||
* @response 200 - `{ lootdrops: Lootdrop[] }`
|
||||
* @response 500 - Error fetching lootdrops
|
||||
*/
|
||||
if (pathname === "/api/lootdrops" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const { lootdrops } = await import("@shared/db/schema");
|
||||
const { DrizzleClient } = await import("@shared/db/DrizzleClient");
|
||||
const { desc } = await import("drizzle-orm");
|
||||
|
||||
const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
|
||||
|
||||
const result = await DrizzleClient.select()
|
||||
.from(lootdrops)
|
||||
.orderBy(desc(lootdrops.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
return jsonResponse({ lootdrops: result });
|
||||
}, "fetch lootdrops");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route POST /api/lootdrops
|
||||
* @description Spawns a new lootdrop in a Discord channel.
|
||||
* Requires a valid text channel ID where the bot has permissions.
|
||||
*
|
||||
* @body {
|
||||
* channelId: string (required) - Discord channel ID to spawn in,
|
||||
* amount?: number - Reward amount (random if not specified),
|
||||
* currency?: string - Currency type
|
||||
* }
|
||||
* @response 201 - `{ success: true }`
|
||||
* @response 400 - Invalid channel or missing channelId
|
||||
* @response 500 - Error spawning lootdrop
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* POST /api/lootdrops
|
||||
* { "channelId": "1234567890", "amount": 100, "currency": "Gold" }
|
||||
*/
|
||||
if (pathname === "/api/lootdrops" && method === "POST") {
|
||||
return withErrorHandling(async () => {
|
||||
const { spawnLootdrop } = await import("../../../bot/modules/economy/lootdrop.handler");
|
||||
const { AuroraClient } = await import("../../../bot/lib/BotClient");
|
||||
const { TextChannel } = await import("discord.js");
|
||||
|
||||
const data = await req.json() as Record<string, any>;
|
||||
|
||||
if (!data.channelId) {
|
||||
return errorResponse("Missing required field: channelId", 400);
|
||||
}
|
||||
|
||||
const channel = await AuroraClient.channels.fetch(data.channelId);
|
||||
|
||||
if (!channel || !(channel instanceof TextChannel)) {
|
||||
return errorResponse("Invalid channel. Must be a TextChannel.", 400);
|
||||
}
|
||||
|
||||
await spawnLootdrop(channel, data.amount, data.currency);
|
||||
|
||||
return jsonResponse({ success: true }, 201);
|
||||
}, "spawn lootdrop");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route DELETE /api/lootdrops/:messageId
|
||||
* @description Cancels and deletes an active lootdrop.
|
||||
* The lootdrop is identified by its Discord message ID.
|
||||
*
|
||||
* @param messageId - Discord message ID of the lootdrop
|
||||
* @response 204 - Lootdrop deleted (no content)
|
||||
* @response 404 - Lootdrop not found
|
||||
* @response 500 - Error deleting lootdrop
|
||||
*/
|
||||
if (pathname.match(/^\/api\/lootdrops\/[^\/]+$/) && method === "DELETE") {
|
||||
const messageId = parseStringIdFromPath(pathname);
|
||||
if (!messageId) return null;
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const { deleteLootdrop } = await import("../../../bot/modules/economy/lootdrop.handler");
|
||||
const success = await deleteLootdrop(messageId);
|
||||
|
||||
if (!success) {
|
||||
return errorResponse("Lootdrop not found", 404);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}, "delete lootdrop");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const lootdropsRoutes: RouteModule = {
|
||||
name: "lootdrops",
|
||||
handler
|
||||
};
|
||||
217
api/src/routes/moderation.routes.ts
Normal file
217
api/src/routes/moderation.routes.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* @fileoverview Moderation case management endpoints for Aurora API.
|
||||
* Provides endpoints for viewing, creating, and resolving moderation cases.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import {
|
||||
jsonResponse,
|
||||
errorResponse,
|
||||
parseBody,
|
||||
withErrorHandling
|
||||
} from "./utils";
|
||||
import { CreateCaseSchema, ClearCaseSchema, CaseIdPattern } from "./schemas";
|
||||
|
||||
/**
|
||||
* Moderation routes handler.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/moderation - List cases with filters
|
||||
* - GET /api/moderation/:caseId - Get single case
|
||||
* - POST /api/moderation - Create new case
|
||||
* - PUT /api/moderation/:caseId/clear - Clear/resolve case
|
||||
*/
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method, req, url } = ctx;
|
||||
|
||||
// Only handle requests to /api/moderation*
|
||||
if (!pathname.startsWith("/api/moderation")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { moderationService } = await import("@shared/modules/moderation/moderation.service");
|
||||
|
||||
/**
|
||||
* @route GET /api/moderation
|
||||
* @description Returns moderation cases with optional filtering.
|
||||
*
|
||||
* @query userId - Filter by target user ID
|
||||
* @query moderatorId - Filter by moderator ID
|
||||
* @query type - Filter by case type (warn, timeout, kick, ban, note, prune)
|
||||
* @query active - Filter by active status (true/false)
|
||||
* @query limit - Max results (default: 50)
|
||||
* @query offset - Pagination offset (default: 0)
|
||||
*
|
||||
* @response 200 - `{ cases: ModerationCase[] }`
|
||||
* @response 500 - Error fetching cases
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* GET /api/moderation?type=warn&active=true&limit=10
|
||||
*
|
||||
* // Response
|
||||
* {
|
||||
* "cases": [
|
||||
* {
|
||||
* "id": "1",
|
||||
* "caseId": "CASE-0001",
|
||||
* "type": "warn",
|
||||
* "userId": "123456789",
|
||||
* "username": "User1",
|
||||
* "moderatorId": "987654321",
|
||||
* "moderatorName": "Mod1",
|
||||
* "reason": "Spam",
|
||||
* "active": true,
|
||||
* "createdAt": "2024-01-15T12:00:00Z"
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
if (pathname === "/api/moderation" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const filter: any = {};
|
||||
if (url.searchParams.get("userId")) filter.userId = url.searchParams.get("userId");
|
||||
if (url.searchParams.get("moderatorId")) filter.moderatorId = url.searchParams.get("moderatorId");
|
||||
if (url.searchParams.get("type")) filter.type = url.searchParams.get("type");
|
||||
const activeParam = url.searchParams.get("active");
|
||||
if (activeParam !== null) filter.active = activeParam === "true";
|
||||
filter.limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
|
||||
filter.offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
|
||||
|
||||
const cases = await moderationService.searchCases(filter);
|
||||
return jsonResponse({ cases });
|
||||
}, "fetch moderation cases");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route GET /api/moderation/:caseId
|
||||
* @description Returns a single moderation case by case ID.
|
||||
* Case IDs follow the format CASE-XXXX (e.g., CASE-0001).
|
||||
*
|
||||
* @param caseId - Case ID in CASE-XXXX format
|
||||
* @response 200 - Full case object
|
||||
* @response 404 - Case not found
|
||||
* @response 500 - Error fetching case
|
||||
*/
|
||||
if (pathname.match(/^\/api\/moderation\/CASE-\d+$/i) && method === "GET") {
|
||||
const caseId = pathname.split("/").pop()!.toUpperCase();
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const moderationCase = await moderationService.getCaseById(caseId);
|
||||
|
||||
if (!moderationCase) {
|
||||
return errorResponse("Case not found", 404);
|
||||
}
|
||||
|
||||
return jsonResponse(moderationCase);
|
||||
}, "fetch moderation case");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route POST /api/moderation
|
||||
* @description Creates a new moderation case.
|
||||
*
|
||||
* @body {
|
||||
* type: "warn" | "timeout" | "kick" | "ban" | "note" | "prune" (required),
|
||||
* userId: string (required) - Target user's Discord ID,
|
||||
* username: string (required) - Target user's username,
|
||||
* moderatorId: string (required) - Moderator's Discord ID,
|
||||
* moderatorName: string (required) - Moderator's username,
|
||||
* reason: string (required) - Reason for the action,
|
||||
* metadata?: object - Additional case metadata (e.g., duration)
|
||||
* }
|
||||
* @response 201 - `{ success: true, case: ModerationCase }`
|
||||
* @response 400 - Missing required fields
|
||||
* @response 500 - Error creating case
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* POST /api/moderation
|
||||
* {
|
||||
* "type": "warn",
|
||||
* "userId": "123456789",
|
||||
* "username": "User1",
|
||||
* "moderatorId": "987654321",
|
||||
* "moderatorName": "Mod1",
|
||||
* "reason": "Rule violation",
|
||||
* "metadata": { "duration": "24h" }
|
||||
* }
|
||||
*/
|
||||
if (pathname === "/api/moderation" && method === "POST") {
|
||||
return withErrorHandling(async () => {
|
||||
const data = await req.json() as Record<string, any>;
|
||||
|
||||
if (!data.type || !data.userId || !data.username || !data.moderatorId || !data.moderatorName || !data.reason) {
|
||||
return errorResponse(
|
||||
"Missing required fields: type, userId, username, moderatorId, moderatorName, reason",
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const newCase = await moderationService.createCase({
|
||||
type: data.type,
|
||||
userId: data.userId,
|
||||
username: data.username,
|
||||
moderatorId: data.moderatorId,
|
||||
moderatorName: data.moderatorName,
|
||||
reason: data.reason,
|
||||
metadata: data.metadata || {},
|
||||
});
|
||||
|
||||
return jsonResponse({ success: true, case: newCase }, 201);
|
||||
}, "create moderation case");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route PUT /api/moderation/:caseId/clear
|
||||
* @description Clears/resolves a moderation case.
|
||||
* Sets the case as inactive and records who cleared it.
|
||||
*
|
||||
* @param caseId - Case ID in CASE-XXXX format
|
||||
* @body {
|
||||
* clearedBy: string (required) - Discord ID of user clearing the case,
|
||||
* clearedByName: string (required) - Username of user clearing the case,
|
||||
* reason?: string - Reason for clearing (default: "Cleared via API")
|
||||
* }
|
||||
* @response 200 - `{ success: true, case: ModerationCase }`
|
||||
* @response 400 - Missing required fields
|
||||
* @response 404 - Case not found
|
||||
* @response 500 - Error clearing case
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* PUT /api/moderation/CASE-0001/clear
|
||||
* { "clearedBy": "987654321", "clearedByName": "Admin1", "reason": "Appeal accepted" }
|
||||
*/
|
||||
if (pathname.match(/^\/api\/moderation\/CASE-\d+\/clear$/i) && method === "PUT") {
|
||||
const caseId = (pathname.split("/")[3] || "").toUpperCase();
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const data = await req.json() as Record<string, any>;
|
||||
|
||||
if (!data.clearedBy || !data.clearedByName) {
|
||||
return errorResponse("Missing required fields: clearedBy, clearedByName", 400);
|
||||
}
|
||||
|
||||
const updatedCase = await moderationService.clearCase({
|
||||
caseId,
|
||||
clearedBy: data.clearedBy,
|
||||
clearedByName: data.clearedByName,
|
||||
reason: data.reason || "Cleared via API",
|
||||
});
|
||||
|
||||
if (!updatedCase) {
|
||||
return errorResponse("Case not found", 404);
|
||||
}
|
||||
|
||||
return jsonResponse({ success: true, case: updatedCase });
|
||||
}, "clear moderation case");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const moderationRoutes: RouteModule = {
|
||||
name: "moderation",
|
||||
handler
|
||||
};
|
||||
207
api/src/routes/quests.routes.ts
Normal file
207
api/src/routes/quests.routes.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @fileoverview Quest management endpoints for Aurora API.
|
||||
* Provides CRUD operations for game quests.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import { jsonResponse, errorResponse, parseIdFromPath, withErrorHandling } from "./utils";
|
||||
import { CreateQuestSchema, UpdateQuestSchema } from "@shared/modules/quest/quest.types";
|
||||
|
||||
/**
|
||||
* Quest routes handler.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/quests - List all quests
|
||||
* - POST /api/quests - Create a new quest
|
||||
* - PUT /api/quests/:id - Update an existing quest
|
||||
* - DELETE /api/quests/:id - Delete a quest
|
||||
*/
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method, req } = ctx;
|
||||
|
||||
// Only handle requests to /api/quests*
|
||||
if (!pathname.startsWith("/api/quests")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { questService } = await import("@shared/modules/quest/quest.service");
|
||||
|
||||
/**
|
||||
* @route GET /api/quests
|
||||
* @description Returns all quests in the system.
|
||||
* @response 200 - `{ success: true, data: Quest[] }`
|
||||
* @response 500 - Error fetching quests
|
||||
*
|
||||
* @example
|
||||
* // Response
|
||||
* {
|
||||
* "success": true,
|
||||
* "data": [
|
||||
* {
|
||||
* "id": 1,
|
||||
* "name": "Daily Login",
|
||||
* "description": "Login once to claim",
|
||||
* "triggerEvent": "login",
|
||||
* "requirements": { "target": 1 },
|
||||
* "rewards": { "xp": 50, "balance": 100 }
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
if (pathname === "/api/quests" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const quests = await questService.getAllQuests();
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
data: quests.map(q => ({
|
||||
id: q.id,
|
||||
name: q.name,
|
||||
description: q.description,
|
||||
triggerEvent: q.triggerEvent,
|
||||
requirements: q.requirements,
|
||||
rewards: q.rewards,
|
||||
})),
|
||||
});
|
||||
}, "fetch quests");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route POST /api/quests
|
||||
* @description Creates a new quest.
|
||||
*
|
||||
* @body {
|
||||
* name: string,
|
||||
* description?: string,
|
||||
* triggerEvent: string,
|
||||
* target: number,
|
||||
* xpReward: number,
|
||||
* balanceReward: number
|
||||
* }
|
||||
* @response 200 - `{ success: true, quest: Quest }`
|
||||
* @response 400 - Validation error
|
||||
* @response 500 - Error creating quest
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* POST /api/quests
|
||||
* {
|
||||
* "name": "Win 5 Battles",
|
||||
* "description": "Defeat 5 enemies in combat",
|
||||
* "triggerEvent": "battle_win",
|
||||
* "target": 5,
|
||||
* "xpReward": 200,
|
||||
* "balanceReward": 500
|
||||
* }
|
||||
*/
|
||||
if (pathname === "/api/quests" && method === "POST") {
|
||||
return withErrorHandling(async () => {
|
||||
const rawData = await req.json();
|
||||
const parseResult = CreateQuestSchema.safeParse(rawData);
|
||||
|
||||
if (!parseResult.success) {
|
||||
return Response.json({
|
||||
error: "Invalid payload",
|
||||
issues: parseResult.error.issues.map(i => ({ path: i.path, message: i.message }))
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const data = parseResult.data;
|
||||
const result = await questService.createQuest({
|
||||
name: data.name,
|
||||
description: data.description || "",
|
||||
triggerEvent: data.triggerEvent,
|
||||
requirements: { target: data.target },
|
||||
rewards: {
|
||||
xp: data.xpReward,
|
||||
balance: data.balanceReward
|
||||
}
|
||||
});
|
||||
|
||||
return jsonResponse({ success: true, quest: result[0] });
|
||||
}, "create quest");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route PUT /api/quests/:id
|
||||
* @description Updates an existing quest by ID.
|
||||
*
|
||||
* @param id - Quest ID (numeric)
|
||||
* @body Partial quest fields to update
|
||||
* @response 200 - `{ success: true, quest: Quest }`
|
||||
* @response 400 - Invalid quest ID or validation error
|
||||
* @response 404 - Quest not found
|
||||
* @response 500 - Error updating quest
|
||||
*/
|
||||
if (pathname.match(/^\/api\/quests\/\d+$/) && method === "PUT") {
|
||||
const id = parseIdFromPath(pathname);
|
||||
if (!id) {
|
||||
return errorResponse("Invalid quest ID", 400);
|
||||
}
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const rawData = await req.json();
|
||||
const parseResult = UpdateQuestSchema.safeParse(rawData);
|
||||
|
||||
if (!parseResult.success) {
|
||||
return Response.json({
|
||||
error: "Invalid payload",
|
||||
issues: parseResult.error.issues.map(i => ({ path: i.path, message: i.message }))
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const data = parseResult.data;
|
||||
const result = await questService.updateQuest(id, {
|
||||
...(data.name !== undefined && { name: data.name }),
|
||||
...(data.description !== undefined && { description: data.description }),
|
||||
...(data.triggerEvent !== undefined && { triggerEvent: data.triggerEvent }),
|
||||
...(data.target !== undefined && { requirements: { target: data.target } }),
|
||||
...((data.xpReward !== undefined || data.balanceReward !== undefined) && {
|
||||
rewards: {
|
||||
xp: data.xpReward ?? 0,
|
||||
balance: data.balanceReward ?? 0
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!result || result.length === 0) {
|
||||
return errorResponse("Quest not found", 404);
|
||||
}
|
||||
|
||||
return jsonResponse({ success: true, quest: result[0] });
|
||||
}, "update quest");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route DELETE /api/quests/:id
|
||||
* @description Deletes a quest by ID.
|
||||
*
|
||||
* @param id - Quest ID (numeric)
|
||||
* @response 200 - `{ success: true, deleted: number }`
|
||||
* @response 400 - Invalid quest ID
|
||||
* @response 404 - Quest not found
|
||||
* @response 500 - Error deleting quest
|
||||
*/
|
||||
if (pathname.match(/^\/api\/quests\/\d+$/) && method === "DELETE") {
|
||||
const id = parseIdFromPath(pathname);
|
||||
if (!id) {
|
||||
return errorResponse("Invalid quest ID", 400);
|
||||
}
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const result = await questService.deleteQuest(id);
|
||||
|
||||
if (!result || result.length === 0) {
|
||||
return errorResponse("Quest not found", 404);
|
||||
}
|
||||
|
||||
return jsonResponse({ success: true, deleted: (result[0] as { id: number }).id });
|
||||
}, "delete quest");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const questsRoutes: RouteModule = {
|
||||
name: "quests",
|
||||
handler
|
||||
};
|
||||
274
api/src/routes/schemas.ts
Normal file
274
api/src/routes/schemas.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* @fileoverview Centralized Zod validation schemas for all Aurora API endpoints.
|
||||
* Provides type-safe request/response validation for every entity in the system.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
// ============================================================================
|
||||
// Common Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Standard pagination query parameters.
|
||||
*/
|
||||
export const PaginationSchema = z.object({
|
||||
limit: z.coerce.number().min(1).max(100).optional().default(50),
|
||||
offset: z.coerce.number().min(0).optional().default(0),
|
||||
});
|
||||
|
||||
/**
|
||||
* Numeric ID parameter validation.
|
||||
*/
|
||||
export const NumericIdSchema = z.coerce.number().int().positive();
|
||||
|
||||
/**
|
||||
* Discord snowflake ID validation (string of digits).
|
||||
*/
|
||||
export const SnowflakeIdSchema = z.string().regex(/^\d{17,20}$/, "Invalid Discord ID format");
|
||||
|
||||
// ============================================================================
|
||||
// Items Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Valid item types in the system.
|
||||
*/
|
||||
export const ItemTypeEnum = z.enum([
|
||||
"CONSUMABLE",
|
||||
"EQUIPMENT",
|
||||
"MATERIAL",
|
||||
"LOOTBOX",
|
||||
"COLLECTIBLE",
|
||||
"KEY",
|
||||
"TOOL"
|
||||
]);
|
||||
|
||||
/**
|
||||
* Valid item rarities.
|
||||
*/
|
||||
export const ItemRarityEnum = z.enum(["C", "R", "SR", "SSR"]);
|
||||
|
||||
/**
|
||||
* Query parameters for listing items.
|
||||
*/
|
||||
export const ItemQuerySchema = PaginationSchema.extend({
|
||||
search: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
rarity: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for creating a new item.
|
||||
*/
|
||||
export const CreateItemSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").max(100),
|
||||
description: z.string().max(500).nullable().optional(),
|
||||
rarity: ItemRarityEnum.optional().default("C"),
|
||||
type: ItemTypeEnum,
|
||||
price: z.union([z.string(), z.number()]).nullable().optional(),
|
||||
iconUrl: z.string().optional(),
|
||||
imageUrl: z.string().optional(),
|
||||
usageData: z.any().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for updating an existing item.
|
||||
*/
|
||||
export const UpdateItemSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
description: z.string().max(500).nullable().optional(),
|
||||
rarity: ItemRarityEnum.optional(),
|
||||
type: ItemTypeEnum.optional(),
|
||||
price: z.union([z.string(), z.number()]).nullable().optional(),
|
||||
iconUrl: z.string().optional(),
|
||||
imageUrl: z.string().optional(),
|
||||
usageData: z.any().nullable().optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Users Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Query parameters for listing users.
|
||||
*/
|
||||
export const UserQuerySchema = PaginationSchema.extend({
|
||||
search: z.string().optional(),
|
||||
sortBy: z.enum(["balance", "level", "xp", "username"]).optional().default("balance"),
|
||||
sortOrder: z.enum(["asc", "desc"]).optional().default("desc"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for updating a user.
|
||||
*/
|
||||
export const UpdateUserSchema = z.object({
|
||||
username: z.string().min(1).max(32).optional(),
|
||||
balance: z.union([z.string(), z.number()]).optional(),
|
||||
xp: z.union([z.string(), z.number()]).optional(),
|
||||
level: z.coerce.number().int().min(0).optional(),
|
||||
dailyStreak: z.coerce.number().int().min(0).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
settings: z.record(z.string(), z.any()).optional(),
|
||||
classId: z.union([z.string(), z.number()]).nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for adding an item to user inventory.
|
||||
*/
|
||||
export const InventoryAddSchema = z.object({
|
||||
itemId: z.coerce.number().int().positive("Item ID is required"),
|
||||
quantity: z.union([z.string(), z.number()]).refine(
|
||||
(val) => BigInt(val) > 0n,
|
||||
"Quantity must be positive"
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Query params for removing inventory items.
|
||||
*/
|
||||
export const InventoryRemoveQuerySchema = z.object({
|
||||
amount: z.coerce.number().int().min(1).optional().default(1),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Classes Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Schema for creating a new class.
|
||||
*/
|
||||
export const CreateClassSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
name: z.string().min(1, "Name is required").max(50),
|
||||
balance: z.union([z.string(), z.number()]).optional().default("0"),
|
||||
roleId: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for updating a class.
|
||||
*/
|
||||
export const UpdateClassSchema = z.object({
|
||||
name: z.string().min(1).max(50).optional(),
|
||||
balance: z.union([z.string(), z.number()]).optional(),
|
||||
roleId: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Moderation Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Valid moderation case types.
|
||||
*/
|
||||
export const ModerationTypeEnum = z.enum([
|
||||
"warn",
|
||||
"timeout",
|
||||
"kick",
|
||||
"ban",
|
||||
"note",
|
||||
"prune"
|
||||
]);
|
||||
|
||||
/**
|
||||
* Query parameters for searching moderation cases.
|
||||
*/
|
||||
export const CaseQuerySchema = PaginationSchema.extend({
|
||||
userId: z.string().optional(),
|
||||
moderatorId: z.string().optional(),
|
||||
type: ModerationTypeEnum.optional(),
|
||||
active: z.preprocess(
|
||||
(val) => val === "true" ? true : val === "false" ? false : undefined,
|
||||
z.boolean().optional()
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for creating a moderation case.
|
||||
*/
|
||||
export const CreateCaseSchema = z.object({
|
||||
type: ModerationTypeEnum,
|
||||
userId: z.string().min(1, "User ID is required"),
|
||||
username: z.string().min(1, "Username is required"),
|
||||
moderatorId: z.string().min(1, "Moderator ID is required"),
|
||||
moderatorName: z.string().min(1, "Moderator name is required"),
|
||||
reason: z.string().min(1, "Reason is required").max(1000),
|
||||
metadata: z.record(z.string(), z.any()).optional().default({}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for clearing/resolving a moderation case.
|
||||
*/
|
||||
export const ClearCaseSchema = z.object({
|
||||
clearedBy: z.string().min(1, "Cleared by ID is required"),
|
||||
clearedByName: z.string().min(1, "Cleared by name is required"),
|
||||
reason: z.string().max(500).optional().default("Cleared via API"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Case ID pattern validation (CASE-XXXX format).
|
||||
*/
|
||||
export const CaseIdPattern = /^CASE-\d+$/i;
|
||||
|
||||
// ============================================================================
|
||||
// Transactions Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Query parameters for listing transactions.
|
||||
*/
|
||||
export const TransactionQuerySchema = PaginationSchema.extend({
|
||||
userId: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Lootdrops Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Query parameters for listing lootdrops.
|
||||
*/
|
||||
export const LootdropQuerySchema = z.object({
|
||||
limit: z.coerce.number().min(1).max(100).optional().default(50),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for spawning a lootdrop.
|
||||
*/
|
||||
export const CreateLootdropSchema = z.object({
|
||||
channelId: z.string().min(1, "Channel ID is required"),
|
||||
amount: z.coerce.number().int().positive().optional(),
|
||||
currency: z.string().optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Admin Actions Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Schema for toggling maintenance mode.
|
||||
*/
|
||||
export const MaintenanceModeSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
reason: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Type Exports
|
||||
// ============================================================================
|
||||
|
||||
export type ItemQuery = z.infer<typeof ItemQuerySchema>;
|
||||
export type CreateItem = z.infer<typeof CreateItemSchema>;
|
||||
export type UpdateItem = z.infer<typeof UpdateItemSchema>;
|
||||
export type UserQuery = z.infer<typeof UserQuerySchema>;
|
||||
export type UpdateUser = z.infer<typeof UpdateUserSchema>;
|
||||
export type InventoryAdd = z.infer<typeof InventoryAddSchema>;
|
||||
export type CreateClass = z.infer<typeof CreateClassSchema>;
|
||||
export type UpdateClass = z.infer<typeof UpdateClassSchema>;
|
||||
export type CaseQuery = z.infer<typeof CaseQuerySchema>;
|
||||
export type CreateCase = z.infer<typeof CreateCaseSchema>;
|
||||
export type ClearCase = z.infer<typeof ClearCaseSchema>;
|
||||
export type TransactionQuery = z.infer<typeof TransactionQuerySchema>;
|
||||
export type CreateLootdrop = z.infer<typeof CreateLootdropSchema>;
|
||||
export type MaintenanceMode = z.infer<typeof MaintenanceModeSchema>;
|
||||
152
api/src/routes/settings.routes.ts
Normal file
152
api/src/routes/settings.routes.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* @fileoverview Bot settings endpoints for Aurora API.
|
||||
* Provides endpoints for reading and updating bot configuration,
|
||||
* as well as fetching Discord metadata.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
|
||||
|
||||
/**
|
||||
* Settings routes handler.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/settings - Get current bot configuration
|
||||
* - POST /api/settings - Update bot configuration (partial merge)
|
||||
* - GET /api/settings/meta - Get Discord metadata (roles, channels, commands)
|
||||
*/
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method, req } = ctx;
|
||||
|
||||
// Only handle requests to /api/settings*
|
||||
if (!pathname.startsWith("/api/settings")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @route GET /api/settings
|
||||
* @description Returns the current bot configuration from database.
|
||||
* Configuration includes economy settings, leveling settings,
|
||||
* command toggles, and other system settings.
|
||||
* @response 200 - Full configuration object (DB format with strings for BigInts)
|
||||
* @response 500 - Error fetching settings
|
||||
*
|
||||
* @example
|
||||
* // Response
|
||||
* {
|
||||
* "economy": { "daily": { "amount": "100", "streakBonus": "10" } },
|
||||
* "leveling": { "base": 100, "exponent": 1.5 },
|
||||
* "commands": { "disabled": [], "channelLocks": {} }
|
||||
* }
|
||||
*/
|
||||
if (pathname === "/api/settings" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
|
||||
const settings = await gameSettingsService.getSettings();
|
||||
|
||||
if (!settings) {
|
||||
// Return defaults if no settings in DB yet
|
||||
return jsonResponse(gameSettingsService.getDefaults());
|
||||
}
|
||||
|
||||
return jsonResponse(settings);
|
||||
}, "fetch settings");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route POST /api/settings
|
||||
* @description Updates bot configuration with partial merge.
|
||||
* Only the provided fields will be updated; other settings remain unchanged.
|
||||
* After updating, commands are automatically reloaded.
|
||||
*
|
||||
* @body Partial configuration object (DB format with strings for BigInts)
|
||||
* @response 200 - `{ success: true }`
|
||||
* @response 400 - Validation error
|
||||
* @response 500 - Error saving settings
|
||||
*
|
||||
* @example
|
||||
* // Request - Only update economy daily reward
|
||||
* POST /api/settings
|
||||
* { "economy": { "daily": { "amount": "150" } } }
|
||||
*/
|
||||
if (pathname === "/api/settings" && method === "POST") {
|
||||
try {
|
||||
const partialConfig = await req.json() as Record<string, unknown>;
|
||||
const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
|
||||
|
||||
// Use upsertSettings to merge partial update
|
||||
await gameSettingsService.upsertSettings(partialConfig as Record<string, unknown>);
|
||||
|
||||
const { systemEvents, EVENTS } = await import("@shared/lib/events");
|
||||
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
|
||||
|
||||
return jsonResponse({ success: true });
|
||||
} catch (error) {
|
||||
// Return 400 for validation errors
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return errorResponse("Failed to save settings", 400, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @route GET /api/settings/meta
|
||||
* @description Returns Discord server metadata for settings UI.
|
||||
* Provides lists of roles, channels, and registered commands.
|
||||
*
|
||||
* @response 200 - `{ roles: Role[], channels: Channel[], commands: Command[] }`
|
||||
* @response 500 - Error fetching metadata
|
||||
*
|
||||
* @example
|
||||
* // Response
|
||||
* {
|
||||
* "roles": [
|
||||
* { "id": "123456789", "name": "Admin", "color": "#FF0000" }
|
||||
* ],
|
||||
* "channels": [
|
||||
* { "id": "987654321", "name": "general", "type": 0 }
|
||||
* ],
|
||||
* "commands": [
|
||||
* { "name": "daily", "category": "economy" }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
if (pathname === "/api/settings/meta" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const { AuroraClient } = await import("../../../bot/lib/BotClient");
|
||||
const { env } = await import("@shared/lib/env");
|
||||
|
||||
if (!env.DISCORD_GUILD_ID) {
|
||||
return jsonResponse({ roles: [], channels: [], commands: [] });
|
||||
}
|
||||
|
||||
const guild = AuroraClient.guilds.cache.get(env.DISCORD_GUILD_ID);
|
||||
if (!guild) {
|
||||
return jsonResponse({ roles: [], channels: [], commands: [] });
|
||||
}
|
||||
|
||||
// Map roles and channels to a simplified format
|
||||
const roles = guild.roles.cache
|
||||
.sort((a, b) => b.position - a.position)
|
||||
.map(r => ({ id: r.id, name: r.name, color: r.hexColor }));
|
||||
|
||||
const channels = guild.channels.cache
|
||||
.map(c => ({ id: c.id, name: c.name, type: c.type }));
|
||||
|
||||
const commands = Array.from(AuroraClient.knownCommands.entries())
|
||||
.map(([name, category]) => ({ name, category }))
|
||||
.sort((a, b) => {
|
||||
if (a.category !== b.category) return a.category.localeCompare(b.category);
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return jsonResponse({ guildId: env.DISCORD_GUILD_ID, roles, channels, commands });
|
||||
}, "fetch settings meta");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const settingsRoutes: RouteModule = {
|
||||
name: "settings",
|
||||
handler
|
||||
};
|
||||
94
api/src/routes/stats.helper.ts
Normal file
94
api/src/routes/stats.helper.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @fileoverview Dashboard stats helper for Aurora API.
|
||||
* Provides the getFullDashboardStats function used by stats routes.
|
||||
*/
|
||||
|
||||
import { logger } from "@shared/lib/logger";
|
||||
|
||||
/**
|
||||
* Fetches comprehensive dashboard statistics.
|
||||
* Aggregates data from multiple services with error isolation.
|
||||
*
|
||||
* @returns Full dashboard stats object including bot info, user counts,
|
||||
* economy data, leaderboards, and system status.
|
||||
*/
|
||||
export async function getFullDashboardStats() {
|
||||
// Import services (dynamic to avoid circular deps)
|
||||
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||
const { getClientStats } = await import("../../../bot/lib/clientStats");
|
||||
|
||||
// Fetch all data in parallel with error isolation
|
||||
const results = await Promise.allSettled([
|
||||
Promise.resolve(getClientStats()),
|
||||
dashboardService.getActiveUserCount(),
|
||||
dashboardService.getTotalUserCount(),
|
||||
dashboardService.getEconomyStats(),
|
||||
dashboardService.getRecentEvents(10),
|
||||
dashboardService.getTotalItems(),
|
||||
dashboardService.getActiveLootdrops(),
|
||||
dashboardService.getLeaderboards(),
|
||||
Promise.resolve(lootdropService.getLootdropState()),
|
||||
]);
|
||||
|
||||
// Helper to unwrap result or return default
|
||||
const unwrap = <T>(result: PromiseSettledResult<T>, defaultValue: T, name: string): T => {
|
||||
if (result.status === 'fulfilled') return result.value;
|
||||
logger.error("web", `Failed to fetch ${name}`, result.reason);
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
const clientStats = unwrap(results[0], {
|
||||
bot: { name: 'Aurora', avatarUrl: null, status: null },
|
||||
guilds: 0,
|
||||
commandsRegistered: 0,
|
||||
commandsKnown: 0,
|
||||
cachedUsers: 0,
|
||||
ping: 0,
|
||||
uptime: 0,
|
||||
lastCommandTimestamp: null
|
||||
}, 'clientStats');
|
||||
|
||||
const activeUsers = unwrap(results[1], 0, 'activeUsers');
|
||||
const totalUsers = unwrap(results[2], 0, 'totalUsers');
|
||||
const economyStats = unwrap(results[3], { totalWealth: 0n, avgLevel: 0, topStreak: 0 }, 'economyStats');
|
||||
const recentEvents = unwrap(results[4], [], 'recentEvents');
|
||||
const totalItems = unwrap(results[5], 0, 'totalItems');
|
||||
const activeLootdrops = unwrap(results[6], [], 'activeLootdrops');
|
||||
const leaderboards = unwrap(results[7], { topLevels: [], topWealth: [], topNetWorth: [] }, 'leaderboards');
|
||||
const lootdropState = unwrap(results[8], undefined, 'lootdropState');
|
||||
|
||||
return {
|
||||
bot: clientStats.bot,
|
||||
guilds: { count: clientStats.guilds },
|
||||
users: { active: activeUsers, total: totalUsers },
|
||||
commands: {
|
||||
total: clientStats.commandsKnown,
|
||||
active: clientStats.commandsRegistered,
|
||||
disabled: clientStats.commandsKnown - clientStats.commandsRegistered
|
||||
},
|
||||
ping: { avg: clientStats.ping },
|
||||
economy: {
|
||||
totalWealth: economyStats.totalWealth.toString(),
|
||||
avgLevel: economyStats.avgLevel,
|
||||
topStreak: economyStats.topStreak,
|
||||
totalItems,
|
||||
},
|
||||
recentEvents: recentEvents.map(event => ({
|
||||
...event,
|
||||
timestamp: event.timestamp instanceof Date ? event.timestamp.toISOString() : event.timestamp,
|
||||
})),
|
||||
activeLootdrops: activeLootdrops.map(drop => ({
|
||||
rewardAmount: drop.rewardAmount,
|
||||
currency: drop.currency,
|
||||
createdAt: drop.createdAt.toISOString(),
|
||||
expiresAt: drop.expiresAt ? drop.expiresAt.toISOString() : null,
|
||||
// Explicitly excluding channelId/messageId to prevent sniping
|
||||
})),
|
||||
lootdropState,
|
||||
leaderboards,
|
||||
uptime: clientStats.uptime,
|
||||
lastCommandTimestamp: clientStats.lastCommandTimestamp,
|
||||
maintenanceMode: (await import("../../../bot/lib/BotClient")).AuroraClient.maintenanceMode,
|
||||
};
|
||||
}
|
||||
85
api/src/routes/stats.routes.ts
Normal file
85
api/src/routes/stats.routes.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @fileoverview Statistics endpoints for Aurora API.
|
||||
* Provides dashboard statistics and activity aggregation data.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
|
||||
|
||||
// Cache for activity stats (heavy aggregation)
|
||||
let activityPromise: Promise<import("@shared/modules/dashboard/dashboard.types").ActivityData[]> | null = null;
|
||||
let lastActivityFetch: number = 0;
|
||||
const ACTIVITY_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Stats routes handler.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/stats - Full dashboard statistics
|
||||
* - GET /api/stats/activity - Activity aggregation with caching
|
||||
*/
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method } = ctx;
|
||||
|
||||
/**
|
||||
* @route GET /api/stats
|
||||
* @description Returns comprehensive dashboard statistics including
|
||||
* bot info, user counts, economy data, and leaderboards.
|
||||
* @response 200 - Full dashboard stats object
|
||||
* @response 500 - Error fetching statistics
|
||||
*/
|
||||
if (pathname === "/api/stats" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
// Import the stats function from wherever it's defined
|
||||
// This will be passed in during initialization
|
||||
const { getFullDashboardStats } = await import("./stats.helper.ts");
|
||||
const stats = await getFullDashboardStats();
|
||||
return jsonResponse(stats);
|
||||
}, "fetch dashboard stats");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route GET /api/stats/activity
|
||||
* @description Returns activity aggregation data with 5-minute caching.
|
||||
* Heavy query, results are cached to reduce database load.
|
||||
* @response 200 - Array of activity data points
|
||||
* @response 500 - Error fetching activity statistics
|
||||
*
|
||||
* @example
|
||||
* // Response
|
||||
* [
|
||||
* { "date": "2024-02-08", "commands": 150, "users": 25 },
|
||||
* { "date": "2024-02-07", "commands": 200, "users": 30 }
|
||||
* ]
|
||||
*/
|
||||
if (pathname === "/api/stats/activity" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const now = Date.now();
|
||||
|
||||
// If we have a valid cache, return it
|
||||
if (activityPromise && (now - lastActivityFetch < ACTIVITY_CACHE_TTL)) {
|
||||
const data = await activityPromise;
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
// Otherwise, trigger a new fetch (deduplicated by the promise)
|
||||
if (!activityPromise || (now - lastActivityFetch >= ACTIVITY_CACHE_TTL)) {
|
||||
activityPromise = (async () => {
|
||||
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||
return await dashboardService.getActivityAggregation();
|
||||
})();
|
||||
lastActivityFetch = now;
|
||||
}
|
||||
|
||||
const activity = await activityPromise;
|
||||
return jsonResponse(activity);
|
||||
}, "fetch activity stats");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const statsRoutes: RouteModule = {
|
||||
name: "stats",
|
||||
handler
|
||||
};
|
||||
91
api/src/routes/transactions.routes.ts
Normal file
91
api/src/routes/transactions.routes.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @fileoverview Transaction listing endpoints for Aurora API.
|
||||
* Provides read access to economy transaction history.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import { jsonResponse, withErrorHandling } from "./utils";
|
||||
|
||||
/**
|
||||
* Transactions routes handler.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/transactions - List transactions with filters
|
||||
*/
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method, url } = ctx;
|
||||
|
||||
/**
|
||||
* @route GET /api/transactions
|
||||
* @description Returns economy transactions with optional filtering.
|
||||
*
|
||||
* @query userId - Filter by user ID (Discord snowflake)
|
||||
* @query type - Filter by transaction type
|
||||
* @query limit - Max results (default: 50)
|
||||
* @query offset - Pagination offset (default: 0)
|
||||
*
|
||||
* @response 200 - `{ transactions: Transaction[] }`
|
||||
* @response 500 - Error fetching transactions
|
||||
*
|
||||
* Transaction Types:
|
||||
* - DAILY_REWARD - Daily claim reward
|
||||
* - TRANSFER_IN - Received from another user
|
||||
* - TRANSFER_OUT - Sent to another user
|
||||
* - LOOTDROP_CLAIM - Claimed lootdrop
|
||||
* - SHOP_BUY - Item purchase
|
||||
* - QUEST_REWARD - Quest completion reward
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* GET /api/transactions?userId=123456789&type=DAILY_REWARD&limit=10
|
||||
*
|
||||
* // Response
|
||||
* {
|
||||
* "transactions": [
|
||||
* {
|
||||
* "id": "1",
|
||||
* "userId": "123456789",
|
||||
* "amount": "100",
|
||||
* "type": "DAILY_REWARD",
|
||||
* "description": "Daily reward (Streak: 3)",
|
||||
* "createdAt": "2024-01-15T12:00:00Z"
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
if (pathname === "/api/transactions" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const { transactions } = await import("@shared/db/schema");
|
||||
const { DrizzleClient } = await import("@shared/db/DrizzleClient");
|
||||
const { eq, desc } = await import("drizzle-orm");
|
||||
|
||||
const userId = url.searchParams.get("userId");
|
||||
const type = url.searchParams.get("type");
|
||||
const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
|
||||
const offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
|
||||
|
||||
let query = DrizzleClient.select().from(transactions);
|
||||
|
||||
if (userId) {
|
||||
query = query.where(eq(transactions.userId, BigInt(userId))) as typeof query;
|
||||
}
|
||||
if (type) {
|
||||
query = query.where(eq(transactions.type, type)) as typeof query;
|
||||
}
|
||||
|
||||
const result = await query
|
||||
.orderBy(desc(transactions.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return jsonResponse({ transactions: result });
|
||||
}, "fetch transactions");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const transactionsRoutes: RouteModule = {
|
||||
name: "transactions",
|
||||
handler
|
||||
};
|
||||
94
api/src/routes/types.ts
Normal file
94
api/src/routes/types.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @fileoverview Shared types for the Aurora API routing system.
|
||||
* Provides type definitions for route handlers, responses, and errors.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Standard API error response structure.
|
||||
*/
|
||||
export interface ApiErrorResponse {
|
||||
error: string;
|
||||
details?: string;
|
||||
issues?: Array<{ path: (string | number)[]; message: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard API success response with optional data wrapper.
|
||||
*/
|
||||
export interface ApiSuccessResponse<T = unknown> {
|
||||
success: true;
|
||||
[key: string]: T | true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route context passed to all route handlers.
|
||||
* Contains parsed URL information and the original request.
|
||||
*/
|
||||
export interface RouteContext {
|
||||
/** The original HTTP request */
|
||||
req: Request;
|
||||
/** Parsed URL object */
|
||||
url: URL;
|
||||
/** HTTP method (GET, POST, PUT, DELETE, etc.) */
|
||||
method: string;
|
||||
/** URL pathname without query string */
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A route handler function that processes a request and returns a response.
|
||||
* Returns null if the route doesn't match, allowing the next handler to try.
|
||||
*/
|
||||
export type RouteHandler = (ctx: RouteContext) => Promise<Response | null> | Response | null;
|
||||
|
||||
/**
|
||||
* A route module that exports a handler function.
|
||||
*/
|
||||
export interface RouteModule {
|
||||
/** Human-readable name for debugging */
|
||||
name: string;
|
||||
/** The route handler function */
|
||||
handler: RouteHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom API error class with HTTP status code support.
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number = 500,
|
||||
public readonly details?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a 400 Bad Request error.
|
||||
*/
|
||||
static badRequest(message: string, details?: string): ApiError {
|
||||
return new ApiError(message, 400, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a 404 Not Found error.
|
||||
*/
|
||||
static notFound(resource: string): ApiError {
|
||||
return new ApiError(`${resource} not found`, 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a 409 Conflict error.
|
||||
*/
|
||||
static conflict(message: string): ApiError {
|
||||
return new ApiError(message, 409);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a 500 Internal Server Error.
|
||||
*/
|
||||
static internal(message: string, details?: string): ApiError {
|
||||
return new ApiError(message, 500, details);
|
||||
}
|
||||
}
|
||||
140
api/src/routes/users.routes.test.ts
Normal file
140
api/src/routes/users.routes.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
|
||||
let currentSession: { discordId: string; username: string; role: "admin" | "player"; expiresAt: number } | null = null;
|
||||
|
||||
const getUserById = mock(async (id: string) => ({
|
||||
id,
|
||||
username: id === "123" ? "player-one" : "user",
|
||||
level: 5,
|
||||
xp: 100n,
|
||||
balance: 250n,
|
||||
className: null,
|
||||
}));
|
||||
|
||||
const updateUser = mock(async (id: string, data: Record<string, unknown>) => ({
|
||||
id,
|
||||
...data,
|
||||
}));
|
||||
|
||||
const getInventory = mock(async (id: string) => [{ userId: id, itemId: 1, quantity: 2n }]);
|
||||
const addItem = mock(async (userId: string, itemId: number, quantity: bigint) => ({ userId, itemId, quantity }));
|
||||
const removeItem = mock(async () => undefined);
|
||||
|
||||
mock.module("./auth.routes", () => ({
|
||||
getSession: () => currentSession,
|
||||
}));
|
||||
|
||||
mock.module("@shared/modules/user/user.service", () => ({
|
||||
userService: {
|
||||
getUserById,
|
||||
updateUser,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("@shared/modules/inventory/inventory.service", () => ({
|
||||
inventoryService: {
|
||||
getInventory,
|
||||
addItem,
|
||||
removeItem,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("@shared/lib/logger", () => ({
|
||||
logger: {
|
||||
error: () => { },
|
||||
info: () => { },
|
||||
warn: () => { },
|
||||
debug: () => { },
|
||||
},
|
||||
}));
|
||||
|
||||
import { usersRoutes } from "./users.routes";
|
||||
|
||||
describe("Users Routes", () => {
|
||||
beforeEach(() => {
|
||||
currentSession = {
|
||||
discordId: "123",
|
||||
username: "player",
|
||||
role: "player",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
};
|
||||
getUserById.mockClear();
|
||||
updateUser.mockClear();
|
||||
getInventory.mockClear();
|
||||
addItem.mockClear();
|
||||
removeItem.mockClear();
|
||||
});
|
||||
|
||||
it("serves the authenticated user through /api/me", async () => {
|
||||
const url = new URL("http://localhost/api/me");
|
||||
const res = await usersRoutes.handler({
|
||||
req: new Request(url, { method: "GET" }),
|
||||
url,
|
||||
method: "GET",
|
||||
pathname: "/api/me",
|
||||
});
|
||||
|
||||
expect(res?.status).toBe(200);
|
||||
expect(getUserById).toHaveBeenCalledWith("123");
|
||||
});
|
||||
|
||||
it("serves the authenticated user's inventory through /api/me/inventory", async () => {
|
||||
const url = new URL("http://localhost/api/me/inventory");
|
||||
const res = await usersRoutes.handler({
|
||||
req: new Request(url, { method: "GET" }),
|
||||
url,
|
||||
method: "GET",
|
||||
pathname: "/api/me/inventory",
|
||||
});
|
||||
|
||||
expect(res?.status).toBe(200);
|
||||
expect(getInventory).toHaveBeenCalledWith("123");
|
||||
});
|
||||
|
||||
it("validates user updates before calling the service", async () => {
|
||||
const url = new URL("http://localhost/api/users/123");
|
||||
const res = await usersRoutes.handler({
|
||||
req: new Request(url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ level: -1 }),
|
||||
}),
|
||||
url,
|
||||
method: "PUT",
|
||||
pathname: "/api/users/123",
|
||||
});
|
||||
|
||||
expect(res?.status).toBe(400);
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates inventory additions before calling the service", async () => {
|
||||
const url = new URL("http://localhost/api/users/123/inventory");
|
||||
const res = await usersRoutes.handler({
|
||||
req: new Request(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ itemId: 1, quantity: 0 }),
|
||||
}),
|
||||
url,
|
||||
method: "POST",
|
||||
pathname: "/api/users/123/inventory",
|
||||
});
|
||||
|
||||
expect(res?.status).toBe(400);
|
||||
expect(addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates inventory removal query params before calling the service", async () => {
|
||||
const url = new URL("http://localhost/api/users/123/inventory/1?amount=0");
|
||||
const res = await usersRoutes.handler({
|
||||
req: new Request(url, { method: "DELETE" }),
|
||||
url,
|
||||
method: "DELETE",
|
||||
pathname: "/api/users/123/inventory/1",
|
||||
});
|
||||
|
||||
expect(res?.status).toBe(400);
|
||||
expect(removeItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
302
api/src/routes/users.routes.ts
Normal file
302
api/src/routes/users.routes.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* @fileoverview User management endpoints for Aurora API.
|
||||
* Provides CRUD operations for users and user inventory.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import {
|
||||
jsonResponse,
|
||||
errorResponse,
|
||||
parseBody,
|
||||
parseQuery,
|
||||
parseStringIdFromPath,
|
||||
withErrorHandling
|
||||
} from "./utils";
|
||||
import { InventoryAddSchema, InventoryRemoveQuerySchema, UpdateUserSchema, UserQuerySchema } from "./schemas";
|
||||
import { getSession } from "./auth.routes";
|
||||
|
||||
/**
|
||||
* Users routes handler.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/me - Get current authenticated user
|
||||
* - GET /api/me/inventory - Get current authenticated user's inventory
|
||||
* - GET /api/users - List users with filters
|
||||
* - GET /api/users/:id - Get single user
|
||||
* - PUT /api/users/:id - Update user
|
||||
* - GET /api/users/:id/inventory - Get user inventory
|
||||
* - POST /api/users/:id/inventory - Add item to inventory
|
||||
* - DELETE /api/users/:id/inventory/:itemId - Remove item from inventory
|
||||
*/
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method, req, url } = ctx;
|
||||
|
||||
// Only handle requests to /api/users*
|
||||
if (!pathname.startsWith("/api/users")) {
|
||||
if (pathname === "/api/me" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const session = getSession(req);
|
||||
if (!session) {
|
||||
return errorResponse("Unauthorized", 401);
|
||||
}
|
||||
|
||||
const { userService } = await import("@shared/modules/user/user.service");
|
||||
const user = await userService.getUserById(session.discordId);
|
||||
|
||||
if (!user) {
|
||||
return errorResponse("User not found", 404);
|
||||
}
|
||||
|
||||
return jsonResponse(user);
|
||||
}, "fetch current user");
|
||||
}
|
||||
|
||||
if (pathname === "/api/me/inventory" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const session = getSession(req);
|
||||
if (!session) {
|
||||
return errorResponse("Unauthorized", 401);
|
||||
}
|
||||
|
||||
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
|
||||
const inventory = await inventoryService.getInventory(session.discordId);
|
||||
return jsonResponse({ inventory });
|
||||
}, "fetch current user inventory");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @route GET /api/users
|
||||
* @description Returns a paginated list of users with optional filtering and sorting.
|
||||
*
|
||||
* @query search - Filter by username (partial match)
|
||||
* @query sortBy - Sort field: balance, level, xp, username (default: balance)
|
||||
* @query sortOrder - Sort direction: asc, desc (default: desc)
|
||||
* @query limit - Max results (default: 50)
|
||||
* @query offset - Pagination offset (default: 0)
|
||||
*
|
||||
* @response 200 - `{ users: User[], total: number }`
|
||||
* @response 500 - Error fetching users
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* GET /api/users?sortBy=level&sortOrder=desc&limit=10
|
||||
*/
|
||||
if (pathname === "/api/users" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const { users } = await import("@shared/db/schema");
|
||||
const { DrizzleClient } = await import("@shared/db/DrizzleClient");
|
||||
const { ilike, desc, asc, sql } = await import("drizzle-orm");
|
||||
const queryParams = parseQuery(url, UserQuerySchema);
|
||||
if (queryParams instanceof Response) {
|
||||
return queryParams;
|
||||
}
|
||||
|
||||
const { search, sortBy, sortOrder, limit, offset } = queryParams;
|
||||
|
||||
let query = DrizzleClient.select().from(users);
|
||||
|
||||
if (search) {
|
||||
query = query.where(ilike(users.username, `%${search}%`)) as typeof query;
|
||||
}
|
||||
|
||||
const sortColumn = sortBy === "level" ? users.level :
|
||||
sortBy === "xp" ? users.xp :
|
||||
sortBy === "username" ? users.username : users.balance;
|
||||
const orderFn = sortOrder === "asc" ? asc : desc;
|
||||
|
||||
const result = await query
|
||||
.orderBy(orderFn(sortColumn))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const countResult = await DrizzleClient.select({ count: sql<number>`count(*)` }).from(users);
|
||||
const total = Number(countResult[0]?.count || 0);
|
||||
|
||||
return jsonResponse({ users: result, total });
|
||||
}, "fetch users");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route GET /api/users/:id
|
||||
* @description Returns a single user by Discord ID.
|
||||
* Includes related class information if the user has a class assigned.
|
||||
*
|
||||
* @param id - Discord User ID (snowflake)
|
||||
* @response 200 - Full user object with class relation
|
||||
* @response 404 - User not found
|
||||
* @response 500 - Error fetching user
|
||||
*
|
||||
* @example
|
||||
* // Response
|
||||
* {
|
||||
* "id": "123456789012345678",
|
||||
* "username": "Player1",
|
||||
* "balance": "1000",
|
||||
* "level": 5,
|
||||
* "class": { "id": "1", "name": "Warrior" }
|
||||
* }
|
||||
*/
|
||||
if (pathname.match(/^\/api\/users\/\d+$/) && method === "GET") {
|
||||
const id = parseStringIdFromPath(pathname);
|
||||
if (!id) return null;
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const { userService } = await import("@shared/modules/user/user.service");
|
||||
const user = await userService.getUserById(id);
|
||||
|
||||
if (!user) {
|
||||
return errorResponse("User not found", 404);
|
||||
}
|
||||
|
||||
return jsonResponse(user);
|
||||
}, "fetch user");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route PUT /api/users/:id
|
||||
* @description Updates user fields. Only provided fields will be updated.
|
||||
*
|
||||
* @param id - Discord User ID (snowflake)
|
||||
* @body {
|
||||
* username?: string,
|
||||
* balance?: string | number,
|
||||
* xp?: string | number,
|
||||
* level?: number,
|
||||
* dailyStreak?: number,
|
||||
* isActive?: boolean,
|
||||
* settings?: object,
|
||||
* classId?: string | number
|
||||
* }
|
||||
* @response 200 - `{ success: true, user: User }`
|
||||
* @response 404 - User not found
|
||||
* @response 500 - Error updating user
|
||||
*/
|
||||
if (pathname.match(/^\/api\/users\/\d+$/) && method === "PUT") {
|
||||
const id = parseStringIdFromPath(pathname);
|
||||
if (!id) return null;
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const { userService } = await import("@shared/modules/user/user.service");
|
||||
const parsed = await parseBody(req, UpdateUserSchema);
|
||||
if (parsed instanceof Response) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const existing = await userService.getUserById(id);
|
||||
if (!existing) {
|
||||
return errorResponse("User not found", 404);
|
||||
}
|
||||
|
||||
// Build update data (only allow safe fields)
|
||||
const updateData: any = {};
|
||||
if (parsed.username !== undefined) updateData.username = parsed.username;
|
||||
if (parsed.balance !== undefined) updateData.balance = BigInt(parsed.balance);
|
||||
if (parsed.xp !== undefined) updateData.xp = BigInt(parsed.xp);
|
||||
if (parsed.level !== undefined) updateData.level = parsed.level;
|
||||
if (parsed.dailyStreak !== undefined) updateData.dailyStreak = parsed.dailyStreak;
|
||||
if (parsed.isActive !== undefined) updateData.isActive = parsed.isActive;
|
||||
if (parsed.settings !== undefined) updateData.settings = parsed.settings;
|
||||
if (parsed.classId !== undefined) {
|
||||
updateData.classId = parsed.classId === null ? null : BigInt(parsed.classId);
|
||||
}
|
||||
|
||||
const updatedUser = await userService.updateUser(id, updateData);
|
||||
return jsonResponse({ success: true, user: updatedUser });
|
||||
}, "update user");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route GET /api/users/:id/inventory
|
||||
* @description Returns user's inventory with item details.
|
||||
*
|
||||
* @param id - Discord User ID (snowflake)
|
||||
* @response 200 - `{ inventory: InventoryEntry[] }`
|
||||
* @response 500 - Error fetching inventory
|
||||
*
|
||||
* @example
|
||||
* // Response
|
||||
* {
|
||||
* "inventory": [
|
||||
* {
|
||||
* "userId": "123456789",
|
||||
* "itemId": 1,
|
||||
* "quantity": "5",
|
||||
* "item": { "id": 1, "name": "Health Potion", ... }
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
if (pathname.match(/^\/api\/users\/\d+\/inventory$/) && method === "GET") {
|
||||
const id = pathname.split("/")[3] || "0";
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
|
||||
const inventory = await inventoryService.getInventory(id);
|
||||
return jsonResponse({ inventory });
|
||||
}, "fetch inventory");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route POST /api/users/:id/inventory
|
||||
* @description Adds an item to user's inventory.
|
||||
*
|
||||
* @param id - Discord User ID (snowflake)
|
||||
* @body { itemId: number, quantity: string | number }
|
||||
* @response 201 - `{ success: true, entry: InventoryEntry }`
|
||||
* @response 400 - Missing required fields
|
||||
* @response 500 - Error adding item
|
||||
*/
|
||||
if (pathname.match(/^\/api\/users\/\d+\/inventory$/) && method === "POST") {
|
||||
const id = pathname.split("/")[3] || "0";
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
|
||||
const parsed = await parseBody(req, InventoryAddSchema);
|
||||
if (parsed instanceof Response) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const entry = await inventoryService.addItem(id, parsed.itemId, BigInt(parsed.quantity));
|
||||
return jsonResponse({ success: true, entry }, 201);
|
||||
}, "add item to inventory");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route DELETE /api/users/:id/inventory/:itemId
|
||||
* @description Removes an item from user's inventory.
|
||||
*
|
||||
* @param id - Discord User ID (snowflake)
|
||||
* @param itemId - Item ID to remove
|
||||
* @query amount - Quantity to remove (default: 1)
|
||||
* @response 204 - Item removed (no content)
|
||||
* @response 500 - Error removing item
|
||||
*/
|
||||
if (pathname.match(/^\/api\/users\/\d+\/inventory\/\d+$/) && method === "DELETE") {
|
||||
const parts = pathname.split("/");
|
||||
const userId = parts[3] || "";
|
||||
const itemId = parseInt(parts[5] || "0");
|
||||
|
||||
if (!userId) return null;
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
|
||||
const queryParams = parseQuery(url, InventoryRemoveQuerySchema);
|
||||
if (queryParams instanceof Response) {
|
||||
return queryParams;
|
||||
}
|
||||
|
||||
await inventoryService.removeItem(userId, itemId, BigInt(queryParams.amount));
|
||||
return new Response(null, { status: 204 });
|
||||
}, "remove item from inventory");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const usersRoutes: RouteModule = {
|
||||
name: "users",
|
||||
handler
|
||||
};
|
||||
213
api/src/routes/utils.ts
Normal file
213
api/src/routes/utils.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* @fileoverview Utility functions for Aurora API route handlers.
|
||||
* Provides helpers for response formatting, parameter parsing, and validation.
|
||||
*/
|
||||
|
||||
import { z, ZodError, type ZodSchema } from "zod";
|
||||
import type { ApiErrorResponse } from "./types";
|
||||
|
||||
/**
|
||||
* JSON replacer function that handles BigInt serialization.
|
||||
* Converts BigInt values to strings for JSON compatibility.
|
||||
*/
|
||||
export function jsonReplacer(_key: string, value: unknown): unknown {
|
||||
return typeof value === "bigint" ? value.toString() : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a JSON response with proper content-type header and BigInt handling.
|
||||
*
|
||||
* @param data - The data to serialize as JSON
|
||||
* @param status - HTTP status code (default: 200)
|
||||
* @returns A Response object with JSON content
|
||||
*
|
||||
* @example
|
||||
* return jsonResponse({ items: [...], total: 10 });
|
||||
* return jsonResponse({ success: true, item }, 201);
|
||||
*/
|
||||
export function jsonResponse<T>(data: T, status: number = 200): Response {
|
||||
return new Response(JSON.stringify(data, jsonReplacer), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a standardized error response.
|
||||
*
|
||||
* @param error - Error message
|
||||
* @param status - HTTP status code (default: 500)
|
||||
* @param details - Optional additional error details
|
||||
* @returns A Response object with error JSON
|
||||
*
|
||||
* @example
|
||||
* return errorResponse("Item not found", 404);
|
||||
* return errorResponse("Validation failed", 400, "Name is required");
|
||||
*/
|
||||
export function errorResponse(
|
||||
error: string,
|
||||
status: number = 500,
|
||||
details?: string
|
||||
): Response {
|
||||
const body: ApiErrorResponse = { error };
|
||||
if (details) body.details = details;
|
||||
|
||||
return Response.json(body, { status });
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a validation error response from a ZodError.
|
||||
*
|
||||
* @param zodError - The ZodError from a failed parse
|
||||
* @returns A 400 Response with validation issue details
|
||||
*/
|
||||
export function validationErrorResponse(zodError: ZodError): Response {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Invalid payload",
|
||||
issues: zodError.issues.map(issue => ({
|
||||
path: issue.path,
|
||||
message: issue.message
|
||||
}))
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and validates a request body against a Zod schema.
|
||||
*
|
||||
* @param req - The HTTP request
|
||||
* @param schema - Zod schema to validate against
|
||||
* @returns Validated data or an error Response
|
||||
*
|
||||
* @example
|
||||
* const result = await parseBody(req, CreateItemSchema);
|
||||
* if (result instanceof Response) return result; // Validation failed
|
||||
* const data = result; // Type-safe validated data
|
||||
*/
|
||||
export async function parseBody<T extends ZodSchema>(
|
||||
req: Request,
|
||||
schema: T
|
||||
): Promise<z.infer<T> | Response> {
|
||||
try {
|
||||
const rawBody = await req.json();
|
||||
const parsed = schema.safeParse(rawBody);
|
||||
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error);
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
} catch (e) {
|
||||
return errorResponse("Invalid JSON body", 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses query parameters against a Zod schema.
|
||||
*
|
||||
* @param url - The URL containing query parameters
|
||||
* @param schema - Zod schema to validate against
|
||||
* @returns Validated query params or an error Response
|
||||
*/
|
||||
export function parseQuery<T extends ZodSchema>(
|
||||
url: URL,
|
||||
schema: T
|
||||
): z.infer<T> | Response {
|
||||
const params: Record<string, string> = {};
|
||||
url.searchParams.forEach((value, key) => {
|
||||
params[key] = value;
|
||||
});
|
||||
|
||||
const parsed = schema.safeParse(params);
|
||||
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error);
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a numeric ID from a URL path segment.
|
||||
*
|
||||
* @param pathname - The URL pathname
|
||||
* @param position - Position from the end (0 = last segment, 1 = second-to-last, etc.)
|
||||
* @returns The parsed integer ID or null if invalid
|
||||
*
|
||||
* @example
|
||||
* parseIdFromPath("/api/items/123") // returns 123
|
||||
* parseIdFromPath("/api/items/abc") // returns null
|
||||
* parseIdFromPath("/api/users/456/inventory", 1) // returns 456
|
||||
*/
|
||||
export function parseIdFromPath(pathname: string, position: number = 0): number | null {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const segment = segments[segments.length - 1 - position];
|
||||
|
||||
if (!segment) return null;
|
||||
|
||||
const id = parseInt(segment, 10);
|
||||
return isNaN(id) ? null : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a string ID (like Discord snowflake) from a URL path segment.
|
||||
*
|
||||
* @param pathname - The URL pathname
|
||||
* @param position - Position from the end (0 = last segment)
|
||||
* @returns The string ID or null if segment doesn't exist
|
||||
*/
|
||||
export function parseStringIdFromPath(pathname: string, position: number = 0): string | null {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const segment = segments[segments.length - 1 - position];
|
||||
return segment || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a pathname matches a pattern with optional parameter placeholders.
|
||||
*
|
||||
* @param pathname - The actual URL pathname
|
||||
* @param pattern - The pattern to match (use :id for numeric params, :param for string params)
|
||||
* @returns True if the pattern matches
|
||||
*
|
||||
* @example
|
||||
* matchPath("/api/items/123", "/api/items/:id") // true
|
||||
* matchPath("/api/items", "/api/items/:id") // false
|
||||
*/
|
||||
export function matchPath(pathname: string, pattern: string): boolean {
|
||||
const pathParts = pathname.split("/").filter(Boolean);
|
||||
const patternParts = pattern.split("/").filter(Boolean);
|
||||
|
||||
if (pathParts.length !== patternParts.length) return false;
|
||||
|
||||
return patternParts.every((part, i) => {
|
||||
if (part.startsWith(":")) return true; // Matches any value
|
||||
return part === pathParts[i];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an async route handler with consistent error handling.
|
||||
* Catches all errors and returns appropriate error responses.
|
||||
*
|
||||
* @param handler - The async handler function
|
||||
* @param logContext - Context string for error logging
|
||||
* @returns A wrapped handler with error handling
|
||||
*/
|
||||
export function withErrorHandling(
|
||||
handler: () => Promise<Response>,
|
||||
logContext: string
|
||||
): Promise<Response> {
|
||||
return handler().catch((error: unknown) => {
|
||||
// Dynamic import to avoid circular dependencies
|
||||
return import("@shared/lib/logger").then(({ logger }) => {
|
||||
logger.error("web", `Error in ${logContext}`, error);
|
||||
return errorResponse(
|
||||
`Failed to ${logContext.toLowerCase()}`,
|
||||
500,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
445
api/src/server.items.test.ts
Normal file
445
api/src/server.items.test.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
import { describe, test, expect, afterAll, beforeAll, mock } from "bun:test";
|
||||
import type { WebServerInstance } from "./server";
|
||||
import { createWebServer } from "./server";
|
||||
|
||||
/**
|
||||
* Items API Integration Tests
|
||||
*
|
||||
* Tests the full CRUD functionality for the Items management API.
|
||||
* Uses mocked database and service layers.
|
||||
*/
|
||||
|
||||
// --- Mock Types ---
|
||||
interface MockItem {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
rarity: string;
|
||||
type: string;
|
||||
price: bigint | null;
|
||||
iconUrl: string;
|
||||
imageUrl: string;
|
||||
usageData: { consume: boolean; effects: any[] } | null;
|
||||
}
|
||||
|
||||
// --- Mock Data ---
|
||||
let mockItems: MockItem[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Health Potion",
|
||||
description: "Restores health",
|
||||
rarity: "C",
|
||||
type: "CONSUMABLE",
|
||||
price: 100n,
|
||||
iconUrl: "/assets/items/1.png",
|
||||
imageUrl: "/assets/items/1.png",
|
||||
usageData: { consume: true, effects: [] },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Iron Sword",
|
||||
description: "A basic sword",
|
||||
rarity: "R",
|
||||
type: "EQUIPMENT",
|
||||
price: 500n,
|
||||
iconUrl: "/assets/items/2.png",
|
||||
imageUrl: "/assets/items/2.png",
|
||||
usageData: null,
|
||||
},
|
||||
];
|
||||
|
||||
let mockIdCounter = 3;
|
||||
|
||||
// --- Mock Items Service ---
|
||||
mock.module("@shared/modules/items/items.service", () => ({
|
||||
itemsService: {
|
||||
getAllItems: mock(async (filters: any = {}) => {
|
||||
let filtered = [...mockItems];
|
||||
|
||||
if (filters.search) {
|
||||
const search = filters.search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(search) ||
|
||||
(item.description?.toLowerCase().includes(search) ?? false)
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.type) {
|
||||
filtered = filtered.filter((item) => item.type === filters.type);
|
||||
}
|
||||
|
||||
if (filters.rarity) {
|
||||
filtered = filtered.filter((item) => item.rarity === filters.rarity);
|
||||
}
|
||||
|
||||
return {
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
};
|
||||
}),
|
||||
|
||||
getItemById: mock(async (id: number) => {
|
||||
return mockItems.find((item) => item.id === id) ?? null;
|
||||
}),
|
||||
|
||||
isNameTaken: mock(async (name: string, excludeId?: number) => {
|
||||
return mockItems.some(
|
||||
(item) =>
|
||||
item.name.toLowerCase() === name.toLowerCase() &&
|
||||
item.id !== excludeId
|
||||
);
|
||||
}),
|
||||
|
||||
createItem: mock(async (data: any) => {
|
||||
const newItem: MockItem = {
|
||||
id: mockIdCounter++,
|
||||
name: data.name,
|
||||
description: data.description ?? null,
|
||||
rarity: data.rarity ?? "C",
|
||||
type: data.type,
|
||||
price: data.price ?? null,
|
||||
iconUrl: data.iconUrl,
|
||||
imageUrl: data.imageUrl,
|
||||
usageData: data.usageData ?? null,
|
||||
};
|
||||
mockItems.push(newItem);
|
||||
return newItem;
|
||||
}),
|
||||
|
||||
updateItem: mock(async (id: number, data: any) => {
|
||||
const index = mockItems.findIndex((item) => item.id === id);
|
||||
if (index === -1) return null;
|
||||
|
||||
mockItems[index] = { ...mockItems[index], ...data };
|
||||
return mockItems[index];
|
||||
}),
|
||||
|
||||
deleteItem: mock(async (id: number) => {
|
||||
const index = mockItems.findIndex((item) => item.id === id);
|
||||
if (index === -1) return null;
|
||||
|
||||
const [deleted] = mockItems.splice(index, 1);
|
||||
return deleted;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// --- Mock Utilities ---
|
||||
mock.module("@shared/lib/utils", () => ({
|
||||
deepMerge: (target: any, source: any) => ({ ...target, ...source }),
|
||||
jsonReplacer: (key: string, value: any) =>
|
||||
typeof value === "bigint" ? value.toString() : value,
|
||||
}));
|
||||
|
||||
// --- Mock Auth (bypass authentication) ---
|
||||
mock.module("./routes/auth.routes", () => ({
|
||||
authRoutes: { name: "auth", handler: () => null },
|
||||
getSession: () => ({ discordId: "123", username: "testuser", role: "admin", expiresAt: Date.now() + 3600000 }),
|
||||
}));
|
||||
|
||||
// --- Mock Logger ---
|
||||
mock.module("@shared/lib/logger", () => ({
|
||||
logger: {
|
||||
info: () => { },
|
||||
warn: () => { },
|
||||
error: () => { },
|
||||
debug: () => { },
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Items API", () => {
|
||||
const port = 3002;
|
||||
const hostname = "127.0.0.1";
|
||||
const baseUrl = `http://${hostname}:${port}`;
|
||||
let serverInstance: WebServerInstance | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Reset mock data before all tests
|
||||
mockItems = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Health Potion",
|
||||
description: "Restores health",
|
||||
rarity: "C",
|
||||
type: "CONSUMABLE",
|
||||
price: 100n,
|
||||
iconUrl: "/assets/items/1.png",
|
||||
imageUrl: "/assets/items/1.png",
|
||||
usageData: { consume: true, effects: [] },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Iron Sword",
|
||||
description: "A basic sword",
|
||||
rarity: "R",
|
||||
type: "EQUIPMENT",
|
||||
price: 500n,
|
||||
iconUrl: "/assets/items/2.png",
|
||||
imageUrl: "/assets/items/2.png",
|
||||
usageData: null,
|
||||
},
|
||||
];
|
||||
mockIdCounter = 3;
|
||||
|
||||
serverInstance = await createWebServer({ port, hostname });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (serverInstance) {
|
||||
await serverInstance.stop();
|
||||
}
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// GET /api/items Tests
|
||||
// ===========================================
|
||||
describe("GET /api/items", () => {
|
||||
test("should return all items", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = (await response.json()) as { items: MockItem[]; total: number };
|
||||
expect(data.items).toBeInstanceOf(Array);
|
||||
expect(data.total).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test("should filter items by search query", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items?search=potion`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = (await response.json()) as { items: MockItem[]; total: number };
|
||||
expect(data.items.every((item) =>
|
||||
item.name.toLowerCase().includes("potion") ||
|
||||
(item.description?.toLowerCase().includes("potion") ?? false)
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
test("should filter items by type", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items?type=CONSUMABLE`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = (await response.json()) as { items: MockItem[]; total: number };
|
||||
expect(data.items.every((item) => item.type === "CONSUMABLE")).toBe(true);
|
||||
});
|
||||
|
||||
test("should filter items by rarity", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items?rarity=C`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = (await response.json()) as { items: MockItem[]; total: number };
|
||||
expect(data.items.every((item) => item.rarity === "C")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// GET /api/items/:id Tests
|
||||
// ===========================================
|
||||
describe("GET /api/items/:id", () => {
|
||||
test("should return a single item by ID", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items/1`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = (await response.json()) as MockItem;
|
||||
expect(data.id).toBe(1);
|
||||
expect(data.name).toBe("Health Potion");
|
||||
});
|
||||
|
||||
test("should return 404 for non-existent item", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items/9999`);
|
||||
expect(response.status).toBe(404);
|
||||
|
||||
const data = (await response.json()) as { error: string };
|
||||
expect(data.error).toBe("Item not found");
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// POST /api/items Tests
|
||||
// ===========================================
|
||||
describe("POST /api/items", () => {
|
||||
test("should create a new item", async () => {
|
||||
const newItem = {
|
||||
name: "Magic Staff",
|
||||
description: "A powerful staff",
|
||||
rarity: "SR",
|
||||
type: "EQUIPMENT",
|
||||
price: "1000",
|
||||
iconUrl: "/assets/items/placeholder.png",
|
||||
imageUrl: "/assets/items/placeholder.png",
|
||||
};
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/items`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(newItem),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
const data = (await response.json()) as { success: boolean; item: MockItem };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.item.name).toBe("Magic Staff");
|
||||
expect(data.item.id).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("should reject item without required fields", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ description: "No name or type" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const data = (await response.json()) as { error: string };
|
||||
expect(data.error).toContain("required");
|
||||
});
|
||||
|
||||
test("should reject duplicate item name", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: "Health Potion", // Already exists
|
||||
type: "CONSUMABLE",
|
||||
iconUrl: "/assets/items/placeholder.png",
|
||||
imageUrl: "/assets/items/placeholder.png",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
|
||||
const data = (await response.json()) as { error: string };
|
||||
expect(data.error).toContain("already exists");
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// PUT /api/items/:id Tests
|
||||
// ===========================================
|
||||
describe("PUT /api/items/:id", () => {
|
||||
test("should update an existing item", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items/1`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
description: "Updated description",
|
||||
price: "200",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = (await response.json()) as { success: boolean; item: MockItem };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.item.description).toBe("Updated description");
|
||||
});
|
||||
|
||||
test("should return 404 for updating non-existent item", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items/9999`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "New Name" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
test("should reject duplicate name when updating", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items/2`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: "Health Potion", // ID 1 has this name
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// DELETE /api/items/:id Tests
|
||||
// ===========================================
|
||||
describe("DELETE /api/items/:id", () => {
|
||||
test("should delete an existing item", async () => {
|
||||
// First, create an item to delete
|
||||
const createResponse = await fetch(`${baseUrl}/api/items`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: "Item to Delete",
|
||||
type: "MATERIAL",
|
||||
iconUrl: "/assets/items/placeholder.png",
|
||||
imageUrl: "/assets/items/placeholder.png",
|
||||
}),
|
||||
});
|
||||
|
||||
const { item } = (await createResponse.json()) as { item: MockItem };
|
||||
|
||||
// Now delete it
|
||||
const deleteResponse = await fetch(`${baseUrl}/api/items/${item.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
expect(deleteResponse.status).toBe(204);
|
||||
|
||||
// Verify it's gone
|
||||
const getResponse = await fetch(`${baseUrl}/api/items/${item.id}`);
|
||||
expect(getResponse.status).toBe(404);
|
||||
});
|
||||
|
||||
test("should return 404 for deleting non-existent item", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items/9999`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// Static Asset Serving Tests
|
||||
// ===========================================
|
||||
describe("Static Asset Serving (/assets/*)", () => {
|
||||
test("should return 404 for non-existent asset", async () => {
|
||||
const response = await fetch(`${baseUrl}/assets/items/nonexistent.png`);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
test("should prevent path traversal attacks", async () => {
|
||||
// Note: fetch() and HTTP servers normalize ".." segments before the handler sees them,
|
||||
// so we can't send raw traversal paths over HTTP. Instead, test that a suspicious
|
||||
// asset path (with encoded sequences) doesn't serve sensitive file content.
|
||||
const response = await fetch(`${baseUrl}/assets/..%2f..%2f..%2fetc%2fpasswd`);
|
||||
// Should not serve actual file content — expect 403 or 404
|
||||
expect([403, 404]).toContain(response.status);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// Validation Edge Cases
|
||||
// ===========================================
|
||||
describe("Validation Edge Cases", () => {
|
||||
test("should handle empty search query gracefully", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items?search=`);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("should handle invalid pagination values", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items?limit=abc&offset=xyz`);
|
||||
// Should not crash, may use defaults
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("should handle missing content-type header", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "Test", type: "MATERIAL" }),
|
||||
});
|
||||
// May fail due to no content-type, but shouldn't crash
|
||||
expect([200, 201, 400, 415]).toContain(response.status);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,40 +1,64 @@
|
||||
import { describe, expect, it, mock, beforeEach, afterEach, jest } from "bun:test";
|
||||
import { type WebServerInstance } from "./server";
|
||||
|
||||
// Mock the dependencies
|
||||
const mockConfig = {
|
||||
// Mock gameSettingsService — the route now uses this instead of config/saveConfig
|
||||
const mockSettings = {
|
||||
leveling: {
|
||||
base: 100,
|
||||
exponent: 1.5,
|
||||
chat: { minXp: 10, maxXp: 20, cooldownMs: 60000 }
|
||||
},
|
||||
economy: {
|
||||
daily: { amount: 100n, streakBonus: 10n, weeklyBonus: 50n, cooldownMs: 86400000 },
|
||||
transfers: { allowSelfTransfer: false, minAmount: 50n },
|
||||
daily: { amount: "100", streakBonus: "10", weeklyBonus: "50", cooldownMs: 86400000 },
|
||||
transfers: { allowSelfTransfer: false, minAmount: "1" },
|
||||
exam: { multMin: 1.5, multMax: 2.5 }
|
||||
},
|
||||
inventory: { maxStackSize: 99n, maxSlots: 20 },
|
||||
inventory: { maxStackSize: "99", maxSlots: 20 },
|
||||
lootdrop: {
|
||||
spawnChance: 0.1,
|
||||
cooldownMs: 3600000,
|
||||
minMessages: 10,
|
||||
activityWindowMs: 300000,
|
||||
reward: { min: 100, max: 500, currency: "gold" }
|
||||
},
|
||||
commands: { "help": true },
|
||||
system: {},
|
||||
moderation: {
|
||||
prune: { maxAmount: 100, confirmThreshold: 50, batchSize: 100, batchDelayMs: 1000 },
|
||||
cases: { dmOnWarn: true }
|
||||
},
|
||||
trivia: {
|
||||
entryFee: "50",
|
||||
rewardMultiplier: 1.5,
|
||||
timeoutSeconds: 30,
|
||||
cooldownMs: 60000,
|
||||
categories: [],
|
||||
difficulty: "random"
|
||||
}
|
||||
};
|
||||
|
||||
const mockSaveConfig = jest.fn();
|
||||
const mockGetSettings = jest.fn(() => Promise.resolve(mockSettings));
|
||||
const mockUpsertSettings = jest.fn(() => Promise.resolve(mockSettings));
|
||||
const mockGetDefaults = jest.fn(() => mockSettings);
|
||||
|
||||
// Mock @shared/lib/config using mock.module
|
||||
mock.module("@shared/lib/config", () => ({
|
||||
config: mockConfig,
|
||||
saveConfig: mockSaveConfig,
|
||||
GameConfigType: {}
|
||||
mock.module("@shared/modules/game-settings/game-settings.service", () => ({
|
||||
gameSettingsService: {
|
||||
getSettings: mockGetSettings,
|
||||
upsertSettings: mockUpsertSettings,
|
||||
getDefaults: mockGetDefaults,
|
||||
invalidateCache: jest.fn(),
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock DrizzleClient (dependency potentially imported transitively)
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: {}
|
||||
}));
|
||||
|
||||
// Mock @shared/lib/utils (deepMerge is used by settings API)
|
||||
mock.module("@shared/lib/utils", () => ({
|
||||
deepMerge: (target: any, source: any) => ({ ...target, ...source }),
|
||||
jsonReplacer: (key: string, value: any) =>
|
||||
typeof value === "bigint" ? value.toString() : value,
|
||||
}));
|
||||
|
||||
// Mock BotClient
|
||||
@@ -86,17 +110,26 @@ mock.module("bun", () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock auth (bypass authentication)
|
||||
mock.module("./routes/auth.routes", () => ({
|
||||
authRoutes: { name: "auth", handler: () => null },
|
||||
getSession: () => ({ discordId: "123", username: "testuser", role: "admin", expiresAt: Date.now() + 3600000 }),
|
||||
}));
|
||||
|
||||
// Import createWebServer after mocks
|
||||
import { createWebServer } from "./server";
|
||||
|
||||
describe("Settings API", () => {
|
||||
let serverInstance: WebServerInstance;
|
||||
const PORT = 3009;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
const HOSTNAME = "127.0.0.1";
|
||||
const BASE_URL = `http://${HOSTNAME}:${PORT}`;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
serverInstance = await createWebServer({ port: PORT });
|
||||
mockGetSettings.mockImplementation(() => Promise.resolve(mockSettings));
|
||||
mockUpsertSettings.mockImplementation(() => Promise.resolve(mockSettings));
|
||||
serverInstance = await createWebServer({ port: PORT, hostname: HOSTNAME });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -109,18 +142,14 @@ describe("Settings API", () => {
|
||||
const res = await fetch(`${BASE_URL}/api/settings`);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
// Check if BigInts are converted to strings
|
||||
const data = await res.json() as any;
|
||||
// Check values come through correctly
|
||||
expect(data.economy.daily.amount).toBe("100");
|
||||
expect(data.leveling.base).toBe(100);
|
||||
});
|
||||
|
||||
it("POST /api/settings should save valid configuration via merge", async () => {
|
||||
// We only send a partial update, expecting the server to merge it
|
||||
// Note: For now the server implementation might still default to overwrite if we haven't updated it yet.
|
||||
// But the user requested "partial vs full" fix.
|
||||
// Let's assume we implement the merge logic.
|
||||
const partialConfig = { studentRole: "new-role-partial" };
|
||||
const partialConfig = { economy: { daily: { amount: "200" } } };
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/settings`, {
|
||||
method: "POST",
|
||||
@@ -129,26 +158,27 @@ describe("Settings API", () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Expect saveConfig to be called with the MERGED result
|
||||
expect(mockSaveConfig).toHaveBeenCalledWith(expect.objectContaining({
|
||||
studentRole: "new-role-partial",
|
||||
leveling: mockConfig.leveling // Should keep existing values
|
||||
}));
|
||||
// upsertSettings should be called with the partial config
|
||||
expect(mockUpsertSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
economy: { daily: { amount: "200" } }
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /api/settings should return 400 when save fails", async () => {
|
||||
mockSaveConfig.mockImplementationOnce(() => {
|
||||
mockUpsertSettings.mockImplementationOnce(() => {
|
||||
throw new Error("Validation failed");
|
||||
});
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/settings`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}) // Empty might be valid partial, but mocks throw
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
const data = await res.json() as any;
|
||||
expect(data.details).toBe("Validation failed");
|
||||
});
|
||||
|
||||
@@ -156,7 +186,7 @@ describe("Settings API", () => {
|
||||
const res = await fetch(`${BASE_URL}/api/settings/meta`);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
const data = await res.json() as any;
|
||||
expect(data.roles).toHaveLength(2);
|
||||
expect(data.roles[0]).toEqual({ id: "role1", name: "Admin", color: "#ffffff" });
|
||||
expect(data.channels[0]).toEqual({ id: "chan1", name: "general", type: 0 });
|
||||
199
api/src/server.test.ts
Normal file
199
api/src/server.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { beforeEach, describe, test, expect, afterAll, mock } from "bun:test";
|
||||
import type { WebServerInstance } from "./server";
|
||||
|
||||
interface MockBotStats {
|
||||
bot: { name: string; avatarUrl: string | null };
|
||||
guilds: number;
|
||||
ping: number;
|
||||
cachedUsers: number;
|
||||
commandsRegistered: number;
|
||||
uptime: number;
|
||||
lastCommandTimestamp: number | null;
|
||||
}
|
||||
|
||||
// 1. Mock DrizzleClient (dependency of dashboardService)
|
||||
// Must provide full chainable builder for select().from().leftJoin().groupBy().orderBy().limit()
|
||||
mock.module("@shared/db/DrizzleClient", () => {
|
||||
const mockBuilder: Record<string, any> = {};
|
||||
// Every chainable method returns mock builder; terminal calls return resolved promise
|
||||
mockBuilder.where = mock(() => Promise.resolve([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]));
|
||||
mockBuilder.then = (onfulfilled: any) => onfulfilled([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]);
|
||||
mockBuilder.orderBy = mock(() => mockBuilder);
|
||||
mockBuilder.limit = mock(() => Promise.resolve([]));
|
||||
mockBuilder.leftJoin = mock(() => mockBuilder);
|
||||
mockBuilder.groupBy = mock(() => mockBuilder);
|
||||
mockBuilder.from = mock(() => mockBuilder);
|
||||
|
||||
return {
|
||||
DrizzleClient: {
|
||||
select: mock(() => mockBuilder),
|
||||
query: {
|
||||
transactions: { findMany: mock(() => Promise.resolve([])) },
|
||||
moderationCases: { findMany: mock(() => Promise.resolve([])) },
|
||||
users: {
|
||||
findFirst: mock(() => Promise.resolve({ username: "test" })),
|
||||
findMany: mock(() => Promise.resolve([])),
|
||||
},
|
||||
lootdrops: { findMany: mock(() => Promise.resolve([])) },
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// 2. Mock Bot Stats Provider
|
||||
mock.module("../../bot/lib/clientStats", () => ({
|
||||
getClientStats: mock((): MockBotStats => ({
|
||||
bot: { name: "TestBot", avatarUrl: null },
|
||||
guilds: 5,
|
||||
ping: 42,
|
||||
cachedUsers: 100,
|
||||
commandsRegistered: 10,
|
||||
uptime: 3600,
|
||||
lastCommandTimestamp: Date.now(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// 3. Mock config (used by lootdrop.service.getLootdropState)
|
||||
mock.module("@shared/lib/config", () => ({
|
||||
config: {
|
||||
lootdrop: {
|
||||
activityWindowMs: 120000,
|
||||
minMessages: 1,
|
||||
spawnChance: 1,
|
||||
cooldownMs: 3000,
|
||||
reward: { min: 40, max: 150, currency: "Astral Units" }
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
let currentSession: { discordId: string; username: string; role: "admin" | "player"; expiresAt: number } | null = {
|
||||
discordId: "123",
|
||||
username: "admin-user",
|
||||
role: "admin",
|
||||
expiresAt: Date.now() + 3600000,
|
||||
};
|
||||
|
||||
// 4. Mock auth with a mutable session so tests can exercise authz paths.
|
||||
mock.module("./routes/auth.routes", () => ({
|
||||
authRoutes: { name: "auth", handler: () => null },
|
||||
getSession: () => currentSession,
|
||||
}));
|
||||
|
||||
// 5. Mock BotClient (used by stats helper for maintenanceMode)
|
||||
mock.module("../../bot/lib/BotClient", () => ({
|
||||
AuroraClient: {
|
||||
maintenanceMode: false,
|
||||
guilds: { cache: { get: () => null } },
|
||||
commands: [],
|
||||
knownCommands: new Map(),
|
||||
}
|
||||
}));
|
||||
|
||||
// Import after all mocks are set up
|
||||
import { createWebServer } from "./server";
|
||||
|
||||
describe("WebServer Security & Limits", () => {
|
||||
const port = 3001;
|
||||
const hostname = "127.0.0.1";
|
||||
let serverInstance: WebServerInstance | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
currentSession = {
|
||||
discordId: "123",
|
||||
username: "admin-user",
|
||||
role: "admin",
|
||||
expiresAt: Date.now() + 3600000,
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (serverInstance) {
|
||||
await serverInstance.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("should reject unauthorized websocket requests", async () => {
|
||||
serverInstance = await createWebServer({ port, hostname });
|
||||
currentSession = null;
|
||||
|
||||
const response = await fetch(`http://${hostname}:${port}/ws`);
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(body).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
test("should accept websocket requests for authenticated sessions", async () => {
|
||||
if (!serverInstance) {
|
||||
serverInstance = await createWebServer({ port, hostname });
|
||||
}
|
||||
|
||||
const ws = new WebSocket(`ws://${hostname}:${port}/ws`);
|
||||
const opened = await new Promise<boolean>((resolve) => {
|
||||
const timeout = setTimeout(() => resolve(false), 1000);
|
||||
ws.addEventListener("open", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(true);
|
||||
});
|
||||
ws.addEventListener("error", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
|
||||
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||
ws.close();
|
||||
}
|
||||
|
||||
expect(opened).toBe(true);
|
||||
});
|
||||
|
||||
test("should return 200 for health check", async () => {
|
||||
if (!serverInstance) {
|
||||
serverInstance = await createWebServer({ port, hostname });
|
||||
}
|
||||
const response = await fetch(`http://${hostname}:${port}/api/health`);
|
||||
expect(response.status).toBe(200);
|
||||
const data = (await response.json()) as { status: string };
|
||||
expect(data.status).toBe("ok");
|
||||
});
|
||||
|
||||
describe("Administrative Actions", () => {
|
||||
test("should allow administrative actions for admin sessions", async () => {
|
||||
const response = await fetch(`http://${hostname}:${port}/api/actions/reload-commands`, {
|
||||
method: "POST"
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("should reject administrative actions for player sessions", async () => {
|
||||
currentSession = {
|
||||
discordId: "456",
|
||||
username: "player-user",
|
||||
role: "player",
|
||||
expiresAt: Date.now() + 3600000,
|
||||
};
|
||||
|
||||
const response = await fetch(`http://${hostname}:${port}/api/actions/reload-commands`, {
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const data = await response.json() as { error: string };
|
||||
expect(data.error).toBe("Admin access required");
|
||||
});
|
||||
|
||||
test("should reject maintenance mode with invalid payload", async () => {
|
||||
const response = await fetch(`http://${hostname}:${port}/api/actions/maintenance-mode`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ not_enabled: true }) // Wrong field
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json() as { error: string };
|
||||
expect(data.error).toBe("Invalid payload");
|
||||
});
|
||||
});
|
||||
});
|
||||
242
api/src/server.ts
Normal file
242
api/src/server.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* @fileoverview API server factory module.
|
||||
* Exports a function to create and start the API server.
|
||||
* This allows the server to be started in-process from the main application.
|
||||
*
|
||||
* Routes are organized into modular files in the ./routes directory.
|
||||
* Each route module handles its own validation, business logic, and responses.
|
||||
*/
|
||||
|
||||
import { serve, file } from "bun";
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
import { handleRequest } from "./routes";
|
||||
import { getFullDashboardStats } from "./routes/stats.helper";
|
||||
import { join } from "path";
|
||||
import { gameServer } from "./games/GameServer";
|
||||
import type { WsConnectionData } from "./games/GameServer";
|
||||
import { getSession } from "./routes/auth.routes";
|
||||
import { GameWsClientSchema } from "./games/types";
|
||||
|
||||
// Register game plugins
|
||||
import { gameRegistry } from "@shared/games/registry";
|
||||
import { chessPlugin } from "@shared/games/chess/chess.plugin";
|
||||
import { blackjackPlugin } from "@shared/games/blackjack/blackjack.plugin";
|
||||
gameRegistry.register(chessPlugin);
|
||||
gameRegistry.register(blackjackPlugin);
|
||||
|
||||
const WS_CONFIG = {
|
||||
MAX_CONNECTIONS: 200,
|
||||
MAX_PAYLOAD_BYTES: 16384,
|
||||
IDLE_TIMEOUT_SECONDS: 60,
|
||||
STATS_BROADCAST_INTERVAL_MS: 5000,
|
||||
} as const;
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
".html": "text/html",
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
};
|
||||
|
||||
export interface WebServerConfig {
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
export interface WebServerInstance {
|
||||
server: ReturnType<typeof serve>;
|
||||
stop: () => Promise<void>;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve static files from the panel dist directory.
|
||||
* Falls back to index.html for SPA routing.
|
||||
*/
|
||||
async function servePanelStatic(pathname: string, distDir: string): Promise<Response | null> {
|
||||
// Don't serve panel for API/auth/ws/assets routes
|
||||
if (pathname.startsWith("/api/") || pathname.startsWith("/auth/") || pathname === "/ws" || pathname.startsWith("/assets/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to serve the exact file
|
||||
const filePath = join(distDir, pathname);
|
||||
const bunFile = file(filePath);
|
||||
if (await bunFile.exists()) {
|
||||
const ext = pathname.substring(pathname.lastIndexOf("."));
|
||||
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
||||
return new Response(bunFile, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// SPA fallback: serve index.html for all non-file routes
|
||||
const indexFile = file(join(distDir, "index.html"));
|
||||
if (await indexFile.exists()) {
|
||||
return new Response(indexFile, {
|
||||
headers: { "Content-Type": "text/html", "Cache-Control": "no-cache" },
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function createWebServer(config: WebServerConfig = {}): Promise<WebServerInstance> {
|
||||
const { port = 3000, hostname = "localhost" } = config;
|
||||
|
||||
let activeConnections = 0;
|
||||
let statsBroadcastInterval: Timer | undefined;
|
||||
|
||||
const server = serve<WsConnectionData>({
|
||||
port,
|
||||
hostname,
|
||||
async fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (url.pathname === "/ws") {
|
||||
if (activeConnections >= WS_CONFIG.MAX_CONNECTIONS) {
|
||||
logger.warn("web", `Connection rejected: limit reached (${activeConnections}/${WS_CONFIG.MAX_CONNECTIONS})`);
|
||||
return new Response("Connection limit reached", { status: 429 });
|
||||
}
|
||||
|
||||
const session = getSession(req);
|
||||
if (!session) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const success = server.upgrade(req, {
|
||||
data: {
|
||||
session: {
|
||||
discordId: session.discordId,
|
||||
username: session.username,
|
||||
role: session.role,
|
||||
},
|
||||
rooms: new Set<string>(),
|
||||
},
|
||||
});
|
||||
if (success) return undefined;
|
||||
return new Response("WebSocket upgrade failed", { status: 400 });
|
||||
}
|
||||
|
||||
const response = await handleRequest(req, url);
|
||||
if (response) return response;
|
||||
|
||||
const panelDistDir = join(import.meta.dir, "../../panel/dist");
|
||||
const staticResponse = await servePanelStatic(url.pathname, panelDistDir);
|
||||
if (staticResponse) return staticResponse;
|
||||
|
||||
return new Response("Not Found", { status: 404 });
|
||||
},
|
||||
|
||||
websocket: {
|
||||
open(ws: ServerWebSocket<WsConnectionData>) {
|
||||
activeConnections++;
|
||||
ws.subscribe("dashboard");
|
||||
ws.subscribe("lobby");
|
||||
logger.debug("web", `Client connected: ${ws.data.session.discordId}. Total: ${activeConnections}`);
|
||||
|
||||
getFullDashboardStats().then(stats => {
|
||||
ws.send(JSON.stringify({ type: "STATS_UPDATE", data: stats }));
|
||||
});
|
||||
|
||||
gameServer.handleOpen(ws);
|
||||
|
||||
if (!statsBroadcastInterval) {
|
||||
statsBroadcastInterval = setInterval(async () => {
|
||||
try {
|
||||
const stats = await getFullDashboardStats();
|
||||
server.publish("dashboard", JSON.stringify({ type: "STATS_UPDATE", data: stats }));
|
||||
} catch (error) {
|
||||
logger.error("web", "Error in stats broadcast", error);
|
||||
}
|
||||
}, WS_CONFIG.STATS_BROADCAST_INTERVAL_MS);
|
||||
}
|
||||
},
|
||||
|
||||
async message(ws: ServerWebSocket<WsConnectionData>, message) {
|
||||
try {
|
||||
const messageStr = message.toString();
|
||||
|
||||
if (messageStr.length > WS_CONFIG.MAX_PAYLOAD_BYTES) {
|
||||
logger.error("web", "Payload exceeded maximum limit");
|
||||
return;
|
||||
}
|
||||
|
||||
const rawData = JSON.parse(messageStr);
|
||||
|
||||
// Handle dashboard-level messages (PING, etc.)
|
||||
if (rawData && typeof rawData === "object" && rawData.type === "PING") {
|
||||
ws.send(JSON.stringify({ type: "PONG" }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Route game messages — try to parse as a game client message
|
||||
const gameCheck = GameWsClientSchema.safeParse(rawData);
|
||||
if (gameCheck.success) {
|
||||
gameServer.handleMessage(ws, rawData).catch(err =>
|
||||
logger.error("web", `Game message handler error: ${err}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to full WsMessageSchema for dashboard messages that aren't PING
|
||||
const { WsMessageSchema } = await import("@shared/modules/dashboard/dashboard.types");
|
||||
const parsed = WsMessageSchema.safeParse(rawData);
|
||||
if (!parsed?.success) {
|
||||
logger.error("web", "Invalid message format", parsed?.error.issues);
|
||||
}
|
||||
// Nothing else to do for PONG/STATS_UPDATE/NEW_EVENT from clients
|
||||
} catch (e) {
|
||||
logger.error("web", "Failed to handle message", e);
|
||||
}
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<WsConnectionData>) {
|
||||
activeConnections--;
|
||||
ws.unsubscribe("dashboard");
|
||||
ws.unsubscribe("lobby");
|
||||
logger.debug("web", `Client disconnected: ${ws.data.session.discordId}. Total remaining: ${activeConnections}`);
|
||||
|
||||
gameServer.handleClose(ws);
|
||||
|
||||
if (activeConnections === 0 && statsBroadcastInterval) {
|
||||
clearInterval(statsBroadcastInterval);
|
||||
statsBroadcastInterval = undefined;
|
||||
}
|
||||
},
|
||||
maxPayloadLength: WS_CONFIG.MAX_PAYLOAD_BYTES,
|
||||
idleTimeout: WS_CONFIG.IDLE_TIMEOUT_SECONDS,
|
||||
},
|
||||
});
|
||||
|
||||
// Wire gameServer to Bun server for pub/sub publishing
|
||||
gameServer.setServer(server);
|
||||
|
||||
const { systemEvents, EVENTS } = await import("@shared/lib/events");
|
||||
systemEvents.on(EVENTS.DASHBOARD.NEW_EVENT, (event) => {
|
||||
server.publish("dashboard", JSON.stringify({ type: "NEW_EVENT", data: event }));
|
||||
});
|
||||
|
||||
const url = `http://${hostname}:${port}`;
|
||||
|
||||
return {
|
||||
server,
|
||||
url,
|
||||
stop: async () => {
|
||||
if (statsBroadcastInterval) {
|
||||
clearInterval(statsBroadcastInterval);
|
||||
}
|
||||
server.stop(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"DOM"
|
||||
],
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
@@ -38,8 +34,5 @@
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
},
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
0
bot/assets/graphics/items/.gitkeep
Normal file
0
bot/assets/graphics/items/.gitkeep
Normal file
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const moderationCase = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -16,9 +17,9 @@ export const moderationCase = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||
|
||||
// Validate case ID format
|
||||
@@ -30,7 +31,7 @@ export const moderationCase = createCommand({
|
||||
}
|
||||
|
||||
// Get the case
|
||||
const moderationCase = await ModerationService.getCaseById(caseId);
|
||||
const moderationCase = await moderationService.getCaseById(caseId);
|
||||
|
||||
if (!moderationCase) {
|
||||
await interaction.editReply({
|
||||
@@ -43,12 +44,8 @@ export const moderationCase = createCommand({
|
||||
await interaction.editReply({
|
||||
embeds: [getCaseEmbed(moderationCase)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Case command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching the case.")]
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCasesListEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const cases = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -22,14 +23,14 @@ export const cases = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
const activeOnly = interaction.options.getBoolean("active_only") || false;
|
||||
|
||||
// Get cases for the user
|
||||
const userCases = await ModerationService.getUserCases(targetUser.id, activeOnly);
|
||||
const userCases = await moderationService.getUserCases(targetUser.id, activeOnly);
|
||||
|
||||
const title = activeOnly
|
||||
? `⚠️ Active Cases for ${targetUser.username}`
|
||||
@@ -43,12 +44,8 @@ export const cases = createCommand({
|
||||
await interaction.editReply({
|
||||
embeds: [getCasesListEmbed(userCases, title, description)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Cases command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching cases.")]
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const clearwarning = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -23,9 +24,9 @@ export const clearwarning = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||
const reason = interaction.options.getString("reason") || "Cleared by moderator";
|
||||
|
||||
@@ -38,7 +39,7 @@ export const clearwarning = createCommand({
|
||||
}
|
||||
|
||||
// Check if case exists and is active
|
||||
const existingCase = await ModerationService.getCaseById(caseId);
|
||||
const existingCase = await moderationService.getCaseById(caseId);
|
||||
|
||||
if (!existingCase) {
|
||||
await interaction.editReply({
|
||||
@@ -62,7 +63,7 @@ export const clearwarning = createCommand({
|
||||
}
|
||||
|
||||
// Clear the warning
|
||||
await ModerationService.clearCase({
|
||||
await moderationService.clearCase({
|
||||
caseId,
|
||||
clearedBy: interaction.user.id,
|
||||
clearedByName: interaction.user.username,
|
||||
@@ -73,12 +74,8 @@ export const clearwarning = createCommand({
|
||||
await interaction.editReply({
|
||||
embeds: [getClearSuccessEmbed(caseId)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Clear warning command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while clearing the warning.")]
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ModalSubmitInteraction } from "discord.js";
|
||||
import { config, saveConfig } from "@shared/lib/config";
|
||||
import type { GameConfigType } from "@shared/lib/config";
|
||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
||||
|
||||
export const configCommand = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("config")
|
||||
.setDescription("Edit the bot configuration")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
console.log(`Config command executed by ${interaction.user.tag}`);
|
||||
const replacer = (key: string, value: any) => {
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const currentConfigJson = JSON.stringify(config, replacer, 4);
|
||||
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId("config-modal")
|
||||
.setTitle("Edit Configuration");
|
||||
|
||||
const jsonInput = new TextInputBuilder()
|
||||
.setCustomId("json-input")
|
||||
.setLabel("Configuration JSON")
|
||||
.setStyle(TextInputStyle.Paragraph)
|
||||
.setValue(currentConfigJson)
|
||||
.setRequired(true);
|
||||
|
||||
const actionRow = new ActionRowBuilder<TextInputBuilder>().addComponents(jsonInput);
|
||||
modal.addComponents(actionRow);
|
||||
|
||||
await interaction.showModal(modal);
|
||||
|
||||
try {
|
||||
const submitted = await interaction.awaitModalSubmit({
|
||||
time: 300000, // 5 minutes
|
||||
filter: (i) => i.customId === "config-modal" && i.user.id === interaction.user.id
|
||||
});
|
||||
|
||||
const jsonString = submitted.fields.getTextInputValue("json-input");
|
||||
|
||||
try {
|
||||
const newConfig = JSON.parse(jsonString);
|
||||
saveConfig(newConfig as GameConfigType);
|
||||
|
||||
await submitted.reply({
|
||||
embeds: [createSuccessEmbed("Configuration updated successfully.", "Config Saved")]
|
||||
});
|
||||
} catch (parseError) {
|
||||
await submitted.reply({
|
||||
embeds: [createErrorEmbed(`Invalid JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`, "Config Update Failed")],
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Timeout or other error handling if needed, usually just ignore timeouts for modals
|
||||
if (error instanceof Error && error.message.includes('time')) {
|
||||
// specific timeout handling if desired
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js";
|
||||
import { config, saveConfig } from "@shared/lib/config";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
|
||||
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { items } from "@db/schema";
|
||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const createColor = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -31,8 +33,9 @@ export const createColor = createCommand({
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const name = interaction.options.getString("name", true);
|
||||
const colorInput = interaction.options.getString("color", true);
|
||||
const price = interaction.options.getNumber("price") || 500;
|
||||
@@ -45,11 +48,10 @@ export const createColor = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. Create Role
|
||||
const role = await interaction.guild?.roles.create({
|
||||
name: name,
|
||||
color: colorInput as any, // Discord.js types are a bit strict on ColorResolvable, but string generally works or needs parsing
|
||||
color: colorInput as any,
|
||||
reason: `Created via /createcolor by ${interaction.user.tag}`
|
||||
});
|
||||
|
||||
@@ -57,11 +59,9 @@ export const createColor = createCommand({
|
||||
throw new Error("Failed to create role.");
|
||||
}
|
||||
|
||||
// 3. Update Config
|
||||
if (!config.colorRoles.includes(role.id)) {
|
||||
config.colorRoles.push(role.id);
|
||||
saveConfig(config);
|
||||
}
|
||||
// 3. Add to guild settings
|
||||
await guildSettingsService.addColorRole(interaction.guildId!, role.id);
|
||||
invalidateGuildConfigCache(interaction.guildId!);
|
||||
|
||||
// 4. Create Item
|
||||
await DrizzleClient.insert(items).values({
|
||||
@@ -85,10 +85,8 @@ export const createColor = createCommand({
|
||||
"✅ Color Role & Item Created"
|
||||
)]
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error in createcolor:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(`Failed to create color role: ${error.message}`)] });
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
293
bot/commands/admin/featureflags.ts
Normal file
293
bot/commands/admin/featureflags.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, Colors, userMention, roleMention, ChatInputCommandInteraction } from "discord.js";
|
||||
import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service";
|
||||
import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const featureflags = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("featureflags")
|
||||
.setDescription("Manage feature flags for beta testing")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("list")
|
||||
.setDescription("List all feature flags")
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("create")
|
||||
.setDescription("Create a new feature flag")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("name")
|
||||
.setDescription("Name of the feature flag")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addStringOption(opt =>
|
||||
opt.setName("description")
|
||||
.setDescription("Description of the feature flag")
|
||||
.setRequired(false)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("delete")
|
||||
.setDescription("Delete a feature flag")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("name")
|
||||
.setDescription("Name of the feature flag")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("enable")
|
||||
.setDescription("Enable a feature flag")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("name")
|
||||
.setDescription("Name of the feature flag")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("disable")
|
||||
.setDescription("Disable a feature flag")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("name")
|
||||
.setDescription("Name of the feature flag")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("grant")
|
||||
.setDescription("Grant access to a feature flag")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("name")
|
||||
.setDescription("Name of the feature flag")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
)
|
||||
.addUserOption(opt =>
|
||||
opt.setName("user")
|
||||
.setDescription("User to grant access to")
|
||||
.setRequired(false)
|
||||
)
|
||||
.addRoleOption(opt =>
|
||||
opt.setName("role")
|
||||
.setDescription("Role to grant access to")
|
||||
.setRequired(false)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("revoke")
|
||||
.setDescription("Revoke access from a feature flag")
|
||||
.addIntegerOption(opt =>
|
||||
opt.setName("id")
|
||||
.setDescription("Access record ID to revoke")
|
||||
.setRequired(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("access")
|
||||
.setDescription("List access records for a feature flag")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("name")
|
||||
.setDescription("Name of the feature flag")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
)
|
||||
),
|
||||
autocomplete: async (interaction) => {
|
||||
const focused = interaction.options.getFocused(true);
|
||||
|
||||
if (focused.name === "name") {
|
||||
const flags = await featureFlagsService.listFlags();
|
||||
const filtered = flags
|
||||
.filter(f => f.name.toLowerCase().includes(focused.value.toLowerCase()))
|
||||
.slice(0, 25);
|
||||
|
||||
await interaction.respond(
|
||||
filtered.map(f => ({ name: `${f.name} (${f.enabled ? "enabled" : "disabled"})`, value: f.name }))
|
||||
);
|
||||
}
|
||||
},
|
||||
execute: async (interaction) => {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
switch (subcommand) {
|
||||
case "list":
|
||||
await handleList(interaction);
|
||||
break;
|
||||
case "create":
|
||||
await handleCreate(interaction);
|
||||
break;
|
||||
case "delete":
|
||||
await handleDelete(interaction);
|
||||
break;
|
||||
case "enable":
|
||||
await handleEnable(interaction);
|
||||
break;
|
||||
case "disable":
|
||||
await handleDisable(interaction);
|
||||
break;
|
||||
case "grant":
|
||||
await handleGrant(interaction);
|
||||
break;
|
||||
case "revoke":
|
||||
await handleRevoke(interaction);
|
||||
break;
|
||||
case "access":
|
||||
await handleAccess(interaction);
|
||||
break;
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
async function handleList(interaction: ChatInputCommandInteraction) {
|
||||
const flags = await featureFlagsService.listFlags();
|
||||
|
||||
if (flags.length === 0) {
|
||||
await interaction.editReply({ embeds: [createBaseEmbed("Feature Flags", "No feature flags have been created yet.", Colors.Blue)] });
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = createBaseEmbed("Feature Flags", undefined, Colors.Blue)
|
||||
.addFields(
|
||||
flags.map(f => ({
|
||||
name: f.name,
|
||||
value: `${f.enabled ? "✅ Enabled" : "❌ Disabled"}\n${f.description || "*No description*"}`,
|
||||
inline: false,
|
||||
}))
|
||||
);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
|
||||
async function handleCreate(interaction: ChatInputCommandInteraction) {
|
||||
const name = interaction.options.getString("name", true);
|
||||
const description = interaction.options.getString("description");
|
||||
|
||||
const flag = await featureFlagsService.createFlag(name, description ?? undefined);
|
||||
|
||||
if (!flag) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to create feature flag.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" created successfully. Use \`/featureflags enable\` to enable it.`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDelete(interaction: ChatInputCommandInteraction) {
|
||||
const name = interaction.options.getString("name", true);
|
||||
|
||||
const flag = await featureFlagsService.deleteFlag(name);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been deleted.`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleEnable(interaction: ChatInputCommandInteraction) {
|
||||
const name = interaction.options.getString("name", true);
|
||||
|
||||
const flag = await featureFlagsService.setFlagEnabled(name, true);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been enabled.`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDisable(interaction: ChatInputCommandInteraction) {
|
||||
const name = interaction.options.getString("name", true);
|
||||
|
||||
const flag = await featureFlagsService.setFlagEnabled(name, false);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been disabled.`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleGrant(interaction: ChatInputCommandInteraction) {
|
||||
const name = interaction.options.getString("name", true);
|
||||
const user = interaction.options.getUser("user");
|
||||
const role = interaction.options.getRole("role");
|
||||
|
||||
if (!user && !role) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("You must specify either a user or a role to grant access to.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const access = await featureFlagsService.grantAccess(name, {
|
||||
userId: user?.id,
|
||||
roleId: role?.id,
|
||||
guildId: interaction.guildId!,
|
||||
});
|
||||
|
||||
if (!access) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to grant access.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
let target: string;
|
||||
if (user) {
|
||||
target = userMention(user.id);
|
||||
} else if (role) {
|
||||
target = roleMention(role.id);
|
||||
} else {
|
||||
target = "Unknown";
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Access to "**${name}**" granted to ${target} (ID: ${access.id})`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRevoke(interaction: ChatInputCommandInteraction) {
|
||||
const id = interaction.options.getInteger("id", true);
|
||||
|
||||
const access = await featureFlagsService.revokeAccess(id);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Access record #${access.id} has been revoked.`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAccess(interaction: ChatInputCommandInteraction) {
|
||||
const name = interaction.options.getString("name", true);
|
||||
|
||||
const accessRecords = await featureFlagsService.listAccess(name);
|
||||
|
||||
if (accessRecords.length === 0) {
|
||||
await interaction.editReply({
|
||||
embeds: [createBaseEmbed("Feature Flag Access", `No access records for "**${name}**".`, Colors.Blue)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = accessRecords.map(a => {
|
||||
let target = "Unknown";
|
||||
if (a.userId) target = `User: ${userMention(a.userId.toString())}`;
|
||||
else if (a.roleId) target = `Role: ${roleMention(a.roleId.toString())}`;
|
||||
else if (a.guildId) target = `Guild: ${a.guildId.toString()}`;
|
||||
|
||||
return {
|
||||
name: `ID: ${a.id}`,
|
||||
value: target,
|
||||
inline: true,
|
||||
};
|
||||
});
|
||||
|
||||
const embed = createBaseEmbed(`Feature Flag Access: ${name}`, undefined, Colors.Blue)
|
||||
.addFields(fields);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createBaseEmbed } from "@lib/embeds";
|
||||
import { config, reloadConfig, toggleCommand } from "@shared/lib/config";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
|
||||
export const features = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("features")
|
||||
.setDescription("Manage bot features and commands")
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("list")
|
||||
.setDescription("List all commands and their status")
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("toggle")
|
||||
.setDescription("Enable or disable a command")
|
||||
.addStringOption(option =>
|
||||
option.setName("command")
|
||||
.setDescription("The name of the command")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addBooleanOption(option =>
|
||||
option.setName("enabled")
|
||||
.setDescription("Whether the command should be enabled")
|
||||
.setRequired(true)
|
||||
)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
if (subcommand === "list") {
|
||||
const activeCommands = AuroraClient.commands;
|
||||
const categories = new Map<string, string[]>();
|
||||
|
||||
// Group active commands
|
||||
activeCommands.forEach(cmd => {
|
||||
const cat = cmd.category || 'Uncategorized';
|
||||
if (!categories.has(cat)) categories.set(cat, []);
|
||||
categories.get(cat)!.push(cmd.data.name);
|
||||
});
|
||||
|
||||
// Config overrides
|
||||
const overrides = Object.entries(config.commands)
|
||||
.map(([name, enabled]) => `• **${name}**: ${enabled ? "✅ Enabled (Override)" : "❌ Disabled"}`);
|
||||
|
||||
const embed = createBaseEmbed("Command Features", undefined, "Blue");
|
||||
|
||||
// Add fields for each category
|
||||
const sortedCategories = [...categories.keys()].sort();
|
||||
for (const cat of sortedCategories) {
|
||||
const cmds = categories.get(cat)!.sort();
|
||||
const cmdList = cmds.map(name => {
|
||||
const isOverride = config.commands[name] !== undefined;
|
||||
return isOverride ? `**${name}** (See Overrides)` : `**${name}**`;
|
||||
}).join(", ");
|
||||
|
||||
embed.addFields({ name: `📂 ${cat.toUpperCase()}`, value: cmdList || "None" });
|
||||
}
|
||||
|
||||
if (overrides.length > 0) {
|
||||
embed.addFields({ name: "⚙️ Configuration Overrides", value: overrides.join("\n") });
|
||||
} else {
|
||||
embed.addFields({ name: "⚙️ Configuration Overrides", value: "No overrides set." });
|
||||
}
|
||||
|
||||
// Check permissions manually as a fallback (though defaultMemberPermissions handles it at the API level)
|
||||
if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) {
|
||||
await interaction.reply({ content: "❌ You need Administrator permissions to use this command.", flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
|
||||
} else if (subcommand === "toggle") {
|
||||
const commandName = interaction.options.getString("command", true);
|
||||
const enabled = interaction.options.getBoolean("enabled", true);
|
||||
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
toggleCommand(commandName, enabled);
|
||||
|
||||
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Reloading configuration...` });
|
||||
|
||||
// Reload config from disk (which was updated by toggleCommand)
|
||||
reloadConfig();
|
||||
|
||||
await AuroraClient.loadCommands(true);
|
||||
await AuroraClient.deployCommands();
|
||||
|
||||
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Commands reloaded!` });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,20 +1,18 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import {
|
||||
SlashCommandBuilder,
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
type BaseGuildTextChannel,
|
||||
PermissionFlagsBits,
|
||||
MessageFlags
|
||||
} from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { items } from "@db/schema";
|
||||
import { ilike, isNotNull, and } from "drizzle-orm";
|
||||
import { ilike, isNotNull, and, inArray } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { getShopListingMessage } from "@/modules/economy/shop.view";
|
||||
import { EffectType, LootType } from "@shared/lib/constants";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const listing = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -33,8 +31,9 @@ export const listing = createCommand({
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const itemId = interaction.options.getNumber("item", true);
|
||||
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
|
||||
|
||||
@@ -54,23 +53,45 @@ export const listing = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare context for lootboxes
|
||||
const context: { referencedItems: Map<number, { name: string; rarity: string }> } = { referencedItems: new Map() };
|
||||
|
||||
const usageData = item.usageData as any;
|
||||
const lootboxEffect = usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX);
|
||||
|
||||
if (lootboxEffect && lootboxEffect.pool) {
|
||||
const itemIds = lootboxEffect.pool
|
||||
.filter((drop: any) => drop.type === LootType.ITEM && drop.itemId)
|
||||
.map((drop: any) => drop.itemId);
|
||||
|
||||
if (itemIds.length > 0) {
|
||||
// Remove duplicates
|
||||
const uniqueIds = [...new Set(itemIds)] as number[];
|
||||
|
||||
const referencedItems = await DrizzleClient.select({
|
||||
id: items.id,
|
||||
name: items.name,
|
||||
rarity: items.rarity
|
||||
}).from(items).where(inArray(items.id, uniqueIds));
|
||||
|
||||
for (const ref of referencedItems) {
|
||||
context.referencedItems.set(ref.id, { name: ref.name, rarity: ref.rarity || 'C' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listingMessage = getShopListingMessage({
|
||||
...item,
|
||||
rarity: item.rarity || undefined,
|
||||
formattedPrice: `${item.price} 🪙`,
|
||||
price: item.price
|
||||
});
|
||||
}, context);
|
||||
|
||||
try {
|
||||
await targetChannel.send(listingMessage);
|
||||
await targetChannel.send(listingMessage as any);
|
||||
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error creating listing:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||
}
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
},
|
||||
autocomplete: async (interaction) => {
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { CaseType } from "@shared/lib/constants";
|
||||
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const note = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -24,14 +25,14 @@ export const note = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
const noteText = interaction.options.getString("note", true);
|
||||
|
||||
// Create the note case
|
||||
const moderationCase = await ModerationService.createCase({
|
||||
const moderationCase = await moderationService.createCase({
|
||||
type: CaseType.NOTE,
|
||||
userId: targetUser.id,
|
||||
username: targetUser.username,
|
||||
@@ -51,12 +52,8 @@ export const note = createCommand({
|
||||
await interaction.editReply({
|
||||
embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Note command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while adding the note.")]
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCasesListEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const notes = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -16,13 +17,13 @@ export const notes = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
|
||||
// Get all notes for the user
|
||||
const userNotes = await ModerationService.getUserNotes(targetUser.id);
|
||||
const userNotes = await moderationService.getUserNotes(targetUser.id);
|
||||
|
||||
// Display the notes
|
||||
await interaction.editReply({
|
||||
@@ -32,12 +33,8 @@ export const notes = createCommand({
|
||||
userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**`
|
||||
)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Notes command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching notes.")]
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { PruneService } from "@shared/modules/moderation/prune.service";
|
||||
import { pruneService } from "@modules/moderation/prune.service";
|
||||
import {
|
||||
getConfirmationMessage,
|
||||
getProgressEmbed,
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
getPruneWarningEmbed,
|
||||
getCancelledEmbed
|
||||
} from "@/modules/moderation/prune.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
import { PRUNE_CUSTOM_IDS } from "@modules/moderation/prune.types";
|
||||
|
||||
export const prune = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -38,9 +40,9 @@ export const prune = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const amount = interaction.options.getInteger("amount");
|
||||
const user = interaction.options.getUser("user");
|
||||
const all = interaction.options.getBoolean("all") || false;
|
||||
@@ -66,7 +68,7 @@ export const prune = createCommand({
|
||||
let estimatedCount: number | undefined;
|
||||
if (all) {
|
||||
try {
|
||||
estimatedCount = await PruneService.estimateMessageCount(interaction.channel!);
|
||||
estimatedCount = await pruneService.estimateMessageCount(interaction.channel!);
|
||||
} catch {
|
||||
estimatedCount = undefined;
|
||||
}
|
||||
@@ -82,7 +84,7 @@ export const prune = createCommand({
|
||||
time: 30000
|
||||
});
|
||||
|
||||
if (confirmation.customId === "cancel_prune") {
|
||||
if (confirmation.customId === PRUNE_CUSTOM_IDS.CANCEL) {
|
||||
await confirmation.update({
|
||||
embeds: [getCancelledEmbed()],
|
||||
components: []
|
||||
@@ -97,7 +99,7 @@ export const prune = createCommand({
|
||||
});
|
||||
|
||||
// Execute deletion with progress callback for 'all' mode
|
||||
const result = await PruneService.deleteMessages(
|
||||
const result = await pruneService.deleteMessages(
|
||||
interaction.channel!,
|
||||
{
|
||||
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
|
||||
@@ -129,7 +131,7 @@ export const prune = createCommand({
|
||||
}
|
||||
} else {
|
||||
// No confirmation needed, proceed directly
|
||||
const result = await PruneService.deleteMessages(
|
||||
const result = await pruneService.deleteMessages(
|
||||
interaction.channel!,
|
||||
{
|
||||
amount: finalAmount as number,
|
||||
@@ -156,24 +158,8 @@ export const prune = createCommand({
|
||||
embeds: [getSuccessEmbed(result)]
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Prune command error:", error);
|
||||
|
||||
let errorMessage = "An unexpected error occurred while trying to delete messages.";
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("permission")) {
|
||||
errorMessage = "I don't have permission to delete messages in this channel.";
|
||||
} else if (error.message.includes("channel type")) {
|
||||
errorMessage = "This command cannot be used in this type of channel.";
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneErrorEmbed(errorMessage)]
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { createSuccessEmbed } from "@lib/embeds";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const refresh = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -9,9 +10,9 @@ export const refresh = createCommand({
|
||||
.setDescription("Reloads all commands and config without restarting")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const start = Date.now();
|
||||
await AuroraClient.loadCommands(true);
|
||||
const duration = Date.now() - start;
|
||||
@@ -25,9 +26,8 @@ export const refresh = createCommand({
|
||||
);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An error occurred while refreshing commands. Check console for details.", "Refresh Failed")] });
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
243
bot/commands/admin/settings.ts
Normal file
243
bot/commands/admin/settings.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, Colors, ChatInputCommandInteraction } from "discord.js";
|
||||
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
|
||||
import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const settings = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("settings")
|
||||
.setDescription("Manage guild settings")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("show")
|
||||
.setDescription("Show current guild settings"))
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("set")
|
||||
.setDescription("Set a guild setting")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("key")
|
||||
.setDescription("Setting to change")
|
||||
.setRequired(true)
|
||||
.addChoices(
|
||||
{ name: "Student Role", value: "studentRole" },
|
||||
{ name: "Visitor Role", value: "visitorRole" },
|
||||
{ name: "Welcome Channel", value: "welcomeChannel" },
|
||||
{ name: "Welcome Message", value: "welcomeMessage" },
|
||||
{ name: "Feedback Channel", value: "feedbackChannel" },
|
||||
{ name: "Terminal Channel", value: "terminalChannel" },
|
||||
{ name: "Terminal Message", value: "terminalMessage" },
|
||||
{ name: "Moderation Log Channel", value: "moderationLogChannel" },
|
||||
{ name: "DM on Warn", value: "moderationDmOnWarn" },
|
||||
{ name: "Auto Timeout Threshold", value: "moderationAutoTimeoutThreshold" },
|
||||
))
|
||||
.addRoleOption(opt =>
|
||||
opt.setName("role")
|
||||
.setDescription("Role value"))
|
||||
.addChannelOption(opt =>
|
||||
opt.setName("channel")
|
||||
.setDescription("Channel value"))
|
||||
.addStringOption(opt =>
|
||||
opt.setName("text")
|
||||
.setDescription("Text value"))
|
||||
.addIntegerOption(opt =>
|
||||
opt.setName("number")
|
||||
.setDescription("Number value"))
|
||||
.addBooleanOption(opt =>
|
||||
opt.setName("boolean")
|
||||
.setDescription("Boolean value (true/false)")))
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("reset")
|
||||
.setDescription("Reset a setting to default")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("key")
|
||||
.setDescription("Setting to reset")
|
||||
.setRequired(true)
|
||||
.addChoices(
|
||||
{ name: "Student Role", value: "studentRole" },
|
||||
{ name: "Visitor Role", value: "visitorRole" },
|
||||
{ name: "Welcome Channel", value: "welcomeChannel" },
|
||||
{ name: "Welcome Message", value: "welcomeMessage" },
|
||||
{ name: "Feedback Channel", value: "feedbackChannel" },
|
||||
{ name: "Terminal Channel", value: "terminalChannel" },
|
||||
{ name: "Terminal Message", value: "terminalMessage" },
|
||||
{ name: "Moderation Log Channel", value: "moderationLogChannel" },
|
||||
{ name: "DM on Warn", value: "moderationDmOnWarn" },
|
||||
{ name: "Auto Timeout Threshold", value: "moderationAutoTimeoutThreshold" },
|
||||
)))
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("colors")
|
||||
.setDescription("Manage color roles")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("action")
|
||||
.setDescription("Action to perform")
|
||||
.setRequired(true)
|
||||
.addChoices(
|
||||
{ name: "List", value: "list" },
|
||||
{ name: "Add", value: "add" },
|
||||
{ name: "Remove", value: "remove" },
|
||||
))
|
||||
.addRoleOption(opt =>
|
||||
opt.setName("role")
|
||||
.setDescription("Role to add/remove")
|
||||
.setRequired(false))),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
const guildId = interaction.guildId!;
|
||||
|
||||
switch (subcommand) {
|
||||
case "show":
|
||||
await handleShow(interaction, guildId);
|
||||
break;
|
||||
case "set":
|
||||
await handleSet(interaction, guildId);
|
||||
break;
|
||||
case "reset":
|
||||
await handleReset(interaction, guildId);
|
||||
break;
|
||||
case "colors":
|
||||
await handleColors(interaction, guildId);
|
||||
break;
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
async function handleShow(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||
const settings = await getGuildConfig(guildId);
|
||||
|
||||
const colorRolesDisplay = settings.colorRoles?.length
|
||||
? settings.colorRoles.map(id => `<@&${id}>`).join(", ")
|
||||
: "None";
|
||||
|
||||
const embed = createBaseEmbed("Guild Settings", undefined, Colors.Blue)
|
||||
.addFields(
|
||||
{ name: "Student Role", value: settings.studentRole ? `<@&${settings.studentRole}>` : "Not set", inline: true },
|
||||
{ name: "Visitor Role", value: settings.visitorRole ? `<@&${settings.visitorRole}>` : "Not set", inline: true },
|
||||
{ name: "\u200b", value: "\u200b", inline: true },
|
||||
{ name: "Welcome Channel", value: settings.welcomeChannelId ? `<#${settings.welcomeChannelId}>` : "Not set", inline: true },
|
||||
{ name: "Feedback Channel", value: settings.feedbackChannelId ? `<#${settings.feedbackChannelId}>` : "Not set", inline: true },
|
||||
{ name: "Moderation Log", value: settings.moderation?.cases?.logChannelId ? `<#${settings.moderation.cases.logChannelId}>` : "Not set", inline: true },
|
||||
{ name: "Terminal Channel", value: settings.terminal?.channelId ? `<#${settings.terminal.channelId}>` : "Not set", inline: true },
|
||||
{ name: "DM on Warn", value: settings.moderation?.cases?.dmOnWarn !== false ? "Enabled" : "Disabled", inline: true },
|
||||
{ name: "Auto Timeout", value: settings.moderation?.cases?.autoTimeoutThreshold ? `${settings.moderation.cases.autoTimeoutThreshold} warnings` : "Disabled", inline: true },
|
||||
{ name: "Color Roles", value: colorRolesDisplay, inline: false },
|
||||
);
|
||||
|
||||
if (settings.welcomeMessage) {
|
||||
embed.addFields({ name: "Welcome Message", value: settings.welcomeMessage.substring(0, 1024), inline: false });
|
||||
}
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
|
||||
async function handleSet(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||
const key = interaction.options.getString("key", true);
|
||||
const role = interaction.options.getRole("role");
|
||||
const channel = interaction.options.getChannel("channel");
|
||||
const text = interaction.options.getString("text");
|
||||
const number = interaction.options.getInteger("number");
|
||||
const boolean = interaction.options.getBoolean("boolean");
|
||||
|
||||
let value: string | number | boolean | null = null;
|
||||
|
||||
if (role) value = role.id;
|
||||
else if (channel) value = channel.id;
|
||||
else if (text) value = text;
|
||||
else if (number !== null) value = number;
|
||||
else if (boolean !== null) value = boolean;
|
||||
|
||||
if (value === null) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("Please provide a role, channel, text, number, or boolean value")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await guildSettingsService.updateSetting(guildId, key, value);
|
||||
invalidateGuildConfigCache(guildId);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Setting "${key}" updated`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleReset(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||
const key = interaction.options.getString("key", true);
|
||||
|
||||
await guildSettingsService.updateSetting(guildId, key, null);
|
||||
invalidateGuildConfigCache(guildId);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Setting "${key}" reset to default`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleColors(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||
const action = interaction.options.getString("action", true);
|
||||
const role = interaction.options.getRole("role");
|
||||
|
||||
switch (action) {
|
||||
case "list": {
|
||||
const settings = await getGuildConfig(guildId);
|
||||
const colorRoles = settings.colorRoles ?? [];
|
||||
|
||||
if (colorRoles.length === 0) {
|
||||
await interaction.editReply({
|
||||
embeds: [createBaseEmbed("Color Roles", "No color roles configured.", Colors.Blue)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = createBaseEmbed("Color Roles", undefined, Colors.Blue)
|
||||
.addFields({
|
||||
name: `Configured Roles (${colorRoles.length})`,
|
||||
value: colorRoles.map(id => `<@&${id}>`).join("\n"),
|
||||
});
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
break;
|
||||
}
|
||||
|
||||
case "add": {
|
||||
if (!role) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("Please specify a role to add.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await guildSettingsService.addColorRole(guildId, role.id);
|
||||
invalidateGuildConfigCache(guildId);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Added <@&${role.id}> to color roles.`)]
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "remove": {
|
||||
if (!role) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("Please specify a role to remove.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await guildSettingsService.removeColorRole(guildId, role.id);
|
||||
invalidateGuildConfigCache(guildId);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Removed <@&${role.id}> from color roles.`)]
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
|
||||
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
||||
import { createBaseEmbed, createErrorEmbed } from "@/lib/embeds";
|
||||
import { terminalService } from "@modules/system/terminal.service";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const terminal = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -23,15 +24,14 @@ export const terminal = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({ ephemeral: true, content: "Initializing terminal..." });
|
||||
|
||||
try {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
await terminalService.init(channel as TextChannel);
|
||||
await interaction.editReply({ content: "✅ Terminal initialized!" });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await interaction.editReply({ content: "❌ Failed to initialize terminal." });
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||
import { UpdateService } from "@shared/modules/admin/update.service";
|
||||
import {
|
||||
getCheckingEmbed,
|
||||
getNoUpdatesEmbed,
|
||||
getUpdatesAvailableMessage,
|
||||
getPreparingEmbed,
|
||||
getUpdatingEmbed,
|
||||
getCancelledEmbed,
|
||||
getTimeoutEmbed,
|
||||
getErrorEmbed,
|
||||
getRollbackSuccessEmbed,
|
||||
getRollbackFailedEmbed
|
||||
} from "@/modules/admin/update.view";
|
||||
|
||||
export const update = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("update")
|
||||
.setDescription("Check for updates and restart the bot")
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("check")
|
||||
.setDescription("Check for and apply available updates")
|
||||
.addBooleanOption(option =>
|
||||
option.setName("force")
|
||||
.setDescription("Force update even if no changes detected")
|
||||
.setRequired(false)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("rollback")
|
||||
.setDescription("Rollback to the previous version")
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
if (subcommand === "rollback") {
|
||||
await handleRollback(interaction);
|
||||
} else {
|
||||
await handleUpdate(interaction);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function handleUpdate(interaction: any) {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
const force = interaction.options.getBoolean("force") || false;
|
||||
|
||||
try {
|
||||
// 1. Check for updates
|
||||
await interaction.editReply({ embeds: [getCheckingEmbed()] });
|
||||
const updateInfo = await UpdateService.checkForUpdates();
|
||||
|
||||
if (!updateInfo.hasUpdates && !force) {
|
||||
await interaction.editReply({
|
||||
embeds: [getNoUpdatesEmbed(updateInfo.currentCommit)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Analyze requirements
|
||||
const requirements = await UpdateService.checkUpdateRequirements(updateInfo.branch);
|
||||
const categories = UpdateService.categorizeChanges(requirements.changedFiles);
|
||||
|
||||
// 3. Show confirmation with details
|
||||
const { embeds, components } = getUpdatesAvailableMessage(
|
||||
updateInfo,
|
||||
requirements,
|
||||
categories,
|
||||
force
|
||||
);
|
||||
const response = await interaction.editReply({ embeds, components });
|
||||
|
||||
// 4. Wait for confirmation
|
||||
try {
|
||||
const confirmation = await response.awaitMessageComponent({
|
||||
filter: (i: any) => i.user.id === interaction.user.id,
|
||||
componentType: ComponentType.Button,
|
||||
time: 30000
|
||||
});
|
||||
|
||||
if (confirmation.customId === "confirm_update") {
|
||||
await confirmation.update({
|
||||
embeds: [getPreparingEmbed()],
|
||||
components: []
|
||||
});
|
||||
|
||||
// 5. Save rollback point
|
||||
const previousCommit = await UpdateService.saveRollbackPoint();
|
||||
|
||||
// 6. Prepare restart context
|
||||
await UpdateService.prepareRestartContext({
|
||||
channelId: interaction.channelId,
|
||||
userId: interaction.user.id,
|
||||
timestamp: Date.now(),
|
||||
runMigrations: requirements.needsMigrations,
|
||||
installDependencies: requirements.needsRootInstall || requirements.needsWebInstall,
|
||||
buildWebAssets: requirements.needsWebBuild,
|
||||
previousCommit: previousCommit.substring(0, 7),
|
||||
newCommit: updateInfo.latestCommit
|
||||
});
|
||||
|
||||
// 7. Show updating status
|
||||
await interaction.editReply({
|
||||
embeds: [getUpdatingEmbed(requirements)]
|
||||
});
|
||||
|
||||
// 8. Perform update
|
||||
await UpdateService.performUpdate(updateInfo.branch);
|
||||
|
||||
// 9. Trigger restart
|
||||
await UpdateService.triggerRestart();
|
||||
|
||||
} else {
|
||||
await confirmation.update({
|
||||
embeds: [getCancelledEmbed()],
|
||||
components: []
|
||||
});
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes("time")) {
|
||||
await interaction.editReply({
|
||||
embeds: [getTimeoutEmbed()],
|
||||
components: []
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Update failed:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getErrorEmbed(error)],
|
||||
components: []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRollback(interaction: any) {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const hasRollback = await UpdateService.hasRollbackPoint();
|
||||
|
||||
if (!hasRollback) {
|
||||
await interaction.editReply({
|
||||
embeds: [getRollbackFailedEmbed("No rollback point available. Rollback is only possible after a recent update.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await UpdateService.rollback();
|
||||
|
||||
if (result.success) {
|
||||
await interaction.editReply({
|
||||
embeds: [getRollbackSuccessEmbed(result.message.split(" ").pop() || "unknown")]
|
||||
});
|
||||
|
||||
// Restart after rollback
|
||||
setTimeout(() => UpdateService.triggerRestart(), 1000);
|
||||
} else {
|
||||
await interaction.editReply({
|
||||
embeds: [getRollbackFailedEmbed(result.message)]
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Rollback failed:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getErrorEmbed(error)]
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import {
|
||||
getWarnSuccessEmbed,
|
||||
getModerationErrorEmbed,
|
||||
getUserWarningEmbed
|
||||
} from "@/modules/moderation/moderation.view";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const warn = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -28,9 +28,9 @@ export const warn = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
const reason = interaction.options.getString("reason", true);
|
||||
|
||||
@@ -50,8 +50,11 @@ export const warn = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch guild config for moderation settings
|
||||
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||
|
||||
// Issue the warning via service
|
||||
const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({
|
||||
const { moderationCase, warningCount, autoTimeoutIssued } = await moderationService.issueWarning({
|
||||
userId: targetUser.id,
|
||||
username: targetUser.username,
|
||||
moderatorId: interaction.user.id,
|
||||
@@ -59,7 +62,11 @@ export const warn = createCommand({
|
||||
reason,
|
||||
guildName: interaction.guild?.name || undefined,
|
||||
dmTarget: targetUser,
|
||||
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id)
|
||||
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id),
|
||||
config: {
|
||||
dmOnWarn: guildConfig.moderation?.cases?.dmOnWarn,
|
||||
autoTimeoutThreshold: guildConfig.moderation?.cases?.autoTimeoutThreshold,
|
||||
},
|
||||
});
|
||||
|
||||
// Send success message to moderator
|
||||
@@ -76,12 +83,8 @@ export const warn = createCommand({
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Warn command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while issuing the warning.")]
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getWarningsEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const warnings = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -16,24 +17,20 @@ export const warnings = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
|
||||
// Get active warnings for the user
|
||||
const activeWarnings = await ModerationService.getUserWarnings(targetUser.id);
|
||||
const activeWarnings = await moderationService.getUserWarnings(targetUser.id);
|
||||
|
||||
// Display the warnings
|
||||
await interaction.editReply({
|
||||
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Warnings command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching warnings.")]
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const webhook = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -14,8 +15,9 @@ export const webhook = createCommand({
|
||||
.setRequired(true)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const payloadString = interaction.options.getString("payload", true);
|
||||
let payload;
|
||||
|
||||
@@ -37,7 +39,6 @@ export const webhook = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendWebhookMessage(
|
||||
channel,
|
||||
payload,
|
||||
@@ -46,11 +47,8 @@ export const webhook = createCommand({
|
||||
);
|
||||
|
||||
await interaction.editReply({ content: "Message sent successfully!" });
|
||||
} catch (error) {
|
||||
console.error("Webhook error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("Failed to send message via webhook. Ensure the bot has 'Manage Webhooks' permission and the payload is valid.", "Delivery Failed")]
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { createSuccessEmbed } from "@lib/embeds";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const daily = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("daily")
|
||||
.setDescription("Claim your daily reward"),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
try {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const result = await economyService.claimDaily(interaction.user.id);
|
||||
|
||||
const embed = createSuccessEmbed(`You claimed ** ${result.amount}** Astral Units!${result.isWeekly ? `\n🎉 **Weekly Bonus!** +${result.weeklyBonus} extra!` : ''}`, "💰 Daily Reward Claimed!")
|
||||
@@ -23,14 +24,7 @@ export const daily = createCommand({
|
||||
.setColor("Gold");
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error claiming daily:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
@@ -10,9 +11,9 @@ export const exam = createCommand({
|
||||
.setName("exam")
|
||||
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
|
||||
try {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
// First, try to take the exam or check status
|
||||
const result = await examService.takeExam(interaction.user.id);
|
||||
|
||||
@@ -65,11 +66,7 @@ export const exam = createCommand({
|
||||
"Exam Passed!"
|
||||
)]
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error in exam command:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An unexpected error occurred.")] });
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const pay = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -50,20 +50,14 @@ export const pay = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await interaction.deferReply();
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
await economyService.transfer(senderId, receiverId.toString(), amount);
|
||||
|
||||
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
|
||||
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error sending payment:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createErrorEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { TriviaCategory } from "@shared/lib/constants";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const trivia = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -53,9 +54,10 @@ export const trivia = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
// User can play - defer publicly for trivia question
|
||||
await interaction.deferReply();
|
||||
|
||||
// User can play - use standardized error handling for the main operation
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
// Start trivia session (deducts entry fee)
|
||||
const session = await triviaService.startTrivia(
|
||||
interaction.user.id,
|
||||
@@ -84,28 +86,18 @@ export const trivia = createCommand({
|
||||
}
|
||||
}
|
||||
}, config.trivia.timeoutSeconds * 1000 + 5000); // 5 seconds grace period
|
||||
}
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
// Handle errors from the pre-defer canPlayTrivia check
|
||||
if (error instanceof UserError) {
|
||||
// Check if we've already deferred
|
||||
if (interaction.deferred) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(error.message)]
|
||||
});
|
||||
} else {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed(error.message)],
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.error("Error in trivia command:", error);
|
||||
// Check if we've already deferred
|
||||
if (interaction.deferred) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")]
|
||||
});
|
||||
} else {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
|
||||
ephemeral: true
|
||||
@@ -113,5 +105,4 @@ export const trivia = createCommand({
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { getFeedbackTypeMenu } from "@/modules/feedback/feedback.view";
|
||||
|
||||
@@ -9,8 +9,10 @@ export const feedback = createCommand({
|
||||
.setName("feedback")
|
||||
.setDescription("Submit feedback, feature requests, or bug reports"),
|
||||
execute: async (interaction) => {
|
||||
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||
|
||||
// Check if feedback channel is configured
|
||||
if (!config.feedbackChannelId) {
|
||||
if (!guildConfig.feedbackChannelId) {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("Feedback system is not configured. Please contact an administrator.")],
|
||||
ephemeral: true
|
||||
|
||||
@@ -1,22 +1,83 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { SlashCommandBuilder, MessageFlags, ComponentType } from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { createWarningEmbed } from "@lib/embeds";
|
||||
import { getInventoryEmbed } from "@/modules/inventory/inventory.view";
|
||||
import { createWarningEmbed, createErrorEmbed } from "@lib/embeds";
|
||||
import {
|
||||
getInventoryListMessage,
|
||||
getEmptyInventoryMessage,
|
||||
getItemDetailMessage,
|
||||
getDiscardConfirmMessage,
|
||||
appendUseBackButton,
|
||||
sortInventoryItems,
|
||||
ITEMS_PER_PAGE,
|
||||
type InventoryEntry,
|
||||
} from "@/modules/inventory/inventory.view";
|
||||
import { getLootboxResultMessage } from "@/modules/inventory/inventory.view";
|
||||
import {
|
||||
parseInventoryCustomId,
|
||||
executeItemUse,
|
||||
} from "@/modules/inventory/inventory.interaction";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
export const inventory = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("inventory")
|
||||
.setDescription("View your or another user's inventory")
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("list")
|
||||
.setDescription("View your or another user's inventory")
|
||||
.addUserOption(option =>
|
||||
option.setName("user")
|
||||
.setDescription("User to view")
|
||||
.setRequired(false)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("view")
|
||||
.setDescription("View details of a specific item")
|
||||
.addNumberOption(option =>
|
||||
option.setName("item")
|
||||
.setDescription("The item to view")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
|
||||
const viewerId = interaction.user.id;
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
if (subcommand === "view") {
|
||||
// Direct item detail view
|
||||
const itemId = interaction.options.getNumber("item", true);
|
||||
const user = await userService.getOrCreateUser(viewerId, interaction.user.username);
|
||||
if (!user) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("Failed to load user data.", "Error")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = await inventoryService.getInventory(user.id.toString());
|
||||
const entry = entries.find((e: any) => e.item.id === itemId);
|
||||
if (!entry) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("Item not found in your inventory.", "Not Found")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const ownerId = user.id.toString();
|
||||
let currentPage = 0;
|
||||
let selectedItemId: number | null = itemId;
|
||||
|
||||
const response = await interaction.editReply(
|
||||
getItemDetailMessage(entry as InventoryEntry, viewerId, ownerId) as any
|
||||
);
|
||||
|
||||
await setupCollector(interaction, response, viewerId, ownerId, user.username, currentPage, selectedItemId);
|
||||
return;
|
||||
}
|
||||
|
||||
// "list" subcommand
|
||||
const targetUser = interaction.options.getUser("user") || interaction.user;
|
||||
|
||||
if (targetUser.bot) {
|
||||
@@ -30,15 +91,232 @@ export const inventory = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
const items = await inventoryService.getInventory(user.id.toString());
|
||||
const ownerId = user.id.toString();
|
||||
const entries = await inventoryService.getInventory(ownerId);
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("Inventory is empty.", `${user.username}'s Inventory`)] });
|
||||
if (!entries || entries.length === 0) {
|
||||
await interaction.editReply(getEmptyInventoryMessage(user.username) as any);
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = getInventoryEmbed(items, user.username);
|
||||
let currentPage = 0;
|
||||
let selectedItemId: number | null = null;
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
const response = await interaction.editReply(
|
||||
getInventoryListMessage(entries as InventoryEntry[], user.username, currentPage, viewerId, ownerId) as any
|
||||
);
|
||||
|
||||
await setupCollector(interaction, response, viewerId, ownerId, user.username, currentPage, selectedItemId);
|
||||
},
|
||||
autocomplete: async (interaction) => {
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
const userId = interaction.user.id;
|
||||
const results = await inventoryService.getAutocompleteItems(userId, focusedValue);
|
||||
await interaction.respond(results);
|
||||
},
|
||||
});
|
||||
|
||||
async function setupCollector(
|
||||
interaction: any,
|
||||
response: any,
|
||||
viewerId: string,
|
||||
ownerId: string,
|
||||
username: string,
|
||||
initialPage: number,
|
||||
initialItemId: number | null,
|
||||
) {
|
||||
let currentPage = initialPage;
|
||||
let selectedItemId = initialItemId;
|
||||
|
||||
const collector = response.createMessageComponentCollector({
|
||||
time: 120_000,
|
||||
});
|
||||
|
||||
collector.on("collect", async (i: any) => {
|
||||
if (i.user.id !== viewerId) return;
|
||||
|
||||
const parsed = parseInventoryCustomId(i.customId);
|
||||
if (!parsed) return;
|
||||
|
||||
try {
|
||||
await i.deferUpdate();
|
||||
|
||||
// Re-fetch inventory for fresh data
|
||||
const entries = await inventoryService.getInventory(ownerId);
|
||||
const sorted = sortInventoryItems(entries as InventoryEntry[]);
|
||||
|
||||
switch (parsed.action) {
|
||||
case "select": {
|
||||
const itemId = parseInt(i.values[0]);
|
||||
const entry = sorted.find(e => e.item.id === itemId);
|
||||
if (!entry) break;
|
||||
selectedItemId = itemId;
|
||||
await interaction.editReply(
|
||||
getItemDetailMessage(entry, viewerId, ownerId)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "prev": {
|
||||
currentPage = Math.max(0, currentPage - 1);
|
||||
selectedItemId = null;
|
||||
await interaction.editReply(
|
||||
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "next": {
|
||||
currentPage = currentPage + 1;
|
||||
selectedItemId = null;
|
||||
await interaction.editReply(
|
||||
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "back": {
|
||||
selectedItemId = null;
|
||||
if (sorted.length === 0) {
|
||||
await interaction.editReply(getEmptyInventoryMessage(username));
|
||||
} else {
|
||||
await interaction.editReply(
|
||||
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "use": {
|
||||
if (viewerId !== ownerId || !selectedItemId) break;
|
||||
try {
|
||||
const result = await executeItemUse(i, viewerId, selectedItemId);
|
||||
const message = getLootboxResultMessage(result.results, result.item);
|
||||
await interaction.editReply(appendUseBackButton(message, viewerId) as any);
|
||||
} catch (error) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(error.message)],
|
||||
components: [],
|
||||
flags: undefined,
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "use_back": {
|
||||
// Return from use result to detail or list
|
||||
if (!selectedItemId) break;
|
||||
const entry = sorted.find(e => e.item.id === selectedItemId);
|
||||
if (entry) {
|
||||
await interaction.editReply(
|
||||
getItemDetailMessage(entry, viewerId, ownerId)
|
||||
);
|
||||
} else {
|
||||
selectedItemId = null;
|
||||
if (sorted.length === 0) {
|
||||
await interaction.editReply(getEmptyInventoryMessage(username));
|
||||
} else {
|
||||
await interaction.editReply(
|
||||
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "discard": {
|
||||
if (viewerId !== ownerId || !selectedItemId) break;
|
||||
const entry = sorted.find(e => e.item.id === selectedItemId);
|
||||
if (!entry) break;
|
||||
await interaction.editReply(
|
||||
getDiscardConfirmMessage(entry, viewerId)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "discard_confirm": {
|
||||
if (viewerId !== ownerId || !selectedItemId) break;
|
||||
try {
|
||||
await inventoryService.removeItem(ownerId, selectedItemId, 1n);
|
||||
|
||||
const freshEntries = await inventoryService.getInventory(ownerId);
|
||||
const freshSorted = sortInventoryItems(freshEntries as InventoryEntry[]);
|
||||
const freshEntry = freshSorted.find(e => e.item.id === selectedItemId);
|
||||
|
||||
if (freshEntry) {
|
||||
await interaction.editReply(
|
||||
getItemDetailMessage(freshEntry, viewerId, ownerId)
|
||||
);
|
||||
} else {
|
||||
selectedItemId = null;
|
||||
if (freshSorted.length === 0) {
|
||||
await interaction.editReply(getEmptyInventoryMessage(username));
|
||||
} else {
|
||||
await interaction.editReply(
|
||||
getInventoryListMessage(freshSorted, username, currentPage, viewerId, ownerId)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(error.message)],
|
||||
components: [],
|
||||
flags: undefined,
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "discard_cancel": {
|
||||
if (!selectedItemId) break;
|
||||
const entry = sorted.find(e => e.item.id === selectedItemId);
|
||||
if (!entry) break;
|
||||
await interaction.editReply(
|
||||
getItemDetailMessage(entry, viewerId, ownerId)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Inventory interaction error:", error);
|
||||
}
|
||||
});
|
||||
|
||||
collector.on("end", async () => {
|
||||
try {
|
||||
// Re-render current view as static (no interactive components)
|
||||
const entries = await inventoryService.getInventory(ownerId);
|
||||
const sorted = sortInventoryItems(entries as InventoryEntry[]);
|
||||
|
||||
if (selectedItemId) {
|
||||
const entry = sorted.find(e => e.item.id === selectedItemId);
|
||||
if (entry) {
|
||||
// Show detail view without action buttons
|
||||
const msg = getItemDetailMessage(entry, viewerId, ownerId);
|
||||
// Replace components with empty to remove buttons but keep container content
|
||||
await interaction.editReply(msg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (sorted.length === 0) {
|
||||
await interaction.editReply(getEmptyInventoryMessage(username));
|
||||
} else {
|
||||
await interaction.editReply(
|
||||
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// If re-rendering fails, at least try to clear gracefully
|
||||
interaction.editReply({ components: [] }).catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,10 +3,9 @@ import { SlashCommandBuilder } from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
||||
import type { ItemUsageData } from "@shared/lib/types";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { getLootboxResultMessage } from "@/modules/inventory/inventory.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
|
||||
export const use = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -19,7 +18,11 @@ export const use = createCommand({
|
||||
.setAutocomplete(true)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||
const colorRoles = guildConfig.colorRoles ?? [];
|
||||
|
||||
const itemId = interaction.options.getNumber("item", true);
|
||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
@@ -28,7 +31,6 @@ export const use = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await inventoryService.useItem(user.id.toString(), itemId);
|
||||
|
||||
const usageData = result.usageData;
|
||||
@@ -42,7 +44,7 @@ export const use = createCommand({
|
||||
await member.roles.add(effect.roleId);
|
||||
} else if (effect.type === 'COLOR_ROLE') {
|
||||
// Remove existing color roles
|
||||
const rolesToRemove = config.colorRoles.filter(r => member.roles.cache.has(r));
|
||||
const rolesToRemove = colorRoles.filter(r => member.roles.cache.has(r));
|
||||
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
|
||||
await member.roles.add(effect.roleId);
|
||||
}
|
||||
@@ -55,18 +57,10 @@ export const use = createCommand({
|
||||
}
|
||||
}
|
||||
|
||||
const embed = getItemUseResultEmbed(result.results, result.item);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error using item:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred while using the item.")] });
|
||||
}
|
||||
const message = getLootboxResultMessage(result.results, result.item);
|
||||
await interaction.editReply(message as any);
|
||||
}
|
||||
);
|
||||
},
|
||||
autocomplete: async (interaction) => {
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getAvailableQuestsComponents,
|
||||
getQuestActionRows
|
||||
} from "@/modules/quest/quest.view";
|
||||
import { QUEST_CUSTOM_IDS } from "@modules/quest/quest.types";
|
||||
|
||||
export const quests = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -16,16 +17,24 @@ export const quests = createCommand({
|
||||
const response = await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const userId = interaction.user.id;
|
||||
let currentView: 'active' | 'available' = 'active';
|
||||
let currentPage = 0;
|
||||
|
||||
const updateView = async (viewType: 'active' | 'available', page: number = 0) => {
|
||||
currentView = viewType;
|
||||
currentPage = page;
|
||||
|
||||
const updateView = async (viewType: 'active' | 'available') => {
|
||||
const userQuests = await questService.getUserQuests(userId);
|
||||
const availableQuests = await questService.getAvailableQuests(userId);
|
||||
|
||||
const containers = viewType === 'active'
|
||||
? getQuestListComponents(userQuests)
|
||||
: getAvailableQuestsComponents(availableQuests);
|
||||
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
|
||||
const totalItems = viewType === 'active' ? activeQuests.length : availableQuests.length;
|
||||
|
||||
const actionRows = getQuestActionRows(viewType);
|
||||
const containers = viewType === 'active'
|
||||
? getQuestListComponents(userQuests, page)
|
||||
: getAvailableQuestsComponents(availableQuests, page);
|
||||
|
||||
const actionRows = getQuestActionRows(viewType, totalItems, page);
|
||||
|
||||
await interaction.editReply({
|
||||
content: null,
|
||||
@@ -48,13 +57,19 @@ export const quests = createCommand({
|
||||
if (i.user.id !== interaction.user.id) return;
|
||||
|
||||
try {
|
||||
if (i.customId === "quest_view_active") {
|
||||
if (i.customId === QUEST_CUSTOM_IDS.VIEW_ACTIVE) {
|
||||
await i.deferUpdate();
|
||||
await updateView('active');
|
||||
} else if (i.customId === "quest_view_available") {
|
||||
await updateView('active', 0);
|
||||
} else if (i.customId === QUEST_CUSTOM_IDS.VIEW_AVAILABLE) {
|
||||
await i.deferUpdate();
|
||||
await updateView('available');
|
||||
} else if (i.customId.startsWith("quest_accept:")) {
|
||||
await updateView('available', 0);
|
||||
} else if (i.customId === QUEST_CUSTOM_IDS.PAGE_PREV) {
|
||||
await i.deferUpdate();
|
||||
await updateView(currentView, Math.max(0, currentPage - 1));
|
||||
} else if (i.customId === QUEST_CUSTOM_IDS.PAGE_NEXT) {
|
||||
await i.deferUpdate();
|
||||
await updateView(currentView, currentPage + 1);
|
||||
} else if (i.customId.startsWith(QUEST_CUSTOM_IDS.ACCEPT_PREFIX)) {
|
||||
const questIdStr = i.customId.split(":")[1];
|
||||
if (!questIdStr) return;
|
||||
const questId = parseInt(questIdStr);
|
||||
@@ -65,7 +80,8 @@ export const quests = createCommand({
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
|
||||
await updateView('active');
|
||||
// Stay on current view/page but refresh (accepted quest disappears from available)
|
||||
await updateView(currentView, currentPage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Quest interaction error:", error);
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { Events } from "discord.js";
|
||||
import type { Event } from "@shared/lib/types";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
|
||||
// Visitor role
|
||||
const event: Event<Events.GuildMemberAdd> = {
|
||||
name: Events.GuildMemberAdd,
|
||||
execute: async (member) => {
|
||||
console.log(`👤 New member joined: ${member.user.tag} (${member.id})`);
|
||||
|
||||
const guildConfig = await getGuildConfig(member.guild.id);
|
||||
|
||||
try {
|
||||
const user = await userService.getUserById(member.id);
|
||||
|
||||
if (user && user.class) {
|
||||
console.log(`🔄 Returning student detected: ${member.user.tag}`);
|
||||
await member.roles.remove(config.visitorRole);
|
||||
await member.roles.add(config.studentRole);
|
||||
if (guildConfig.visitorRole) {
|
||||
await member.roles.remove(guildConfig.visitorRole);
|
||||
}
|
||||
if (guildConfig.studentRole) {
|
||||
await member.roles.add(guildConfig.studentRole);
|
||||
}
|
||||
|
||||
if (user.class.roleId) {
|
||||
await member.roles.add(user.class.roleId);
|
||||
@@ -22,9 +28,11 @@ const event: Event<Events.GuildMemberAdd> = {
|
||||
}
|
||||
console.log(`Restored student role to ${member.user.tag}`);
|
||||
} else {
|
||||
await member.roles.add(config.visitorRole);
|
||||
if (guildConfig.visitorRole) {
|
||||
await member.roles.add(guildConfig.visitorRole);
|
||||
console.log(`Assigned visitor role to ${member.user.tag}`);
|
||||
}
|
||||
}
|
||||
console.log(`User Roles: ${member.roles.cache.map(role => role.name).join(", ")}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to handle role assignment for ${member.user.tag}:`, error);
|
||||
|
||||
@@ -15,7 +15,7 @@ const event: Event<Events.MessageCreate> = {
|
||||
levelingService.processChatXp(message.author.id);
|
||||
|
||||
// Activity Tracking for Lootdrops
|
||||
import("@shared/modules/economy/lootdrop.service").then(m => m.lootdropService.processMessage(message));
|
||||
import("@modules/economy/lootdrop.handler").then(m => m.processLootdropMessage(message));
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -9,9 +9,7 @@ const event: Event<Events.ClientReady> = {
|
||||
console.log(`Ready! Logged in as ${c.user.tag}`);
|
||||
schedulerService.start();
|
||||
|
||||
// Handle post-update tasks
|
||||
const { UpdateService } = await import("@shared/modules/admin/update.service");
|
||||
await UpdateService.handlePostRestart(c);
|
||||
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
13
bot/index.ts
13
bot/index.ts
@@ -1,8 +1,14 @@
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { env } from "@shared/lib/env";
|
||||
import { join } from "node:path";
|
||||
import { initializeConfig } from "@shared/lib/config";
|
||||
import { registerDomainEventListeners } from "@shared/lib/eventWiring";
|
||||
import { createWebServer } from "../api/src/server";
|
||||
|
||||
import { startWebServerFromRoot } from "../web/src/server";
|
||||
// Initialize config from database
|
||||
await initializeConfig();
|
||||
|
||||
// Register domain event listeners before loading commands/events
|
||||
registerDomainEventListeners();
|
||||
|
||||
// Load commands & events
|
||||
await AuroraClient.loadCommands();
|
||||
@@ -14,12 +20,11 @@ console.log("🌐 Starting web server...");
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
const webProjectPath = join(import.meta.dir, "../web");
|
||||
const webPort = Number(process.env.WEB_PORT) || 3000;
|
||||
const webHost = process.env.HOST || "0.0.0.0";
|
||||
|
||||
// Start web server in the same process
|
||||
const webServer = await startWebServerFromRoot(webProjectPath, {
|
||||
const webServer = await createWebServer({
|
||||
port: webPort,
|
||||
hostname: webHost,
|
||||
});
|
||||
|
||||
@@ -20,7 +20,8 @@ mock.module("discord.js", () => ({
|
||||
Routes: {
|
||||
applicationGuildCommands: () => 'guild_route',
|
||||
applicationCommands: () => 'global_route'
|
||||
}
|
||||
},
|
||||
MessageFlags: {}
|
||||
}));
|
||||
|
||||
// Mock loaders to avoid filesystem access during client init
|
||||
|
||||
@@ -20,6 +20,9 @@ mock.module("./BotClient", () => ({
|
||||
commands: {
|
||||
size: 20,
|
||||
},
|
||||
knownCommands: {
|
||||
size: 20,
|
||||
},
|
||||
lastCommandTimestamp: 1641481200000,
|
||||
},
|
||||
}));
|
||||
|
||||
147
bot/lib/commandUtils.test.ts
Normal file
147
bot/lib/commandUtils.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, mock, beforeEach, spyOn } from "bun:test";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
const mockDeferReply = mock(() => Promise.resolve());
|
||||
const mockEditReply = mock(() => Promise.resolve());
|
||||
|
||||
const mockInteraction = {
|
||||
deferReply: mockDeferReply,
|
||||
editReply: mockEditReply,
|
||||
} as any;
|
||||
|
||||
const mockCreateErrorEmbed = mock((msg: string) => ({ description: msg, type: "error" }));
|
||||
|
||||
mock.module("./embeds", () => ({
|
||||
createErrorEmbed: mockCreateErrorEmbed,
|
||||
}));
|
||||
|
||||
// Import AFTER mocking
|
||||
const { withCommandErrorHandling } = await import("./commandUtils");
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe("withCommandErrorHandling", () => {
|
||||
let consoleErrorSpy: ReturnType<typeof spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockDeferReply.mockClear();
|
||||
mockEditReply.mockClear();
|
||||
mockCreateErrorEmbed.mockClear();
|
||||
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => { });
|
||||
});
|
||||
|
||||
it("should always call deferReply", async () => {
|
||||
await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => "result"
|
||||
);
|
||||
|
||||
expect(mockDeferReply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should pass ephemeral option to deferReply", async () => {
|
||||
await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => "result",
|
||||
{ ephemeral: true }
|
||||
);
|
||||
|
||||
expect(mockDeferReply).toHaveBeenCalledWith({ ephemeral: true });
|
||||
});
|
||||
|
||||
it("should return the operation result on success", async () => {
|
||||
const result = await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => ({ data: "test" })
|
||||
);
|
||||
|
||||
expect(result).toEqual({ data: "test" });
|
||||
});
|
||||
|
||||
it("should call onSuccess with the result", async () => {
|
||||
const onSuccess = mock(async (_result: string) => { });
|
||||
|
||||
await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => "hello",
|
||||
{ onSuccess }
|
||||
);
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("should send successMessage when no onSuccess is provided", async () => {
|
||||
await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => "result",
|
||||
{ successMessage: "It worked!" }
|
||||
);
|
||||
|
||||
expect(mockEditReply).toHaveBeenCalledWith({
|
||||
content: "It worked!",
|
||||
});
|
||||
});
|
||||
|
||||
it("should prefer onSuccess over successMessage", async () => {
|
||||
const onSuccess = mock(async (_result: string) => { });
|
||||
|
||||
await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => "result",
|
||||
{ successMessage: "This should not be sent", onSuccess }
|
||||
);
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1);
|
||||
// editReply should NOT have been called with the successMessage
|
||||
expect(mockEditReply).not.toHaveBeenCalledWith({
|
||||
content: "This should not be sent",
|
||||
});
|
||||
});
|
||||
|
||||
it("should show error embed for UserError", async () => {
|
||||
const result = await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => {
|
||||
throw new UserError("You can't do that!");
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockCreateErrorEmbed).toHaveBeenCalledWith("You can't do that!");
|
||||
expect(mockEditReply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should show generic error and log for unexpected errors", async () => {
|
||||
const unexpectedError = new Error("Database exploded");
|
||||
|
||||
const result = await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => {
|
||||
throw unexpectedError;
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Unexpected error in command:",
|
||||
unexpectedError
|
||||
);
|
||||
expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
|
||||
"An unexpected error occurred."
|
||||
);
|
||||
expect(mockEditReply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should return undefined on error", async () => {
|
||||
const result = await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => {
|
||||
throw new Error("fail");
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
79
bot/lib/commandUtils.ts
Normal file
79
bot/lib/commandUtils.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { ChatInputCommandInteraction } from "discord.js";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { createErrorEmbed } from "./embeds";
|
||||
|
||||
/**
|
||||
* Wraps a command's core logic with standardized error handling.
|
||||
*
|
||||
* - Calls `interaction.deferReply()` automatically
|
||||
* - On success, invokes `onSuccess` callback or sends `successMessage`
|
||||
* - On `UserError`, shows the error message in an error embed
|
||||
* - On unexpected errors, logs to console and shows a generic error embed
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* export const myCommand = createCommand({
|
||||
* execute: async (interaction) => {
|
||||
* await withCommandErrorHandling(
|
||||
* interaction,
|
||||
* async () => {
|
||||
* const result = await doSomething();
|
||||
* await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
||||
* }
|
||||
* );
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // With deferReply options (e.g. ephemeral)
|
||||
* await withCommandErrorHandling(
|
||||
* interaction,
|
||||
* async () => doSomething(),
|
||||
* {
|
||||
* ephemeral: true,
|
||||
* successMessage: "Done!",
|
||||
* }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export async function withCommandErrorHandling<T>(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
operation: () => Promise<T>,
|
||||
options?: {
|
||||
/** Message to send on success (if no onSuccess callback is provided) */
|
||||
successMessage?: string;
|
||||
/** Callback invoked with the operation result on success */
|
||||
onSuccess?: (result: T) => Promise<void>;
|
||||
/** Whether the deferred reply should be ephemeral */
|
||||
ephemeral?: boolean;
|
||||
}
|
||||
): Promise<T | undefined> {
|
||||
try {
|
||||
await interaction.deferReply({ ephemeral: options?.ephemeral });
|
||||
const result = await operation();
|
||||
|
||||
if (options?.onSuccess) {
|
||||
await options.onSuccess(result);
|
||||
} else if (options?.successMessage) {
|
||||
await interaction.editReply({
|
||||
content: options.successMessage,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(error.message)],
|
||||
});
|
||||
} else {
|
||||
console.error("Unexpected error in command:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("An unexpected error occurred.")],
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||
|
||||
// Mock DrizzleClient
|
||||
mock.module("./DrizzleClient", () => ({
|
||||
// Mock DrizzleClient — must match the import path used in db.ts
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: {
|
||||
transaction: async (cb: any) => cb("MOCK_TX")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
|
||||
@@ -25,6 +26,37 @@ export class CommandHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check beta feature access
|
||||
if (command.beta) {
|
||||
const flagName = command.featureFlag || interaction.commandName;
|
||||
let memberRoles: string[] = [];
|
||||
|
||||
if (interaction.member && 'roles' in interaction.member) {
|
||||
const roles = interaction.member.roles;
|
||||
if (typeof roles === 'object' && 'cache' in roles) {
|
||||
memberRoles = [...roles.cache.keys()];
|
||||
} else if (Array.isArray(roles)) {
|
||||
memberRoles = roles;
|
||||
}
|
||||
}
|
||||
|
||||
const hasAccess = await featureFlagsService.hasAccess(flagName, {
|
||||
guildId: interaction.guildId!,
|
||||
userId: interaction.user.id,
|
||||
memberRoles,
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
const errorEmbed = createErrorEmbed(
|
||||
"This feature is currently in beta testing and not available to all users. " +
|
||||
"Stay tuned for the official release!",
|
||||
"Beta Feature"
|
||||
);
|
||||
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure user exists in database
|
||||
try {
|
||||
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
|
||||
import { TRADE_CUSTOM_IDS } from "@modules/trade/trade.types";
|
||||
import { SHOP_CUSTOM_IDS, LOOTDROP_CUSTOM_IDS } from "@modules/economy/economy.types";
|
||||
import { ITEM_WIZARD_CUSTOM_IDS } from "@modules/admin/item_wizard.types";
|
||||
import { TRIVIA_CUSTOM_IDS } from "@modules/trivia/trivia.types";
|
||||
import { ENROLLMENT_CUSTOM_IDS } from "@modules/user/user.types";
|
||||
import { FEEDBACK_CUSTOM_IDS } from "@modules/feedback/feedback.types";
|
||||
|
||||
// Union type for all component interactions
|
||||
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
||||
|
||||
// Type for the handler function that modules export
|
||||
type InteractionHandler = (interaction: ComponentInteraction) => Promise<void>;
|
||||
|
||||
// Type for the dynamically imported module containing the handler
|
||||
interface InteractionModule {
|
||||
[key: string]: (...args: any[]) => Promise<void> | any;
|
||||
@@ -21,45 +24,45 @@ interface InteractionRoute {
|
||||
export const interactionRoutes: InteractionRoute[] = [
|
||||
// --- TRADE MODULE ---
|
||||
{
|
||||
predicate: (i) => i.customId.startsWith("trade_") || i.customId === "amount",
|
||||
predicate: (i) => i.customId.startsWith(TRADE_CUSTOM_IDS.PREFIX) || i.customId === TRADE_CUSTOM_IDS.MONEY_AMOUNT_FIELD,
|
||||
handler: () => import("@/modules/trade/trade.interaction"),
|
||||
method: 'handleTradeInteraction'
|
||||
},
|
||||
|
||||
// --- ECONOMY MODULE ---
|
||||
{
|
||||
predicate: (i) => i.isButton() && i.customId.startsWith("shop_buy_"),
|
||||
predicate: (i) => i.isButton() && i.customId.startsWith(SHOP_CUSTOM_IDS.BUY_PREFIX),
|
||||
handler: () => import("@/modules/economy/shop.interaction"),
|
||||
method: 'handleShopInteraction'
|
||||
},
|
||||
{
|
||||
predicate: (i) => i.isButton() && i.customId.startsWith("lootdrop_"),
|
||||
predicate: (i) => i.isButton() && i.customId.startsWith(LOOTDROP_CUSTOM_IDS.PREFIX),
|
||||
handler: () => import("@/modules/economy/lootdrop.interaction"),
|
||||
method: 'handleLootdropInteraction'
|
||||
},
|
||||
{
|
||||
predicate: (i) => i.isButton() && i.customId.startsWith("trivia_"),
|
||||
predicate: (i) => i.isButton() && i.customId.startsWith(TRIVIA_CUSTOM_IDS.PREFIX),
|
||||
handler: () => import("@/modules/trivia/trivia.interaction"),
|
||||
method: 'handleTriviaInteraction'
|
||||
},
|
||||
|
||||
// --- ADMIN MODULE ---
|
||||
{
|
||||
predicate: (i) => i.customId.startsWith("createitem_"),
|
||||
predicate: (i) => i.customId.startsWith(ITEM_WIZARD_CUSTOM_IDS.PREFIX),
|
||||
handler: () => import("@/modules/admin/item_wizard"),
|
||||
method: 'handleItemWizardInteraction'
|
||||
},
|
||||
|
||||
// --- USER MODULE ---
|
||||
{
|
||||
predicate: (i) => i.isButton() && i.customId === "enrollment",
|
||||
predicate: (i) => i.isButton() && i.customId === ENROLLMENT_CUSTOM_IDS.ENROLL,
|
||||
handler: () => import("@/modules/user/enrollment.interaction"),
|
||||
method: 'handleEnrollmentInteraction'
|
||||
},
|
||||
|
||||
// --- FEEDBACK MODULE ---
|
||||
{
|
||||
predicate: (i) => i.customId.startsWith("feedback_"),
|
||||
predicate: (i) => i.customId.startsWith(FEEDBACK_CUSTOM_IDS.PREFIX),
|
||||
handler: () => import("@/modules/feedback/feedback.interaction"),
|
||||
method: 'handleFeedbackInteraction'
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { items } from "@db/schema";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import type { ItemUsageData, ItemEffect } from "@shared/lib/types";
|
||||
import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
|
||||
import type { DraftItem } from "./item_wizard.types";
|
||||
import { ITEM_WIZARD_CUSTOM_IDS, type DraftItem } from "./item_wizard.types";
|
||||
import { ItemType, EffectType } from "@shared/lib/constants";
|
||||
|
||||
// --- Types ---
|
||||
@@ -23,7 +23,7 @@ export const renderWizard = (userId: string, isDraft = true) => {
|
||||
draft = {
|
||||
name: "New Item",
|
||||
description: "No description",
|
||||
rarity: "Common",
|
||||
rarity: "C",
|
||||
type: ItemType.MATERIAL,
|
||||
price: null,
|
||||
iconUrl: "",
|
||||
@@ -41,13 +41,13 @@ export const renderWizard = (userId: string, isDraft = true) => {
|
||||
export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
// Only handle createitem interactions
|
||||
if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return;
|
||||
if (!interaction.customId.startsWith("createitem_")) return;
|
||||
if (!interaction.customId.startsWith(ITEM_WIZARD_CUSTOM_IDS.PREFIX)) return;
|
||||
|
||||
const userId = interaction.user.id;
|
||||
let draft = draftSession.get(userId);
|
||||
|
||||
// Special case for Cancel - doesn't need draft checks usually, but we want to clear it
|
||||
if (interaction.customId === "createitem_cancel") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.CANCEL) {
|
||||
draftSession.delete(userId);
|
||||
if (interaction.isMessageComponent()) {
|
||||
await interaction.update({ content: "❌ Item creation cancelled.", embeds: [], components: [] });
|
||||
@@ -59,7 +59,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
if (!draft) {
|
||||
if (interaction.isMessageComponent()) {
|
||||
// Create one implicitly to prevent crashes, or warn user
|
||||
if (interaction.customId === "createitem_start") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.START) {
|
||||
// Allow start
|
||||
} else {
|
||||
await interaction.reply({ content: "⚠️ Session expired. Please run `/createitem` again.", ephemeral: true });
|
||||
@@ -81,7 +81,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
// --- Routing ---
|
||||
|
||||
// 1. Details Modal
|
||||
if (interaction.customId === "createitem_details") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.DETAILS) {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = getDetailsModal(draft);
|
||||
await interaction.showModal(modal);
|
||||
@@ -89,7 +89,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
}
|
||||
|
||||
// 2. Economy Modal
|
||||
if (interaction.customId === "createitem_economy") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.ECONOMY) {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = getEconomyModal(draft);
|
||||
await interaction.showModal(modal);
|
||||
@@ -97,7 +97,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
}
|
||||
|
||||
// 3. Visuals Modal
|
||||
if (interaction.customId === "createitem_visuals") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.VISUALS) {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = getVisualsModal(draft);
|
||||
await interaction.showModal(modal);
|
||||
@@ -105,14 +105,14 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
}
|
||||
|
||||
// 4. Type Toggle (Start Select Menu)
|
||||
if (interaction.customId === "createitem_type_toggle") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.TYPE_TOGGLE) {
|
||||
if (!interaction.isButton()) return;
|
||||
const { components } = getItemTypeSelection();
|
||||
await interaction.update({ components }); // Temporary view
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.customId === "createitem_select_type") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.SELECT_TYPE) {
|
||||
if (!interaction.isStringSelectMenu()) return;
|
||||
const selected = interaction.values[0];
|
||||
if (selected) {
|
||||
@@ -125,14 +125,14 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
}
|
||||
|
||||
// 5. Add Effect Flow
|
||||
if (interaction.customId === "createitem_addeffect_start") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.ADD_EFFECT_START) {
|
||||
if (!interaction.isButton()) return;
|
||||
const { components } = getEffectTypeSelection();
|
||||
await interaction.update({ components });
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.customId === "createitem_select_effect_type") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.SELECT_EFFECT_TYPE) {
|
||||
if (!interaction.isStringSelectMenu()) return;
|
||||
const effectType = interaction.values[0];
|
||||
if (!effectType) return;
|
||||
@@ -149,7 +149,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
}
|
||||
|
||||
// Toggle Consume
|
||||
if (interaction.customId === "createitem_toggle_consume") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.TOGGLE_CONSUME) {
|
||||
if (!interaction.isButton()) return;
|
||||
draft.usageData.consume = !draft.usageData.consume;
|
||||
const payload = renderWizard(userId);
|
||||
@@ -159,43 +159,43 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
|
||||
// 6. Handle Modal Submits
|
||||
if (interaction.isModalSubmit()) {
|
||||
if (interaction.customId === "createitem_modal_details") {
|
||||
draft.name = interaction.fields.getTextInputValue("name");
|
||||
draft.description = interaction.fields.getTextInputValue("desc");
|
||||
draft.rarity = interaction.fields.getTextInputValue("rarity");
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_DETAILS) {
|
||||
draft.name = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_NAME);
|
||||
draft.description = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_DESC);
|
||||
draft.rarity = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_RARITY);
|
||||
}
|
||||
else if (interaction.customId === "createitem_modal_economy") {
|
||||
const price = parseInt(interaction.fields.getTextInputValue("price"));
|
||||
else if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_ECONOMY) {
|
||||
const price = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_PRICE));
|
||||
draft.price = isNaN(price) || price === 0 ? null : price;
|
||||
}
|
||||
else if (interaction.customId === "createitem_modal_visuals") {
|
||||
draft.iconUrl = interaction.fields.getTextInputValue("icon");
|
||||
draft.imageUrl = interaction.fields.getTextInputValue("image");
|
||||
else if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_VISUALS) {
|
||||
draft.iconUrl = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_ICON);
|
||||
draft.imageUrl = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_IMAGE);
|
||||
}
|
||||
else if (interaction.customId === "createitem_modal_effect") {
|
||||
else if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_EFFECT) {
|
||||
const type = draft.pendingEffectType;
|
||||
if (type) {
|
||||
let effect: ItemEffect | null = null;
|
||||
|
||||
if (type === EffectType.ADD_XP || type === EffectType.ADD_BALANCE) {
|
||||
const amount = parseInt(interaction.fields.getTextInputValue("amount"));
|
||||
const amount = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_AMOUNT));
|
||||
if (!isNaN(amount)) effect = { type: type as any, amount };
|
||||
}
|
||||
else if (type === EffectType.REPLY_MESSAGE) {
|
||||
effect = { type: EffectType.REPLY_MESSAGE, message: interaction.fields.getTextInputValue("message") };
|
||||
effect = { type: EffectType.REPLY_MESSAGE, message: interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_MESSAGE) };
|
||||
}
|
||||
else if (type === EffectType.XP_BOOST) {
|
||||
const multiplier = parseFloat(interaction.fields.getTextInputValue("multiplier"));
|
||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
||||
const multiplier = parseFloat(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_MULTIPLIER));
|
||||
const duration = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_DURATION));
|
||||
if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: EffectType.XP_BOOST, multiplier, durationSeconds: duration };
|
||||
}
|
||||
else if (type === EffectType.TEMP_ROLE) {
|
||||
const roleId = interaction.fields.getTextInputValue("role_id");
|
||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
||||
const roleId = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID);
|
||||
const duration = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_DURATION));
|
||||
if (roleId && !isNaN(duration)) effect = { type: EffectType.TEMP_ROLE, roleId: roleId, durationSeconds: duration };
|
||||
}
|
||||
else if (type === EffectType.COLOR_ROLE) {
|
||||
const roleId = interaction.fields.getTextInputValue("role_id");
|
||||
const roleId = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID);
|
||||
if (roleId) effect = { type: EffectType.COLOR_ROLE, roleId: roleId };
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
}
|
||||
|
||||
// 7. Save
|
||||
if (interaction.customId === "createitem_save") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.SAVE) {
|
||||
if (!interaction.isButton()) return;
|
||||
|
||||
await interaction.deferUpdate(); // Prepare to save
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
import type { ItemUsageData } from "@shared/lib/types";
|
||||
|
||||
export const ITEM_WIZARD_CUSTOM_IDS = {
|
||||
PREFIX: "createitem_",
|
||||
START: "createitem_start",
|
||||
DETAILS: "createitem_details",
|
||||
ECONOMY: "createitem_economy",
|
||||
VISUALS: "createitem_visuals",
|
||||
TYPE_TOGGLE: "createitem_type_toggle",
|
||||
SELECT_TYPE: "createitem_select_type",
|
||||
ADD_EFFECT_START: "createitem_addeffect_start",
|
||||
SELECT_EFFECT_TYPE: "createitem_select_effect_type",
|
||||
TOGGLE_CONSUME: "createitem_toggle_consume",
|
||||
SAVE: "createitem_save",
|
||||
CANCEL: "createitem_cancel",
|
||||
MODAL_DETAILS: "createitem_modal_details",
|
||||
MODAL_ECONOMY: "createitem_modal_economy",
|
||||
MODAL_VISUALS: "createitem_modal_visuals",
|
||||
MODAL_EFFECT: "createitem_modal_effect",
|
||||
// Modal field IDs
|
||||
FIELD_NAME: "name",
|
||||
FIELD_DESC: "desc",
|
||||
FIELD_RARITY: "rarity",
|
||||
FIELD_PRICE: "price",
|
||||
FIELD_ICON: "icon",
|
||||
FIELD_IMAGE: "image",
|
||||
FIELD_AMOUNT: "amount",
|
||||
FIELD_MESSAGE: "message",
|
||||
FIELD_MULTIPLIER: "multiplier",
|
||||
FIELD_DURATION: "duration",
|
||||
FIELD_ROLE_ID: "role_id",
|
||||
} as const;
|
||||
|
||||
export interface DraftItem {
|
||||
name: string;
|
||||
description: string;
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type MessageActionRowComponentBuilder
|
||||
} from "discord.js";
|
||||
import { createBaseEmbed } from "@lib/embeds";
|
||||
import type { DraftItem } from "./item_wizard.types";
|
||||
import { ITEM_WIZARD_CUSTOM_IDS, type DraftItem } from "./item_wizard.types";
|
||||
import { ItemType } from "@shared/lib/constants";
|
||||
|
||||
const getItemTypeOptions = () => [
|
||||
@@ -51,18 +51,18 @@ export const getItemWizardEmbed = (draft: DraftItem) => {
|
||||
// Components
|
||||
const row1 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder().setCustomId("createitem_details").setLabel("Edit Details").setStyle(ButtonStyle.Secondary).setEmoji("📝"),
|
||||
new ButtonBuilder().setCustomId("createitem_economy").setLabel("Edit Economy").setStyle(ButtonStyle.Secondary).setEmoji("💰"),
|
||||
new ButtonBuilder().setCustomId("createitem_visuals").setLabel("Edit Visuals").setStyle(ButtonStyle.Secondary).setEmoji("🖼️"),
|
||||
new ButtonBuilder().setCustomId("createitem_type_toggle").setLabel("Change Type").setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
|
||||
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.DETAILS).setLabel("Edit Details").setStyle(ButtonStyle.Secondary).setEmoji("📝"),
|
||||
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.ECONOMY).setLabel("Edit Economy").setStyle(ButtonStyle.Secondary).setEmoji("💰"),
|
||||
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.VISUALS).setLabel("Edit Visuals").setStyle(ButtonStyle.Secondary).setEmoji("🖼️"),
|
||||
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.TYPE_TOGGLE).setLabel("Change Type").setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
|
||||
);
|
||||
|
||||
const row2 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder().setCustomId("createitem_addeffect_start").setLabel("Add Effect").setStyle(ButtonStyle.Primary).setEmoji("✨"),
|
||||
new ButtonBuilder().setCustomId("createitem_toggle_consume").setLabel(`Consume: ${draft.usageData.consume ? "ON" : "OFF"}`).setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
|
||||
new ButtonBuilder().setCustomId("createitem_save").setLabel("Save Item").setStyle(ButtonStyle.Success).setEmoji("💾"),
|
||||
new ButtonBuilder().setCustomId("createitem_cancel").setLabel("Cancel").setStyle(ButtonStyle.Danger).setEmoji("✖️")
|
||||
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.ADD_EFFECT_START).setLabel("Add Effect").setStyle(ButtonStyle.Primary).setEmoji("✨"),
|
||||
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.TOGGLE_CONSUME).setLabel(`Consume: ${draft.usageData.consume ? "ON" : "OFF"}`).setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
|
||||
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.SAVE).setLabel("Save Item").setStyle(ButtonStyle.Success).setEmoji("💾"),
|
||||
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.CANCEL).setLabel("Cancel").setStyle(ButtonStyle.Danger).setEmoji("✖️")
|
||||
);
|
||||
|
||||
return { embeds: [embed], components: [row1, row2] };
|
||||
@@ -70,65 +70,65 @@ export const getItemWizardEmbed = (draft: DraftItem) => {
|
||||
|
||||
export const getItemTypeSelection = () => {
|
||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||
new StringSelectMenuBuilder().setCustomId("createitem_select_type").setPlaceholder("Select Item Type").addOptions(getItemTypeOptions())
|
||||
new StringSelectMenuBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.SELECT_TYPE).setPlaceholder("Select Item Type").addOptions(getItemTypeOptions())
|
||||
);
|
||||
return { components: [row] };
|
||||
};
|
||||
|
||||
export const getEffectTypeSelection = () => {
|
||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||
new StringSelectMenuBuilder().setCustomId("createitem_select_effect_type").setPlaceholder("Select Effect Type").addOptions(getEffectTypeOptions())
|
||||
new StringSelectMenuBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.SELECT_EFFECT_TYPE).setPlaceholder("Select Effect Type").addOptions(getEffectTypeOptions())
|
||||
);
|
||||
return { components: [row] };
|
||||
};
|
||||
|
||||
export const getDetailsModal = (current: DraftItem) => {
|
||||
const modal = new ModalBuilder().setCustomId("createitem_modal_details").setTitle("Edit Details");
|
||||
const modal = new ModalBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.MODAL_DETAILS).setTitle("Edit Details");
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("name").setLabel("Name").setValue(current.name).setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("desc").setLabel("Description").setValue(current.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("Common, Rare, Legendary...").setRequired(true))
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_NAME).setLabel("Name").setValue(current.name).setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_DESC).setLabel("Description").setValue(current.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_RARITY).setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("C, R, SR, SSR").setRequired(true))
|
||||
);
|
||||
return modal;
|
||||
};
|
||||
|
||||
export const getEconomyModal = (current: DraftItem) => {
|
||||
const modal = new ModalBuilder().setCustomId("createitem_modal_economy").setTitle("Edit Economy");
|
||||
const modal = new ModalBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.MODAL_ECONOMY).setTitle("Edit Economy");
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("price").setLabel("Price (0 for not for sale)").setValue(current.price?.toString() || "0").setStyle(TextInputStyle.Short).setRequired(true))
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_PRICE).setLabel("Price (0 for not for sale)").setValue(current.price?.toString() || "0").setStyle(TextInputStyle.Short).setRequired(true))
|
||||
);
|
||||
return modal;
|
||||
};
|
||||
|
||||
export const getVisualsModal = (current: DraftItem) => {
|
||||
const modal = new ModalBuilder().setCustomId("createitem_modal_visuals").setTitle("Edit Visuals");
|
||||
const modal = new ModalBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.MODAL_VISUALS).setTitle("Edit Visuals");
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("icon").setLabel("Icon URL (Emoji or Link)").setValue(current.iconUrl).setStyle(TextInputStyle.Short).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("image").setLabel("Image URL").setValue(current.imageUrl).setStyle(TextInputStyle.Short).setRequired(false))
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_ICON).setLabel("Icon URL (Emoji or Link)").setValue(current.iconUrl).setStyle(TextInputStyle.Short).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_IMAGE).setLabel("Image URL").setValue(current.imageUrl).setStyle(TextInputStyle.Short).setRequired(false))
|
||||
);
|
||||
return modal;
|
||||
};
|
||||
|
||||
export const getEffectConfigModal = (effectType: string) => {
|
||||
let modal = new ModalBuilder().setCustomId("createitem_modal_effect").setTitle(`Config ${effectType}`);
|
||||
let modal = new ModalBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.MODAL_EFFECT).setTitle(`Config ${effectType}`);
|
||||
|
||||
if (effectType === "ADD_XP" || effectType === "ADD_BALANCE") {
|
||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("amount").setLabel("Amount").setStyle(TextInputStyle.Short).setRequired(true).setPlaceholder("100")));
|
||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_AMOUNT).setLabel("Amount").setStyle(TextInputStyle.Short).setRequired(true).setPlaceholder("100")));
|
||||
} else if (effectType === "REPLY_MESSAGE") {
|
||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("message").setLabel("Message").setStyle(TextInputStyle.Paragraph).setRequired(true)));
|
||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_MESSAGE).setLabel("Message").setStyle(TextInputStyle.Paragraph).setRequired(true)));
|
||||
} else if (effectType === "XP_BOOST") {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("multiplier").setLabel("Multiplier (e.g. 1.5)").setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_MULTIPLIER).setLabel("Multiplier (e.g. 1.5)").setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_DURATION).setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||
);
|
||||
} else if (effectType === "TEMP_ROLE") {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID).setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_DURATION).setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||
);
|
||||
} else if (effectType === "COLOR_ROLE") {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true))
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID).setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true))
|
||||
);
|
||||
}
|
||||
return modal;
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
|
||||
export interface RestartContext {
|
||||
channelId: string;
|
||||
userId: string;
|
||||
timestamp: number;
|
||||
runMigrations: boolean;
|
||||
installDependencies: boolean;
|
||||
buildWebAssets: boolean;
|
||||
previousCommit: string;
|
||||
newCommit: string;
|
||||
}
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
needsRootInstall: boolean;
|
||||
needsWebInstall: boolean;
|
||||
needsWebBuild: boolean;
|
||||
needsMigrations: boolean;
|
||||
changedFiles: string[];
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
hasUpdates: boolean;
|
||||
branch: string;
|
||||
currentCommit: string;
|
||||
latestCommit: string;
|
||||
commitCount: number;
|
||||
commits: CommitInfo[];
|
||||
}
|
||||
|
||||
export interface CommitInfo {
|
||||
hash: string;
|
||||
message: string;
|
||||
author: string;
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js";
|
||||
import { createInfoEmbed, createSuccessEmbed, createWarningEmbed, createErrorEmbed } from "@lib/embeds";
|
||||
import type { UpdateInfo, UpdateCheckResult } from "./update.types";
|
||||
|
||||
// Constants for UI
|
||||
const LOG_TRUNCATE_LENGTH = 800;
|
||||
const OUTPUT_TRUNCATE_LENGTH = 400;
|
||||
|
||||
function truncate(text: string, maxLength: number): string {
|
||||
if (!text) return "";
|
||||
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
|
||||
}
|
||||
|
||||
// ============ Pre-Update Embeds ============
|
||||
|
||||
export function getCheckingEmbed() {
|
||||
return createInfoEmbed("🔍 Fetching latest changes from remote...", "Checking for Updates");
|
||||
}
|
||||
|
||||
export function getNoUpdatesEmbed(currentCommit: string) {
|
||||
return createSuccessEmbed(
|
||||
`You're running the latest version.\n\n**Current:** \`${currentCommit}\``,
|
||||
"✅ Already Up to Date"
|
||||
);
|
||||
}
|
||||
|
||||
export function getUpdatesAvailableMessage(
|
||||
updateInfo: UpdateInfo,
|
||||
requirements: UpdateCheckResult,
|
||||
changeCategories: Record<string, number>,
|
||||
force: boolean
|
||||
) {
|
||||
const { branch, currentCommit, latestCommit, commitCount, commits } = updateInfo;
|
||||
const { needsRootInstall, needsWebInstall, needsWebBuild, needsMigrations } = requirements;
|
||||
|
||||
// Build commit list (max 5)
|
||||
const commitList = commits
|
||||
.slice(0, 5)
|
||||
.map(c => `\`${c.hash}\` ${truncate(c.message, 50)}`)
|
||||
.join("\n");
|
||||
|
||||
const moreCommits = commitCount > 5 ? `\n*...and ${commitCount - 5} more*` : "";
|
||||
|
||||
// Build change categories
|
||||
const categoryList = Object.entries(changeCategories)
|
||||
.map(([cat, count]) => `• ${cat}: ${count} file${count > 1 ? "s" : ""}`)
|
||||
.join("\n");
|
||||
|
||||
// Build requirements list
|
||||
const reqs: string[] = [];
|
||||
if (needsRootInstall) reqs.push("📦 Install root dependencies");
|
||||
if (needsWebInstall) reqs.push("🌐 Install web dependencies");
|
||||
if (needsWebBuild) reqs.push("🏗️ Build web dashboard");
|
||||
if (needsMigrations) reqs.push("🗃️ Run database migrations");
|
||||
if (reqs.length === 0) reqs.push("⚡ Quick update (no extra steps)");
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("📥 Updates Available")
|
||||
.setColor(force ? 0xFF6B6B : 0x5865F2)
|
||||
.addFields(
|
||||
{
|
||||
name: "Version",
|
||||
value: `\`${currentCommit}\` → \`${latestCommit}\``,
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: "Branch",
|
||||
value: `\`${branch}\``,
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: "Commits",
|
||||
value: `${commitCount} new commit${commitCount > 1 ? "s" : ""}`,
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: "Recent Changes",
|
||||
value: commitList + moreCommits || "No commits",
|
||||
inline: false
|
||||
},
|
||||
{
|
||||
name: "Files Changed",
|
||||
value: categoryList || "Unknown",
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: "Update Actions",
|
||||
value: reqs.join("\n"),
|
||||
inline: true
|
||||
}
|
||||
)
|
||||
.setFooter({ text: force ? "⚠️ Force mode enabled" : "This will restart the bot" })
|
||||
.setTimestamp();
|
||||
|
||||
const confirmButton = new ButtonBuilder()
|
||||
.setCustomId("confirm_update")
|
||||
.setLabel(force ? "Force Update" : "Update Now")
|
||||
.setEmoji(force ? "⚠️" : "🚀")
|
||||
.setStyle(force ? ButtonStyle.Danger : ButtonStyle.Success);
|
||||
|
||||
const cancelButton = new ButtonBuilder()
|
||||
.setCustomId("cancel_update")
|
||||
.setLabel("Cancel")
|
||||
.setStyle(ButtonStyle.Secondary);
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(confirmButton, cancelButton);
|
||||
|
||||
return { embeds: [embed], components: [row] };
|
||||
}
|
||||
|
||||
// ============ Update Progress Embeds ============
|
||||
|
||||
export function getPreparingEmbed() {
|
||||
return createInfoEmbed(
|
||||
"🔒 Saving rollback point...\n📥 Preparing to download updates...",
|
||||
"⏳ Preparing Update"
|
||||
);
|
||||
}
|
||||
|
||||
export function getUpdatingEmbed(requirements: UpdateCheckResult) {
|
||||
const steps: string[] = ["✅ Rollback point saved"];
|
||||
|
||||
steps.push("📥 Downloading updates...");
|
||||
|
||||
if (requirements.needsRootInstall || requirements.needsWebInstall) {
|
||||
steps.push("📦 Dependencies will be installed after restart");
|
||||
}
|
||||
if (requirements.needsWebBuild) {
|
||||
steps.push("🏗️ Web dashboard will be rebuilt after restart");
|
||||
}
|
||||
if (requirements.needsMigrations) {
|
||||
steps.push("🗃️ Migrations will run after restart");
|
||||
}
|
||||
|
||||
steps.push("\n🔄 **Restarting now...**");
|
||||
|
||||
return createWarningEmbed(steps.join("\n"), "🚀 Updating");
|
||||
}
|
||||
|
||||
export function getCancelledEmbed() {
|
||||
return createInfoEmbed("Update cancelled. No changes were made.", "❌ Cancelled");
|
||||
}
|
||||
|
||||
export function getTimeoutEmbed() {
|
||||
return createWarningEmbed(
|
||||
"No response received within 30 seconds.\nRun `/update` again when ready.",
|
||||
"⏰ Timed Out"
|
||||
);
|
||||
}
|
||||
|
||||
export function getErrorEmbed(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return createErrorEmbed(
|
||||
`The update could not be completed:\n\`\`\`\n${truncate(message, 500)}\n\`\`\``,
|
||||
"❌ Update Failed"
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Post-Restart Embeds ============
|
||||
|
||||
export interface PostRestartResult {
|
||||
installSuccess: boolean;
|
||||
installOutput: string;
|
||||
webBuildSuccess: boolean;
|
||||
webBuildOutput: string;
|
||||
migrationSuccess: boolean;
|
||||
migrationOutput: string;
|
||||
ranInstall: boolean;
|
||||
ranWebBuild: boolean;
|
||||
ranMigrations: boolean;
|
||||
previousCommit?: string;
|
||||
newCommit?: string;
|
||||
}
|
||||
|
||||
export function getPostRestartEmbed(result: PostRestartResult, hasRollback: boolean) {
|
||||
const isSuccess = result.installSuccess && result.webBuildSuccess && result.migrationSuccess;
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(isSuccess ? "✅ Update Complete" : "⚠️ Update Completed with Issues")
|
||||
.setColor(isSuccess ? 0x57F287 : 0xFEE75C)
|
||||
.setTimestamp();
|
||||
|
||||
// Version info
|
||||
if (result.previousCommit && result.newCommit) {
|
||||
embed.addFields({
|
||||
name: "Version",
|
||||
value: `\`${result.previousCommit}\` → \`${result.newCommit}\``,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
// Results summary
|
||||
const results: string[] = [];
|
||||
|
||||
if (result.ranInstall) {
|
||||
results.push(result.installSuccess
|
||||
? "✅ Dependencies installed"
|
||||
: "❌ Dependency installation failed"
|
||||
);
|
||||
}
|
||||
|
||||
if (result.ranWebBuild) {
|
||||
results.push(result.webBuildSuccess
|
||||
? "✅ Web dashboard built"
|
||||
: "❌ Web dashboard build failed"
|
||||
);
|
||||
}
|
||||
|
||||
if (result.ranMigrations) {
|
||||
results.push(result.migrationSuccess
|
||||
? "✅ Migrations applied"
|
||||
: "❌ Migration failed"
|
||||
);
|
||||
}
|
||||
|
||||
if (results.length > 0) {
|
||||
embed.addFields({
|
||||
name: "Actions Performed",
|
||||
value: results.join("\n"),
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
// Output details (collapsed if too long)
|
||||
if (result.installOutput && !result.installSuccess) {
|
||||
embed.addFields({
|
||||
name: "Install Output",
|
||||
value: `\`\`\`\n${truncate(result.installOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
if (result.webBuildOutput && !result.webBuildSuccess) {
|
||||
embed.addFields({
|
||||
name: "Web Build Output",
|
||||
value: `\`\`\`\n${truncate(result.webBuildOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
if (result.migrationOutput && !result.migrationSuccess) {
|
||||
embed.addFields({
|
||||
name: "Migration Output",
|
||||
value: `\`\`\`\n${truncate(result.migrationOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
// Footer with rollback hint
|
||||
if (!isSuccess && hasRollback) {
|
||||
embed.setFooter({ text: "💡 Use /update rollback to revert if needed" });
|
||||
}
|
||||
|
||||
// Build components
|
||||
const components: ActionRowBuilder<ButtonBuilder>[] = [];
|
||||
|
||||
if (!isSuccess && hasRollback) {
|
||||
const rollbackButton = new ButtonBuilder()
|
||||
.setCustomId("rollback_update")
|
||||
.setLabel("Rollback")
|
||||
.setEmoji("↩️")
|
||||
.setStyle(ButtonStyle.Danger);
|
||||
|
||||
components.push(new ActionRowBuilder<ButtonBuilder>().addComponents(rollbackButton));
|
||||
}
|
||||
|
||||
return { embeds: [embed], components };
|
||||
}
|
||||
|
||||
export function getInstallingDependenciesEmbed() {
|
||||
return createInfoEmbed(
|
||||
"📦 Installing dependencies for root and web projects...\nThis may take a moment.",
|
||||
"⏳ Installing Dependencies"
|
||||
);
|
||||
}
|
||||
|
||||
export function getRunningMigrationsEmbed() {
|
||||
return createInfoEmbed(
|
||||
"🗃️ Applying database migrations...",
|
||||
"⏳ Running Migrations"
|
||||
);
|
||||
}
|
||||
|
||||
export function getBuildingWebEmbed() {
|
||||
return createInfoEmbed(
|
||||
"🌐 Building web dashboard assets...\nThis may take a moment.",
|
||||
"⏳ Building Web Dashboard"
|
||||
);
|
||||
}
|
||||
|
||||
export interface PostRestartProgress {
|
||||
installDeps: boolean;
|
||||
buildWeb: boolean;
|
||||
runMigrations: boolean;
|
||||
currentStep: "starting" | "install" | "build" | "migrate" | "done";
|
||||
installDone?: boolean;
|
||||
buildDone?: boolean;
|
||||
migrateDone?: boolean;
|
||||
}
|
||||
|
||||
export function getPostRestartProgressEmbed(progress: PostRestartProgress) {
|
||||
const steps: string[] = [];
|
||||
|
||||
// Installation step
|
||||
if (progress.installDeps) {
|
||||
if (progress.currentStep === "install") {
|
||||
steps.push("⏳ Installing dependencies...");
|
||||
} else if (progress.installDone) {
|
||||
steps.push("✅ Dependencies installed");
|
||||
} else {
|
||||
steps.push("⬚ Install dependencies");
|
||||
}
|
||||
}
|
||||
|
||||
// Web build step
|
||||
if (progress.buildWeb) {
|
||||
if (progress.currentStep === "build") {
|
||||
steps.push("⏳ Building web dashboard...");
|
||||
} else if (progress.buildDone) {
|
||||
steps.push("✅ Web dashboard built");
|
||||
} else {
|
||||
steps.push("⬚ Build web dashboard");
|
||||
}
|
||||
}
|
||||
|
||||
// Migrations step
|
||||
if (progress.runMigrations) {
|
||||
if (progress.currentStep === "migrate") {
|
||||
steps.push("⏳ Running migrations...");
|
||||
} else if (progress.migrateDone) {
|
||||
steps.push("✅ Migrations applied");
|
||||
} else {
|
||||
steps.push("⬚ Run migrations");
|
||||
}
|
||||
}
|
||||
|
||||
if (steps.length === 0) {
|
||||
steps.push("⚡ Quick restart (no extra steps needed)");
|
||||
}
|
||||
|
||||
return createInfoEmbed(steps.join("\n"), "🔄 Post-Update Tasks");
|
||||
}
|
||||
|
||||
export function getRollbackSuccessEmbed(commit: string) {
|
||||
return createSuccessEmbed(
|
||||
`Successfully rolled back to commit \`${commit}\`.\nThe bot will restart now.`,
|
||||
"↩️ Rollback Complete"
|
||||
);
|
||||
}
|
||||
|
||||
export function getRollbackFailedEmbed(error: string) {
|
||||
return createErrorEmbed(
|
||||
`Could not rollback:\n\`\`\`\n${error}\n\`\`\``,
|
||||
"❌ Rollback Failed"
|
||||
);
|
||||
}
|
||||
10
bot/modules/economy/economy.types.ts
Normal file
10
bot/modules/economy/economy.types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const LOOTDROP_CUSTOM_IDS = {
|
||||
PREFIX: "lootdrop_",
|
||||
CLAIM: "lootdrop_claim",
|
||||
CLAIM_DISABLED: "lootdrop_claim_disabled",
|
||||
} as const;
|
||||
|
||||
export const SHOP_CUSTOM_IDS = {
|
||||
BUY_PREFIX: "shop_buy_",
|
||||
BUY: (itemId: number) => `shop_buy_${itemId}`,
|
||||
} as const;
|
||||
60
bot/modules/economy/lootdrop.handler.ts
Normal file
60
bot/modules/economy/lootdrop.handler.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Message, TextChannel } from "discord.js";
|
||||
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
|
||||
import { getLootdropMessage } from "./lootdrop.view";
|
||||
import { terminalService } from "@modules/system/terminal.service";
|
||||
|
||||
/**
|
||||
* Process a Discord message for lootdrop activity tracking.
|
||||
* Called from messageCreate event handler.
|
||||
*/
|
||||
export async function processLootdropMessage(message: Message): Promise<void> {
|
||||
if (message.author.bot || !message.guild) return;
|
||||
|
||||
const { shouldSpawn } = lootdropService.trackActivity(message.channel.id);
|
||||
|
||||
if (shouldSpawn) {
|
||||
await spawnLootdrop(message.channel as TextChannel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a lootdrop in a Discord channel.
|
||||
* Used by both bot events and API routes.
|
||||
*/
|
||||
export async function spawnLootdrop(
|
||||
channel: TextChannel,
|
||||
overrideReward?: number,
|
||||
overrideCurrency?: string
|
||||
): Promise<void> {
|
||||
const { reward, currency } = lootdropService.calculateReward(overrideReward, overrideCurrency);
|
||||
const { content, files, components } = await getLootdropMessage(reward, currency);
|
||||
|
||||
try {
|
||||
const sentMessage = await channel.send({ content, files, components });
|
||||
await lootdropService.persistLootdrop(sentMessage.id, channel.id, reward, currency);
|
||||
terminalService.update(channel.guildId);
|
||||
} catch (error) {
|
||||
console.error("Failed to spawn lootdrop:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a lootdrop from DB and Discord.
|
||||
*/
|
||||
export async function deleteLootdrop(messageId: string): Promise<boolean> {
|
||||
const result = await lootdropService.removeLootdrop(messageId);
|
||||
if (!result) return false;
|
||||
|
||||
try {
|
||||
const { AuroraClient } = await import("@/lib/BotClient");
|
||||
const channel = await AuroraClient.channels.fetch(result.channelId) as TextChannel;
|
||||
if (channel) {
|
||||
const message = await channel.messages.fetch(messageId);
|
||||
if (message) await message.delete();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Could not delete lootdrop message from Discord:", e);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import { ButtonInteraction } from "discord.js";
|
||||
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { getLootdropClaimedMessage } from "./lootdrop.view";
|
||||
import { terminalService } from "@modules/system/terminal.service";
|
||||
import { LOOTDROP_CUSTOM_IDS } from "./economy.types";
|
||||
|
||||
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
|
||||
if (interaction.customId === "lootdrop_claim") {
|
||||
if (interaction.customId === LOOTDROP_CUSTOM_IDS.CLAIM) {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
const result = await lootdropService.tryClaim(interaction.message.id, interaction.user.id, interaction.user.username);
|
||||
@@ -13,6 +15,9 @@ export async function handleLootdropInteraction(interaction: ButtonInteraction)
|
||||
throw new UserError(result.error || "Failed to claim.");
|
||||
}
|
||||
|
||||
// Update terminal display after successful claim
|
||||
terminalService.update();
|
||||
|
||||
await interaction.editReply({
|
||||
content: `🎉 You successfully claimed **${result.amount} ${result.currency}**!`
|
||||
});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
|
||||
import { generateLootdropCard, generateClaimedLootdropCard } from "@/graphics/lootdrop";
|
||||
import { LOOTDROP_CUSTOM_IDS } from "./economy.types";
|
||||
|
||||
export async function getLootdropMessage(reward: number, currency: string) {
|
||||
const cardBuffer = await generateLootdropCard(reward, currency);
|
||||
const attachment = new AttachmentBuilder(cardBuffer, { name: "lootdrop.png" });
|
||||
|
||||
const claimButton = new ButtonBuilder()
|
||||
.setCustomId("lootdrop_claim")
|
||||
.setCustomId(LOOTDROP_CUSTOM_IDS.CLAIM)
|
||||
.setLabel("CLAIM REWARD")
|
||||
.setStyle(ButtonStyle.Secondary) // Changed to Secondary to fit the darker theme better? Or keep Success? Let's try Secondary with custom emoji
|
||||
.setEmoji("🌠");
|
||||
@@ -28,7 +29,7 @@ export async function getLootdropClaimedMessage(userId: string, username: string
|
||||
const newRow = new ActionRowBuilder<ButtonBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId("lootdrop_claim_disabled")
|
||||
.setCustomId(LOOTDROP_CUSTOM_IDS.CLAIM_DISABLED)
|
||||
.setLabel("CLAIMED")
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji("✅")
|
||||
|
||||
@@ -2,13 +2,14 @@ import { ButtonInteraction, MessageFlags } from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { SHOP_CUSTOM_IDS } from "./economy.types";
|
||||
|
||||
export async function handleShopInteraction(interaction: ButtonInteraction) {
|
||||
if (!interaction.customId.startsWith("shop_buy_")) return;
|
||||
if (!interaction.customId.startsWith(SHOP_CUSTOM_IDS.BUY_PREFIX)) return;
|
||||
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const itemId = parseInt(interaction.customId.replace("shop_buy_", ""));
|
||||
const itemId = parseInt(interaction.customId.replace(SHOP_CUSTOM_IDS.BUY_PREFIX, ""));
|
||||
if (isNaN(itemId)) {
|
||||
throw new UserError("Invalid Item ID.");
|
||||
}
|
||||
|
||||
@@ -1,20 +1,216 @@
|
||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
|
||||
import { createBaseEmbed } from "@/lib/embeds";
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
AttachmentBuilder,
|
||||
ContainerBuilder,
|
||||
SectionBuilder,
|
||||
TextDisplayBuilder,
|
||||
MediaGalleryBuilder,
|
||||
MediaGalleryItemBuilder,
|
||||
ThumbnailBuilder,
|
||||
SeparatorBuilder,
|
||||
SeparatorSpacingSize,
|
||||
MessageFlags
|
||||
} from "discord.js";
|
||||
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
|
||||
import { join } from "path";
|
||||
import { existsSync } from "fs";
|
||||
import { LootType, EffectType } from "@shared/lib/constants";
|
||||
import type { LootTableItem } from "@shared/lib/types";
|
||||
import { getRarityConfig, defaultName, stripQuery } from "@shared/lib/rarity";
|
||||
import { SHOP_CUSTOM_IDS } from "./economy.types";
|
||||
|
||||
export function getShopListingMessage(item: { id: number; name: string; description: string | null; formattedPrice: string; iconUrl: string | null; imageUrl: string | null; price: number | bigint }) {
|
||||
const embed = createBaseEmbed(`Shop: ${item.name}`, item.description || "No description available.", "Green")
|
||||
.addFields({ name: "Price", value: item.formattedPrice, inline: true })
|
||||
.setThumbnail(item.iconUrl || null)
|
||||
.setImage(item.imageUrl || null)
|
||||
.setFooter({ text: "Click the button below to purchase instantly." });
|
||||
export function getShopListingMessage(
|
||||
item: {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
formattedPrice: string;
|
||||
iconUrl: string | null;
|
||||
imageUrl: string | null;
|
||||
price: number | bigint;
|
||||
usageData?: any;
|
||||
rarity?: string;
|
||||
},
|
||||
context?: { referencedItems: Map<number, { name: string; rarity: string }> }
|
||||
) {
|
||||
const files: AttachmentBuilder[] = [];
|
||||
let thumbnailUrl = resolveAssetUrl(item.iconUrl);
|
||||
let displayImageUrl = resolveAssetUrl(item.imageUrl);
|
||||
|
||||
// Handle local icon
|
||||
if (item.iconUrl && isLocalAssetUrl(item.iconUrl)) {
|
||||
const iconPath = join(process.cwd(), "bot/assets/graphics", stripQuery(item.iconUrl).replace(/^\/?assets\//, ""));
|
||||
if (existsSync(iconPath)) {
|
||||
const iconName = defaultName(item.iconUrl);
|
||||
files.push(new AttachmentBuilder(iconPath, { name: iconName }));
|
||||
thumbnailUrl = `attachment://${iconName}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle local image
|
||||
if (item.imageUrl && isLocalAssetUrl(item.imageUrl)) {
|
||||
if (item.imageUrl === item.iconUrl && thumbnailUrl?.startsWith("attachment://")) {
|
||||
displayImageUrl = thumbnailUrl;
|
||||
} else {
|
||||
const imagePath = join(process.cwd(), "bot/assets/graphics", stripQuery(item.imageUrl).replace(/^\/?assets\//, ""));
|
||||
if (existsSync(imagePath)) {
|
||||
const imageName = defaultName(item.imageUrl);
|
||||
if (!files.find(f => f.name === imageName)) {
|
||||
files.push(new AttachmentBuilder(imagePath, { name: imageName }));
|
||||
}
|
||||
displayImageUrl = `attachment://${imageName}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const containers: ContainerBuilder[] = [];
|
||||
|
||||
// 1. Main Container
|
||||
const mainContainer = new ContainerBuilder()
|
||||
.setAccentColor(getRarityConfig(item.rarity || "C").color);
|
||||
|
||||
// Header Section
|
||||
const infoSection = new SectionBuilder()
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`# ${item.name}`),
|
||||
new TextDisplayBuilder().setContent(item.description || "_No description available._"),
|
||||
new TextDisplayBuilder().setContent(`### 🏷️ Price: ${item.formattedPrice}`)
|
||||
);
|
||||
|
||||
// Set Thumbnail Accessory if we have an icon
|
||||
if (thumbnailUrl) {
|
||||
infoSection.setThumbnailAccessory(new ThumbnailBuilder().setURL(thumbnailUrl));
|
||||
}
|
||||
|
||||
mainContainer.addSectionComponents(infoSection);
|
||||
|
||||
// Media Gallery for additional images (if multiple)
|
||||
const mediaSources: string[] = [];
|
||||
if (thumbnailUrl) mediaSources.push(thumbnailUrl);
|
||||
if (displayImageUrl && displayImageUrl !== thumbnailUrl) mediaSources.push(displayImageUrl);
|
||||
|
||||
if (mediaSources.length > 1) {
|
||||
mainContainer.addMediaGalleryComponents(
|
||||
new MediaGalleryBuilder().addItems(
|
||||
...mediaSources.map(src => new MediaGalleryItemBuilder().setURL(src))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Create buy button (used in either main or loot container)
|
||||
const buyButton = new ButtonBuilder()
|
||||
.setCustomId(`shop_buy_${item.id}`)
|
||||
.setLabel(`Buy for ${item.price} 🪙`)
|
||||
.setCustomId(SHOP_CUSTOM_IDS.BUY(item.id))
|
||||
.setLabel(`Purchase for ${item.price} 🪙`)
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setEmoji("🛒");
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton);
|
||||
// 2. Loot Table (if applicable) — separate Container with blurple accent
|
||||
const lootboxEffect = item.usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX);
|
||||
if (lootboxEffect) {
|
||||
const pool = lootboxEffect.pool as LootTableItem[];
|
||||
const totalWeight = pool.reduce((sum: number, i: LootTableItem) => sum + i.weight, 0);
|
||||
|
||||
return { embeds: [embed], components: [row] };
|
||||
const lootContainer = new ContainerBuilder().setAccentColor(0x5865F2);
|
||||
lootContainer.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("## 🎁 Loot Table")
|
||||
);
|
||||
|
||||
// Group drops by rarity tier with aggregated percentages
|
||||
const tiers: Record<string, { items: string[]; totalChance: number }> = {};
|
||||
|
||||
for (const drop of pool) {
|
||||
const chance = (drop.weight / totalWeight) * 100;
|
||||
let line = "";
|
||||
let rarity = "C";
|
||||
|
||||
switch (drop.type as any) {
|
||||
case LootType.CURRENCY: {
|
||||
const amt = (drop.minAmount != null && drop.maxAmount != null)
|
||||
? `${drop.minAmount} – ${drop.maxAmount}`
|
||||
: (Array.isArray(drop.amount) ? `${drop.amount[0]} – ${drop.amount[1]}` : `${drop.amount || 0}`);
|
||||
line = `${amt} 🪙`;
|
||||
rarity = "CURRENCY";
|
||||
break;
|
||||
}
|
||||
case LootType.XP: {
|
||||
const amt = (drop.minAmount != null && drop.maxAmount != null)
|
||||
? `${drop.minAmount} – ${drop.maxAmount}`
|
||||
: (Array.isArray(drop.amount) ? `${drop.amount[0]} – ${drop.amount[1]}` : `${drop.amount || 0}`);
|
||||
line = `${amt} XP`;
|
||||
rarity = "XP";
|
||||
break;
|
||||
}
|
||||
case LootType.ITEM: {
|
||||
const referencedItems = context?.referencedItems;
|
||||
if (drop.itemId && referencedItems?.has(drop.itemId)) {
|
||||
const i = referencedItems.get(drop.itemId)!;
|
||||
line = `${i.name} ×${drop.amount || 1}`;
|
||||
rarity = i.rarity;
|
||||
} else {
|
||||
line = `Unknown Item`;
|
||||
rarity = "C";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case LootType.NOTHING: {
|
||||
line = "Nothing";
|
||||
rarity = "NOTHING";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (line) {
|
||||
if (!tiers[rarity]) tiers[rarity] = { items: [], totalChance: 0 };
|
||||
const tier = tiers[rarity]!;
|
||||
tier.items.push(line);
|
||||
tier.totalChance += chance;
|
||||
}
|
||||
}
|
||||
|
||||
const order = ["SSR", "SR", "R", "C", "CURRENCY", "XP", "NOTHING"];
|
||||
let isFirst = true;
|
||||
for (const rarity of order) {
|
||||
const tier = tiers[rarity];
|
||||
if (!tier || tier.items.length === 0) continue;
|
||||
|
||||
if (!isFirst) {
|
||||
lootContainer.addSeparatorComponents(
|
||||
new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)
|
||||
);
|
||||
}
|
||||
isFirst = false;
|
||||
|
||||
const config = getRarityConfig(rarity);
|
||||
const chanceStr = tier.totalChance < 0.1 ? "<0.1" : tier.totalChance.toFixed(1);
|
||||
lootContainer.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(
|
||||
`${config.emoji} **${config.label}** — ${chanceStr}%`
|
||||
),
|
||||
new TextDisplayBuilder().setContent(tier.items.join(", "))
|
||||
);
|
||||
}
|
||||
|
||||
// Purchase button inside loot table container
|
||||
lootContainer.addActionRowComponents(
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton)
|
||||
);
|
||||
|
||||
containers.push(mainContainer);
|
||||
containers.push(lootContainer);
|
||||
} else {
|
||||
// Non-lootbox items: purchase button stays in main container
|
||||
mainContainer.addActionRowComponents(
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton)
|
||||
);
|
||||
|
||||
containers.push(mainContainer);
|
||||
}
|
||||
|
||||
return {
|
||||
components: containers as any,
|
||||
files,
|
||||
flags: MessageFlags.IsComponentsV2
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Interaction } from "discord.js";
|
||||
import { TextChannel, MessageFlags } from "discord.js";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
|
||||
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
|
||||
@@ -8,7 +8,7 @@ import { UserError } from "@shared/lib/errors";
|
||||
|
||||
export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
||||
// Handle select menu for choosing feedback type
|
||||
if (interaction.isStringSelectMenu() && interaction.customId === "feedback_select_type") {
|
||||
if (interaction.isStringSelectMenu() && interaction.customId === FEEDBACK_CUSTOM_IDS.SELECT_TYPE) {
|
||||
const feedbackType = interaction.values[0] as FeedbackType;
|
||||
|
||||
if (!feedbackType) {
|
||||
@@ -33,7 +33,13 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
||||
throw new UserError("An error occurred processing your feedback. Please try again.");
|
||||
}
|
||||
|
||||
if (!config.feedbackChannelId) {
|
||||
if (!interaction.guildId) {
|
||||
throw new UserError("This action can only be performed in a server.");
|
||||
}
|
||||
|
||||
const guildConfig = await getGuildConfig(interaction.guildId);
|
||||
|
||||
if (!guildConfig.feedbackChannelId) {
|
||||
throw new UserError("Feedback channel is not configured. Please contact an administrator.");
|
||||
}
|
||||
|
||||
@@ -52,7 +58,7 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
||||
};
|
||||
|
||||
// Get feedback channel
|
||||
const channel = await AuroraClient.channels.fetch(config.feedbackChannelId).catch(() => null) as TextChannel | null;
|
||||
const channel = await AuroraClient.channels.fetch(guildConfig.feedbackChannelId).catch(() => null) as TextChannel | null;
|
||||
|
||||
if (!channel) {
|
||||
throw new UserError("Feedback channel not found. Please contact an administrator.");
|
||||
|
||||
@@ -16,8 +16,10 @@ export const FEEDBACK_TYPE_LABELS: Record<FeedbackType, string> = {
|
||||
};
|
||||
|
||||
export const FEEDBACK_CUSTOM_IDS = {
|
||||
PREFIX: "feedback_",
|
||||
SELECT_TYPE: "feedback_select_type",
|
||||
MODAL: "feedback_modal",
|
||||
TYPE_FIELD: "feedback_type",
|
||||
TITLE_FIELD: "feedback_title",
|
||||
DESCRIPTION_FIELD: "feedback_description"
|
||||
DESCRIPTION_FIELD: "feedback_description",
|
||||
} as const;
|
||||
|
||||
@@ -14,7 +14,7 @@ import { FEEDBACK_TYPE_LABELS, FEEDBACK_CUSTOM_IDS, type FeedbackData, type Feed
|
||||
|
||||
export function getFeedbackTypeMenu() {
|
||||
const select = new StringSelectMenuBuilder()
|
||||
.setCustomId("feedback_select_type")
|
||||
.setCustomId(FEEDBACK_CUSTOM_IDS.SELECT_TYPE)
|
||||
.setPlaceholder("Choose feedback type")
|
||||
.addOptions([
|
||||
{
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import {
|
||||
handleAddXp,
|
||||
handleAddBalance,
|
||||
handleReplyMessage,
|
||||
handleXpBoost,
|
||||
handleTempRole,
|
||||
handleColorRole,
|
||||
handleLootbox
|
||||
} from "./handlers";
|
||||
import type { EffectHandler } from "./types";
|
||||
|
||||
export const effectHandlers: Record<string, EffectHandler> = {
|
||||
'ADD_XP': handleAddXp,
|
||||
'ADD_BALANCE': handleAddBalance,
|
||||
'REPLY_MESSAGE': handleReplyMessage,
|
||||
'XP_BOOST': handleXpBoost,
|
||||
'TEMP_ROLE': handleTempRole,
|
||||
'COLOR_ROLE': handleColorRole,
|
||||
'LOOTBOX': handleLootbox
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user