Compare commits
405 Commits
216189b0a4
...
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 | ||
|
|
afe82c449b | ||
|
|
3c1334b30e | ||
|
|
58f261562a | ||
|
|
4ecbffd617 | ||
|
|
5491551544 | ||
|
|
7d658bbef9 | ||
|
|
d117bcb697 | ||
|
|
94e332ba57 | ||
|
|
3ef9773990 | ||
|
|
d243a11bd3 | ||
|
|
47ce0f12e6 | ||
|
|
f2caa1a3ee | ||
|
|
2a72beb0ef | ||
|
|
2f73f38877 | ||
|
|
9e5c6b5ac3 | ||
|
|
eb108695d3 | ||
|
|
7d541825d8 | ||
|
|
52f8ab11f0 | ||
|
|
f8436e9755 | ||
|
|
194a032c7f | ||
|
|
94a5a183d0 | ||
|
|
c7730b9355 | ||
|
|
1e20a5a7a0 | ||
|
|
54944283a3 | ||
|
|
f79ee6fbc7 | ||
|
|
915f1bc4ad | ||
|
|
4af2690bab | ||
|
|
6e57ab07e4 | ||
|
|
3a620a84c5 | ||
|
|
7d68652ea5 | ||
|
|
35bd1f58dd | ||
|
|
1cd3dbcd72 | ||
|
|
c97249f2ca | ||
|
|
0d923491b5 | ||
|
|
d870ef69d5 | ||
|
|
682e9d208e | ||
|
|
4a691ac71d | ||
|
|
1b84dbd36d | ||
|
|
a5b8d922e3 | ||
|
|
238d9a8803 | ||
|
|
713ea07040 | ||
|
|
bea6c33024 | ||
|
|
8fe300c8a2 | ||
|
|
9caa95a0d8 | ||
|
|
c6fd23b5fa | ||
|
|
d46434de18 | ||
|
|
cf4c28e1df | ||
|
|
39e405afde | ||
|
|
6763e3c543 | ||
|
|
11e07a0068 | ||
|
|
5d2d4bb0c6 | ||
|
|
19206b5cc7 | ||
|
|
0f6cce9b6e | ||
|
|
3f3a6c88e8 | ||
|
|
8253de9f73 | ||
|
|
1251df286e | ||
|
|
fff90804c0 | ||
|
|
8ebaf7b4ee | ||
|
|
17cb70ec00 | ||
|
|
a207d511be | ||
|
|
cf4f180124 | ||
|
|
5df1396b3f | ||
|
|
daad7be01c | ||
|
|
05f27ca604 | ||
|
|
d37059d50f | ||
|
|
caafe6b34d | ||
|
|
017f5ad818 | ||
|
|
f92415b89c | ||
|
|
3f028eb76a | ||
|
|
2b641c952d | ||
|
|
88b266f81b | ||
|
|
53a2f1ff0c | ||
|
|
dc15212ecf | ||
|
|
99e847175e | ||
|
|
b2c7fa6e83 | ||
|
|
9e7f18787b | ||
| 47507dd65a | |||
|
|
e6f94c3e71 | ||
|
|
66af870aa9 | ||
|
|
8047bce755 | ||
|
|
9804456257 | ||
|
|
259b8d6875 | ||
|
|
a2cb684b71 | ||
|
|
9c2098bc46 | ||
|
|
618d973863 | ||
|
|
63f55b6dfd | ||
|
|
ac4025e179 | ||
|
|
ff23f22337 | ||
|
|
292991c605 | ||
|
|
4640cd11a7 | ||
|
|
43a003f641 | ||
|
|
6f4426e49d | ||
|
|
894cad91a8 | ||
|
|
2a1c4e65ae | ||
|
|
022f748517 | ||
|
|
ca392749e3 | ||
|
|
4a1e72c5f3 | ||
|
|
d29a1ec2b7 | ||
|
|
1dd269bf2f | ||
|
|
69186ff3e9 | ||
|
|
b989e807dc | ||
|
|
2e6bdec38c | ||
|
|
a9d5c806ad | ||
|
|
6f73178375 | ||
|
|
dd62336571 | ||
|
|
8280111b66 | ||
|
|
34347f0c63 | ||
|
|
c807fd4fd0 | ||
|
|
47b980eff1 | ||
|
|
bc89ddf7c0 | ||
|
|
606d83a7ae | ||
|
|
3351295bdc | ||
|
|
92cb048a7a | ||
|
|
6ead0c0393 | ||
|
|
278ef4b6b0 | ||
|
|
9a32ab298d | ||
|
|
a2596d4124 | ||
|
|
fbc8952e0a | ||
|
|
d0b4cb80de | ||
|
|
599684cde8 | ||
|
|
5606fb6e2f | ||
|
|
fb260c5beb | ||
|
|
a227e5db59 | ||
|
|
66d5145885 | ||
|
|
2412098536 | ||
|
|
d0c48188b9 | ||
|
|
1523a392c2 | ||
|
|
7d6912cdee | ||
|
|
947bbc10d6 | ||
|
|
2933eaeafc | ||
|
|
77d3fafdce | ||
|
|
10a760edf4 | ||
|
|
a53d30a0b3 | ||
|
|
5420653b2b | ||
|
|
f13ef781b6 | ||
|
|
82a4281f9b | ||
|
|
0dbc532c7e | ||
|
|
953942f563 | ||
|
|
6334275d02 | ||
|
|
f44b053a10 | ||
|
|
fe58380d58 | ||
|
|
64cf47ee03 | ||
|
|
37ac0ee934 | ||
|
|
5ab19bf826 | ||
|
|
42d2313933 | ||
|
|
cddd8cdf57 | ||
|
|
eaaf569f4f | ||
|
|
8c28fe60fc | ||
|
|
6d725b73db | ||
|
|
da048eaad1 | ||
|
|
56da4818dc | ||
|
|
ca443491cb | ||
|
|
345e05f821 | ||
|
|
419059904c | ||
|
|
7698a3abaa | ||
|
|
83984faeae | ||
|
|
2106f06f8f | ||
|
|
16d507991c | ||
|
|
e2aa5ee760 | ||
|
|
e084b6fa4e | ||
|
|
3f6da16f89 | ||
|
|
71de87d3da | ||
|
|
fc7afd7d22 | ||
|
|
fcc82292f2 | ||
|
|
f75cc217e9 | ||
|
|
5c36b9be25 | ||
|
|
eaf97572a4 | ||
|
|
1189483244 | ||
|
|
f39ccee0d3 | ||
|
|
10282a2570 | ||
|
|
a3099b80c5 | ||
|
|
67d6298793 | ||
|
|
808fbef11b | ||
|
|
b833796fb9 | ||
|
|
58ea8b92f1 | ||
|
|
fbd2bd990f | ||
|
|
f859618367 | ||
|
|
b7b1dd87b8 | ||
|
|
f3b6af019d | ||
|
|
0dea266a6d | ||
|
|
fbcac51370 | ||
|
|
75e586cee8 | ||
|
|
6e1e6abf2d | ||
|
|
4a0a2a5878 |
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
15
.gitignore
vendored
@@ -1,7 +1,10 @@
|
||||
.env
|
||||
node_modules
|
||||
db-logs
|
||||
db-data
|
||||
docker-compose.override.yml
|
||||
shared/db-logs
|
||||
shared/db/data
|
||||
shared/db/backups
|
||||
shared/db/loga
|
||||
.cursor
|
||||
# dependencies (bun install)
|
||||
|
||||
@@ -42,4 +45,10 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
.DS_Store
|
||||
|
||||
src/db/data
|
||||
src/db/log
|
||||
src/db/log
|
||||
scratchpad/
|
||||
bot/assets/graphics/items
|
||||
tickets/
|
||||
.citrine.local
|
||||
.worktrees/
|
||||
.superpowers/
|
||||
|
||||
135
AGENTS.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file documents the current implementation shape of the Aurora repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# 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 # Bun's native runner
|
||||
bun run test # repo test wrapper script
|
||||
bun run test:ci # include CI/integration path
|
||||
|
||||
# Database
|
||||
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
|
||||
|
||||
# Panel
|
||||
bun run panel:dev # Vite dev server on :5173
|
||||
bun run panel:build # build panel/dist
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Aurora is a single-process Bun application:
|
||||
|
||||
- `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.
|
||||
|
||||
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
|
||||
|
||||
Use path aliases from the repo `tsconfig.json`:
|
||||
|
||||
- `@/*` -> `bot/*`
|
||||
- `@commands/*` -> `bot/commands/*`
|
||||
- `@db/*` -> `shared/db/*`
|
||||
- `@lib/*` -> `bot/lib/*`
|
||||
- `@modules/*` -> `bot/modules/*`
|
||||
- `@shared/*` -> `shared/*`
|
||||
|
||||
Import order in the repo is generally:
|
||||
|
||||
1. external packages
|
||||
2. aliases
|
||||
3. relative imports
|
||||
|
||||
## File patterns
|
||||
|
||||
- `*.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
|
||||
|
||||
## Runtime config
|
||||
|
||||
- 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`.
|
||||
|
||||
## Interaction routing
|
||||
|
||||
Global component routing is defined in `bot/lib/interaction.routes.ts` and consumed by `ComponentInteractionHandler`.
|
||||
|
||||
Current route table:
|
||||
|
||||
- `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`
|
||||
|
||||
Some features still use local collectors instead of the global route table, notably inventory.
|
||||
|
||||
## Commands and access control
|
||||
|
||||
- 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.
|
||||
|
||||
## API and panel
|
||||
|
||||
- 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.
|
||||
|
||||
## Database notes
|
||||
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
|
||||
## Key entrypoints
|
||||
|
||||
- `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`
|
||||
71
Dockerfile
@@ -1,18 +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
|
||||
# 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/*
|
||||
|
||||
# ============================================
|
||||
# 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
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
# ============================================
|
||||
# Development stage - for local dev with volume mounts
|
||||
# ============================================
|
||||
FROM base AS development
|
||||
|
||||
# Expose port
|
||||
# Copy dependencies from deps stage
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
# 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"]
|
||||
|
||||
230
README.md
@@ -1,119 +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.
|
||||
|
||||
## ✨ Features
|
||||
## Architecture
|
||||
|
||||
* **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.
|
||||
```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
|
||||
```
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
Important points:
|
||||
|
||||
* **Runtime**: [Bun](https://bun.sh/)
|
||||
* **Framework**: [Discord.js](https://discord.js.org/)
|
||||
* **Database**: [PostgreSQL](https://www.postgresql.org/)
|
||||
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
||||
* **Validation**: [Zod](https://zod.dev/)
|
||||
* **Containerization**: [Docker](https://www.docker.com/)
|
||||
- `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`.
|
||||
|
||||
## 🚀 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
|
||||
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
|
||||
```
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
## 📜 Scripts
|
||||
The panel dev server runs on `http://localhost:5173` and proxies `/api`, `/auth`, `/assets`, and `/ws` to `http://localhost:3000`.
|
||||
|
||||
* `bun run dev`: Start the bot in watch mode.
|
||||
* `bun run generate`: Generate Drizzle migrations.
|
||||
* `bun run migrate`: Apply migrations (via Docker).
|
||||
* `bun run db:push`: Push, schema to DB (via Docker).
|
||||
* `bun run db:studio`: Open Drizzle Studio to inspect the database.
|
||||
* `bun test`: Run tests.
|
||||
To build the panel for the integrated Bun server:
|
||||
|
||||
## 📂 Project Structure
|
||||
|
||||
```
|
||||
├── src
|
||||
│ ├── commands # Slash commands
|
||||
│ ├── events # Discord event handlers
|
||||
│ ├── modules # Feature modules (Economy, Inventory, etc.)
|
||||
│ ├── db # Database schema and connection
|
||||
│ └── lib # Shared utilities
|
||||
├── drizzle # Drizzle migration files
|
||||
├── config # Configuration files
|
||||
└── scripts # Utility scripts
|
||||
```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
|
||||
|
||||
34
api/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
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`.
|
||||
17
api/bun-env.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
// Generated by `bun init`
|
||||
|
||||
declare module "*.svg" {
|
||||
/**
|
||||
* A path to the SVG file
|
||||
*/
|
||||
const path: `${string}.svg`;
|
||||
export = path;
|
||||
}
|
||||
|
||||
declare module "*.module.css" {
|
||||
/**
|
||||
* A record of class names to their corresponding CSS module classes
|
||||
*/
|
||||
const classes: { readonly [key: string]: string };
|
||||
export = classes;
|
||||
}
|
||||
3
api/bunfig.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[serve.static]
|
||||
plugins = ["bun-plugin-tailwind"]
|
||||
env = "BUN_PUBLIC_*"
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
200
api/src/server.settings.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { describe, expect, it, mock, beforeEach, afterEach, jest } from "bun:test";
|
||||
import { type WebServerInstance } from "./server";
|
||||
|
||||
// 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: "100", streakBonus: "10", weeklyBonus: "50", cooldownMs: 86400000 },
|
||||
transfers: { allowSelfTransfer: false, minAmount: "1" },
|
||||
exam: { multMin: 1.5, multMax: 2.5 }
|
||||
},
|
||||
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 },
|
||||
},
|
||||
trivia: {
|
||||
entryFee: "50",
|
||||
rewardMultiplier: 1.5,
|
||||
timeoutSeconds: 30,
|
||||
cooldownMs: 60000,
|
||||
categories: [],
|
||||
difficulty: "random"
|
||||
}
|
||||
};
|
||||
|
||||
const mockGetSettings = jest.fn(() => Promise.resolve(mockSettings));
|
||||
const mockUpsertSettings = jest.fn(() => Promise.resolve(mockSettings));
|
||||
const mockGetDefaults = jest.fn(() => mockSettings);
|
||||
|
||||
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
|
||||
const mockGuild = {
|
||||
roles: {
|
||||
cache: [
|
||||
{ id: "role1", name: "Admin", hexColor: "#ffffff", position: 1 },
|
||||
{ id: "role2", name: "User", hexColor: "#000000", position: 0 }
|
||||
]
|
||||
},
|
||||
channels: {
|
||||
cache: [
|
||||
{ id: "chan1", name: "general", type: 0 }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
mock.module("../../bot/lib/BotClient", () => ({
|
||||
AuroraClient: {
|
||||
guilds: {
|
||||
cache: {
|
||||
get: () => mockGuild
|
||||
}
|
||||
},
|
||||
commands: [
|
||||
{ data: { name: "ping" } }
|
||||
],
|
||||
knownCommands: new Map([
|
||||
["ping", "utility"],
|
||||
["help", "utility"],
|
||||
["disabled-cmd", "admin"]
|
||||
])
|
||||
}
|
||||
}));
|
||||
|
||||
mock.module("@shared/lib/env", () => ({
|
||||
env: {
|
||||
DISCORD_GUILD_ID: "123456789"
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock spawn
|
||||
mock.module("bun", () => {
|
||||
return {
|
||||
spawn: jest.fn(() => ({
|
||||
unref: () => { }
|
||||
})),
|
||||
serve: Bun.serve
|
||||
};
|
||||
});
|
||||
|
||||
// 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 HOSTNAME = "127.0.0.1";
|
||||
const BASE_URL = `http://${HOSTNAME}:${PORT}`;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
mockGetSettings.mockImplementation(() => Promise.resolve(mockSettings));
|
||||
mockUpsertSettings.mockImplementation(() => Promise.resolve(mockSettings));
|
||||
serverInstance = await createWebServer({ port: PORT, hostname: HOSTNAME });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (serverInstance) {
|
||||
await serverInstance.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("GET /api/settings should return current configuration", async () => {
|
||||
const res = await fetch(`${BASE_URL}/api/settings`);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
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 () => {
|
||||
const partialConfig = { economy: { daily: { amount: "200" } } };
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/settings`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(partialConfig)
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// 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 () => {
|
||||
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({})
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json() as any;
|
||||
expect(data.details).toBe("Validation failed");
|
||||
});
|
||||
|
||||
it("GET /api/settings/meta should return simplified metadata", async () => {
|
||||
const res = await fetch(`${BASE_URL}/api/settings/meta`);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
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 });
|
||||
|
||||
// Check new commands structure
|
||||
expect(data.commands).toBeArray();
|
||||
expect(data.commands.length).toBeGreaterThan(0);
|
||||
expect(data.commands[0]).toHaveProperty("name");
|
||||
expect(data.commands[0]).toHaveProperty("category");
|
||||
});
|
||||
});
|
||||
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
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
38
api/tsconfig.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@shared/*": [
|
||||
"../shared/*"
|
||||
],
|
||||
"@bot/*": [
|
||||
"../bot/*"
|
||||
]
|
||||
},
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
},
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
0
bot/assets/graphics/items/.gitkeep
Normal file
BIN
bot/assets/graphics/lootdrop/template.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
51
bot/commands/admin/case.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
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()
|
||||
.setName("case")
|
||||
.setDescription("View details of a specific moderation case")
|
||||
.addStringOption(option =>
|
||||
option
|
||||
.setName("case_id")
|
||||
.setDescription("The case ID (e.g., CASE-0001)")
|
||||
.setRequired(true)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||
|
||||
// Validate case ID format
|
||||
if (!caseId.match(/^CASE-\d+$/)) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the case
|
||||
const moderationCase = await moderationService.getCaseById(caseId);
|
||||
|
||||
if (!moderationCase) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Display the case
|
||||
await interaction.editReply({
|
||||
embeds: [getCaseEmbed(moderationCase)]
|
||||
});
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
51
bot/commands/admin/cases.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
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()
|
||||
.setName("cases")
|
||||
.setDescription("View all moderation cases for a user")
|
||||
.addUserOption(option =>
|
||||
option
|
||||
.setName("user")
|
||||
.setDescription("The user to check cases for")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addBooleanOption(option =>
|
||||
option
|
||||
.setName("active_only")
|
||||
.setDescription("Show only active cases (warnings)")
|
||||
.setRequired(false)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
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 title = activeOnly
|
||||
? `⚠️ Active Cases for ${targetUser.username}`
|
||||
: `📋 All Cases for ${targetUser.username}`;
|
||||
|
||||
const description = userCases.length === 0
|
||||
? undefined
|
||||
: `Total cases: **${userCases.length}**`;
|
||||
|
||||
// Display the cases
|
||||
await interaction.editReply({
|
||||
embeds: [getCasesListEmbed(userCases, title, description)]
|
||||
});
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
81
bot/commands/admin/clearwarning.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
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()
|
||||
.setName("clearwarning")
|
||||
.setDescription("Clear/resolve a warning")
|
||||
.addStringOption(option =>
|
||||
option
|
||||
.setName("case_id")
|
||||
.setDescription("The case ID to clear (e.g., CASE-0001)")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addStringOption(option =>
|
||||
option
|
||||
.setName("reason")
|
||||
.setDescription("Reason for clearing the warning")
|
||||
.setRequired(false)
|
||||
.setMaxLength(500)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||
const reason = interaction.options.getString("reason") || "Cleared by moderator";
|
||||
|
||||
// Validate case ID format
|
||||
if (!caseId.match(/^CASE-\d+$/)) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if case exists and is active
|
||||
const existingCase = await moderationService.getCaseById(caseId);
|
||||
|
||||
if (!existingCase) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existingCase.active) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** is already resolved.`)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingCase.type !== 'warn') {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** is not a warning. Only warnings can be cleared.`)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the warning
|
||||
await moderationService.clearCase({
|
||||
caseId,
|
||||
clearedBy: interaction.user.id,
|
||||
clearedByName: interaction.user.username,
|
||||
reason
|
||||
});
|
||||
|
||||
// Send success message
|
||||
await interaction.editReply({
|
||||
embeds: [getClearSuccessEmbed(caseId)]
|
||||
});
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
92
bot/commands/admin/create_color.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
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()
|
||||
.setName("createcolor")
|
||||
.setDescription("Create a new Color Role and corresponding Item")
|
||||
.addStringOption(option =>
|
||||
option.setName("name")
|
||||
.setDescription("The name of the role and item")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addStringOption(option =>
|
||||
option.setName("color")
|
||||
.setDescription("The hex color code (e.g. #FF0000)")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addNumberOption(option =>
|
||||
option.setName("price")
|
||||
.setDescription("Price of the item (Default: 500)")
|
||||
.setRequired(false)
|
||||
)
|
||||
.addStringOption(option =>
|
||||
option.setName("image")
|
||||
.setDescription("Image URL for the item")
|
||||
.setRequired(false)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
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;
|
||||
const imageUrl = interaction.options.getString("image") || "https://cdn.discordapp.com/attachments/1450061247365124199/1453122950822760559/Main_Chip_1.png";
|
||||
|
||||
// 1. Validate Color
|
||||
const colorRegex = /^#([0-9A-F]{3}){1,2}$/i;
|
||||
if (!colorRegex.test(colorInput)) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Hex Color code. Format: #RRGGBB")] });
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Create Role
|
||||
const role = await interaction.guild?.roles.create({
|
||||
name: name,
|
||||
color: colorInput as any,
|
||||
reason: `Created via /createcolor by ${interaction.user.tag}`
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
throw new Error("Failed to create role.");
|
||||
}
|
||||
|
||||
// 3. Add to guild settings
|
||||
await guildSettingsService.addColorRole(interaction.guildId!, role.id);
|
||||
invalidateGuildConfigCache(interaction.guildId!);
|
||||
|
||||
// 4. Create Item
|
||||
await DrizzleClient.insert(items).values({
|
||||
name: `Color Role - ${name}`,
|
||||
description: `Use this item to apply the ${name} color to your name.`,
|
||||
type: "CONSUMABLE",
|
||||
rarity: "Common",
|
||||
price: BigInt(price),
|
||||
iconUrl: "",
|
||||
imageUrl: imageUrl,
|
||||
usageData: {
|
||||
consume: false,
|
||||
effects: [{ type: "COLOR_ROLE", roleId: role.id }]
|
||||
} as any
|
||||
});
|
||||
|
||||
// 5. Success
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`**Role:** <@&${role.id}> (${colorInput})\n**Item:** Color Role - ${name}\n**Price:** ${price} 🪙`,
|
||||
"✅ Color Role & Item Created"
|
||||
)]
|
||||
});
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { renderWizard } from "@/modules/admin/item_wizard";
|
||||
|
||||
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] });
|
||||
}
|
||||
84
bot/commands/admin/health.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { health } from "./health";
|
||||
import { ChatInputCommandInteraction, Colors } from "discord.js";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
|
||||
// Mock DrizzleClient
|
||||
const executeMock = mock(() => Promise.resolve());
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: {
|
||||
execute: executeMock
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock BotClient (already has lastCommandTimestamp if imported, but we might want to control it)
|
||||
AuroraClient.lastCommandTimestamp = 1641481200000; // Fixed timestamp for testing
|
||||
|
||||
describe("Health Command", () => {
|
||||
beforeEach(() => {
|
||||
executeMock.mockClear();
|
||||
});
|
||||
|
||||
test("should execute successfully and return health embed", async () => {
|
||||
const interaction = {
|
||||
deferReply: mock(() => Promise.resolve()),
|
||||
editReply: mock(() => Promise.resolve()),
|
||||
client: {
|
||||
ws: {
|
||||
ping: 42
|
||||
}
|
||||
},
|
||||
user: { id: "123", username: "testuser" },
|
||||
commandName: "health"
|
||||
} as unknown as ChatInputCommandInteraction;
|
||||
|
||||
await health.execute(interaction);
|
||||
|
||||
expect(interaction.deferReply).toHaveBeenCalled();
|
||||
expect(executeMock).toHaveBeenCalled();
|
||||
expect(interaction.editReply).toHaveBeenCalled();
|
||||
|
||||
const editReplyCall = (interaction.editReply as any).mock.calls[0][0];
|
||||
const embed = editReplyCall.embeds[0];
|
||||
|
||||
expect(embed.data.title).toBe("System Health Status");
|
||||
expect(embed.data.color).toBe(Colors.Aqua);
|
||||
|
||||
// Check fields
|
||||
const fields = embed.data.fields;
|
||||
expect(fields).toBeDefined();
|
||||
|
||||
// Connectivity field
|
||||
const connectivityField = fields.find((f: any) => f.name === "📡 Connectivity");
|
||||
expect(connectivityField.value).toContain("42ms");
|
||||
expect(connectivityField.value).toContain("Connected");
|
||||
|
||||
// Activity field
|
||||
const activityField = fields.find((f: any) => f.name === "⌨️ Activity");
|
||||
expect(activityField.value).toContain("R>"); // Relative Discord timestamp
|
||||
});
|
||||
|
||||
test("should handle database disconnection", async () => {
|
||||
executeMock.mockImplementationOnce(() => Promise.reject(new Error("DB Down")));
|
||||
|
||||
const interaction = {
|
||||
deferReply: mock(() => Promise.resolve()),
|
||||
editReply: mock(() => Promise.resolve()),
|
||||
client: {
|
||||
ws: {
|
||||
ping: 42
|
||||
}
|
||||
},
|
||||
user: { id: "123", username: "testuser" },
|
||||
commandName: "health"
|
||||
} as unknown as ChatInputCommandInteraction;
|
||||
|
||||
await health.execute(interaction);
|
||||
|
||||
const editReplyCall = (interaction.editReply as any).mock.calls[0][0];
|
||||
const embed = editReplyCall.embeds[0];
|
||||
const connectivityField = embed.data.fields.find((f: any) => f.name === "📡 Connectivity");
|
||||
|
||||
expect(connectivityField.value).toContain("Disconnected");
|
||||
});
|
||||
});
|
||||
60
bot/commands/admin/health.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, Colors } from "discord.js";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { createBaseEmbed } from "@lib/embeds";
|
||||
|
||||
export const health = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("health")
|
||||
.setDescription("Check the bot's health status")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
|
||||
// 1. Check Discord API latency
|
||||
const wsPing = interaction.client.ws.ping;
|
||||
|
||||
// 2. Verify database connection
|
||||
let dbStatus = "Connected";
|
||||
let dbPing = -1;
|
||||
try {
|
||||
const start = Date.now();
|
||||
await DrizzleClient.execute(sql`SELECT 1`);
|
||||
dbPing = Date.now() - start;
|
||||
} catch (error) {
|
||||
dbStatus = "Disconnected";
|
||||
console.error("Health check DB error:", error);
|
||||
}
|
||||
|
||||
// 3. Uptime
|
||||
const uptime = process.uptime();
|
||||
const days = Math.floor(uptime / 86400);
|
||||
const hours = Math.floor((uptime % 86400) / 3600);
|
||||
const minutes = Math.floor((uptime % 3600) / 60);
|
||||
const seconds = Math.floor(uptime % 60);
|
||||
const uptimeString = `${days}d ${hours}h ${minutes}m ${seconds}s`;
|
||||
|
||||
// 4. Memory usage
|
||||
const memory = process.memoryUsage();
|
||||
const heapUsed = (memory.heapUsed / 1024 / 1024).toFixed(2);
|
||||
const heapTotal = (memory.heapTotal / 1024 / 1024).toFixed(2);
|
||||
const rss = (memory.rss / 1024 / 1024).toFixed(2);
|
||||
|
||||
// 5. Last successful command
|
||||
const lastCommand = AuroraClient.lastCommandTimestamp
|
||||
? `<t:${Math.floor(AuroraClient.lastCommandTimestamp / 1000)}:R>`
|
||||
: "None since startup";
|
||||
|
||||
const embed = createBaseEmbed("System Health Status", undefined, Colors.Aqua)
|
||||
.addFields(
|
||||
{ name: "📡 Connectivity", value: `**Discord WS:** ${wsPing}ms\n**Database:** ${dbStatus} ${dbPing >= 0 ? `(${dbPing}ms)` : ""}`, inline: true },
|
||||
{ name: "⏱️ Uptime", value: uptimeString, inline: true },
|
||||
{ name: "🧠 Memory Usage", value: `**RSS:** ${rss} MB\n**Heap:** ${heapUsed} / ${heapTotal} MB`, inline: false },
|
||||
{ name: "⌨️ Activity", value: `**Last Command:** ${lastCommand}`, inline: true }
|
||||
);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
});
|
||||
120
bot/commands/admin/listing.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import {
|
||||
SlashCommandBuilder,
|
||||
type BaseGuildTextChannel,
|
||||
PermissionFlagsBits,
|
||||
MessageFlags
|
||||
} from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { items } from "@db/schema";
|
||||
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()
|
||||
.setName("listing")
|
||||
.setDescription("Post an item listing in the channel for users to buy")
|
||||
.addNumberOption(option =>
|
||||
option.setName("item")
|
||||
.setDescription("The item to list")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
)
|
||||
.addChannelOption(option =>
|
||||
option.setName("channel")
|
||||
.setDescription("The channel to post the listing in (defaults to current)")
|
||||
.setRequired(false)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const itemId = interaction.options.getNumber("item", true);
|
||||
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
|
||||
|
||||
if (!targetChannel || !targetChannel.isSendable()) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const item = await inventoryService.getItem(itemId);
|
||||
if (!item) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!item.price) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item "${item.name}" is not for sale (no price set).`)] });
|
||||
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);
|
||||
|
||||
await targetChannel.send(listingMessage as any);
|
||||
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
},
|
||||
autocomplete: async (interaction) => {
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
|
||||
const results = await DrizzleClient.select({
|
||||
id: items.id,
|
||||
name: items.name,
|
||||
price: items.price
|
||||
})
|
||||
.from(items)
|
||||
.where(
|
||||
and(
|
||||
ilike(items.name, `%${focusedValue}%`),
|
||||
isNotNull(items.price)
|
||||
)
|
||||
)
|
||||
.limit(20);
|
||||
|
||||
await interaction.respond(
|
||||
results.map(item => ({
|
||||
name: `${item.name} (Price: ${item.price})`,
|
||||
value: item.id
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
59
bot/commands/admin/note.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
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()
|
||||
.setName("note")
|
||||
.setDescription("Add a staff-only note about a user")
|
||||
.addUserOption(option =>
|
||||
option
|
||||
.setName("user")
|
||||
.setDescription("The user to add a note for")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addStringOption(option =>
|
||||
option
|
||||
.setName("note")
|
||||
.setDescription("The note to add")
|
||||
.setRequired(true)
|
||||
.setMaxLength(1000)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
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({
|
||||
type: CaseType.NOTE,
|
||||
userId: targetUser.id,
|
||||
username: targetUser.username,
|
||||
moderatorId: interaction.user.id,
|
||||
moderatorName: interaction.user.username,
|
||||
reason: noteText,
|
||||
});
|
||||
|
||||
if (!moderationCase) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("Failed to create note.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Send success message
|
||||
await interaction.editReply({
|
||||
embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)]
|
||||
});
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
40
bot/commands/admin/notes.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
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()
|
||||
.setName("notes")
|
||||
.setDescription("View all staff notes for a user")
|
||||
.addUserOption(option =>
|
||||
option
|
||||
.setName("user")
|
||||
.setDescription("The user to check notes for")
|
||||
.setRequired(true)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
|
||||
// Get all notes for the user
|
||||
const userNotes = await moderationService.getUserNotes(targetUser.id);
|
||||
|
||||
// Display the notes
|
||||
await interaction.editReply({
|
||||
embeds: [getCasesListEmbed(
|
||||
userNotes,
|
||||
`📝 Staff Notes for ${targetUser.username}`,
|
||||
userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**`
|
||||
)]
|
||||
});
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
165
bot/commands/admin/prune.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { pruneService } from "@modules/moderation/prune.service";
|
||||
import {
|
||||
getConfirmationMessage,
|
||||
getProgressEmbed,
|
||||
getSuccessEmbed,
|
||||
getPruneErrorEmbed,
|
||||
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()
|
||||
.setName("prune")
|
||||
.setDescription("Delete messages in bulk (admin only)")
|
||||
.addIntegerOption(option =>
|
||||
option
|
||||
.setName("amount")
|
||||
.setDescription(`Number of messages to delete (1-${config.moderation?.prune?.maxAmount || 100})`)
|
||||
.setRequired(false)
|
||||
.setMinValue(1)
|
||||
.setMaxValue(config.moderation?.prune?.maxAmount || 100)
|
||||
)
|
||||
.addUserOption(option =>
|
||||
option
|
||||
.setName("user")
|
||||
.setDescription("Only delete messages from this user")
|
||||
.setRequired(false)
|
||||
)
|
||||
.addBooleanOption(option =>
|
||||
option
|
||||
.setName("all")
|
||||
.setDescription("Delete all messages in the channel")
|
||||
.setRequired(false)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const amount = interaction.options.getInteger("amount");
|
||||
const user = interaction.options.getUser("user");
|
||||
const all = interaction.options.getBoolean("all") || false;
|
||||
|
||||
// Validate inputs
|
||||
if (!amount && !all) {
|
||||
// Default to 10 messages
|
||||
} else if (amount && all) {
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneErrorEmbed("Cannot specify both `amount` and `all`. Please use one or the other.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const finalAmount = all ? 'all' : (amount || 10);
|
||||
const confirmThreshold = config.moderation.prune.confirmThreshold;
|
||||
|
||||
// Check if confirmation is needed
|
||||
const needsConfirmation = all || (typeof finalAmount === 'number' && finalAmount > confirmThreshold);
|
||||
|
||||
if (needsConfirmation) {
|
||||
// Estimate message count for confirmation
|
||||
let estimatedCount: number | undefined;
|
||||
if (all) {
|
||||
try {
|
||||
estimatedCount = await pruneService.estimateMessageCount(interaction.channel!);
|
||||
} catch {
|
||||
estimatedCount = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const { embeds, components } = getConfirmationMessage(finalAmount, estimatedCount);
|
||||
const response = await interaction.editReply({ embeds, components });
|
||||
|
||||
try {
|
||||
const confirmation = await response.awaitMessageComponent({
|
||||
filter: (i) => i.user.id === interaction.user.id,
|
||||
componentType: ComponentType.Button,
|
||||
time: 30000
|
||||
});
|
||||
|
||||
if (confirmation.customId === PRUNE_CUSTOM_IDS.CANCEL) {
|
||||
await confirmation.update({
|
||||
embeds: [getCancelledEmbed()],
|
||||
components: []
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// User confirmed, proceed with deletion
|
||||
await confirmation.update({
|
||||
embeds: [getProgressEmbed({ current: 0, total: estimatedCount || finalAmount as number })],
|
||||
components: []
|
||||
});
|
||||
|
||||
// Execute deletion with progress callback for 'all' mode
|
||||
const result = await pruneService.deleteMessages(
|
||||
interaction.channel!,
|
||||
{
|
||||
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
|
||||
userId: user?.id,
|
||||
all
|
||||
},
|
||||
all ? async (progress) => {
|
||||
await interaction.editReply({
|
||||
embeds: [getProgressEmbed(progress)]
|
||||
});
|
||||
} : undefined
|
||||
);
|
||||
|
||||
// Show success
|
||||
await interaction.editReply({
|
||||
embeds: [getSuccessEmbed(result)],
|
||||
components: []
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("time")) {
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneWarningEmbed("Confirmation timed out. Please try again.")],
|
||||
components: []
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No confirmation needed, proceed directly
|
||||
const result = await pruneService.deleteMessages(
|
||||
interaction.channel!,
|
||||
{
|
||||
amount: finalAmount as number,
|
||||
userId: user?.id,
|
||||
all: false
|
||||
}
|
||||
);
|
||||
|
||||
// Check if no messages were found
|
||||
if (result.deletedCount === 0) {
|
||||
if (user) {
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneWarningEmbed(`No messages found from **${user.username}** in the last ${finalAmount} messages.`)]
|
||||
});
|
||||
} else {
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneWarningEmbed("No messages found to delete.")]
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [getSuccessEmbed(result)]
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
33
bot/commands/admin/refresh.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { createSuccessEmbed } from "@lib/embeds";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const refresh = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("refresh")
|
||||
.setDescription("Reloads all commands and config without restarting")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const start = Date.now();
|
||||
await AuroraClient.loadCommands(true);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
// Deploy commands
|
||||
await AuroraClient.deployCommands();
|
||||
|
||||
const embed = createSuccessEmbed(
|
||||
`Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`,
|
||||
"System Refreshed"
|
||||
);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
37
bot/commands/admin/terminal.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
|
||||
import { terminalService } from "@modules/system/terminal.service";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const terminal = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("terminal")
|
||||
.setDescription("Manage the Aurora Terminal")
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("init")
|
||||
.setDescription("Initialize the terminal in the current channel")
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
if (subcommand === "init") {
|
||||
const channel = interaction.channel;
|
||||
if (!channel || channel.type !== ChannelType.GuildText) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed("Terminal can only be initialized in text channels.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
await terminalService.init(channel as TextChannel);
|
||||
await interaction.editReply({ content: "✅ Terminal initialized!" });
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
90
bot/commands/admin/warn.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import {
|
||||
getWarnSuccessEmbed,
|
||||
getModerationErrorEmbed,
|
||||
} from "@/modules/moderation/moderation.view";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const warn = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("warn")
|
||||
.setDescription("Issue a warning to a user")
|
||||
.addUserOption(option =>
|
||||
option
|
||||
.setName("user")
|
||||
.setDescription("The user to warn")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addStringOption(option =>
|
||||
option
|
||||
.setName("reason")
|
||||
.setDescription("Reason for the warning")
|
||||
.setRequired(true)
|
||||
.setMaxLength(1000)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
const reason = interaction.options.getString("reason", true);
|
||||
|
||||
// Don't allow warning bots
|
||||
if (targetUser.bot) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("You cannot warn bots.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't allow self-warnings
|
||||
if (targetUser.id === interaction.user.id) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("You cannot warn yourself.")]
|
||||
});
|
||||
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({
|
||||
userId: targetUser.id,
|
||||
username: targetUser.username,
|
||||
moderatorId: interaction.user.id,
|
||||
moderatorName: interaction.user.username,
|
||||
reason,
|
||||
guildName: interaction.guild?.name || undefined,
|
||||
dmTarget: targetUser,
|
||||
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id),
|
||||
config: {
|
||||
dmOnWarn: guildConfig.moderation?.cases?.dmOnWarn,
|
||||
autoTimeoutThreshold: guildConfig.moderation?.cases?.autoTimeoutThreshold,
|
||||
},
|
||||
});
|
||||
|
||||
// Send success message to moderator
|
||||
await interaction.editReply({
|
||||
embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
|
||||
});
|
||||
|
||||
// Follow up if auto-timeout was issued
|
||||
if (autoTimeoutIssued) {
|
||||
await interaction.followUp({
|
||||
embeds: [getModerationErrorEmbed(
|
||||
`⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.`
|
||||
)],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
36
bot/commands/admin/warnings.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
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()
|
||||
.setName("warnings")
|
||||
.setDescription("View active warnings for a user")
|
||||
.addUserOption(option =>
|
||||
option
|
||||
.setName("user")
|
||||
.setDescription("The user to check warnings for")
|
||||
.setRequired(true)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
|
||||
// Get active warnings for the user
|
||||
const activeWarnings = await moderationService.getUserWarnings(targetUser.id);
|
||||
|
||||
// Display the warnings
|
||||
await interaction.editReply({
|
||||
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)]
|
||||
});
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
54
bot/commands/admin/webhook.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
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()
|
||||
.setName("webhook")
|
||||
.setDescription("Send a message via webhook using a JSON payload")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageWebhooks)
|
||||
.addStringOption(option =>
|
||||
option.setName("payload")
|
||||
.setDescription("The JSON payload for the webhook message")
|
||||
.setRequired(true)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const payloadString = interaction.options.getString("payload", true);
|
||||
let payload;
|
||||
|
||||
try {
|
||||
payload = JSON.parse(payloadString);
|
||||
} catch (error) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("The provided payload is not valid JSON.", "Invalid JSON")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = interaction.channel;
|
||||
|
||||
if (!channel || !('createWebhook' in channel)) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("This channel does not support webhooks.", "Unsupported Channel")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await sendWebhookMessage(
|
||||
channel,
|
||||
payload,
|
||||
interaction.client.user,
|
||||
`Proxy message requested by ${interaction.user.tag}`
|
||||
);
|
||||
|
||||
await interaction.editReply({ content: "Message sent successfully!" });
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { createWarningEmbed } from "@/lib/embeds";
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { createBaseEmbed } from "@lib/embeds";
|
||||
|
||||
export const balance = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -18,15 +18,15 @@ export const balance = createCommand({
|
||||
const targetUser = interaction.options.getUser("user") || interaction.user;
|
||||
|
||||
if (targetUser.bot) {
|
||||
return; // Wait, I need to send the reply inside the if.
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setAuthor({ name: targetUser.username, iconURL: targetUser.displayAvatarURL() })
|
||||
.setDescription(`**Balance**: ${user.balance || 0n} AU`)
|
||||
.setColor("Yellow");
|
||||
if (!user) throw new Error("Failed to retrieve user data.");
|
||||
|
||||
const embed = createBaseEmbed(undefined, `**Balance**: ${user.balance || 0n} AU`, "Yellow")
|
||||
.setAuthor({ name: targetUser.username, iconURL: targetUser.displayAvatarURL() });
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
30
bot/commands/economy/daily.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
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 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!")
|
||||
.addFields(
|
||||
{ name: "Streak", value: `🔥 ${result.streak} days`, inline: true },
|
||||
{ name: "Weekly Progress", value: `${"🟩".repeat(result.streak % 7 || 7)}${"⬜".repeat(7 - (result.streak % 7 || 7))} (${result.streak % 7 || 7}/7)`, inline: true },
|
||||
{ name: "Next Reward", value: `<t:${Math.floor(result.nextReadyAt.getTime() / 1000)}:R> `, inline: true }
|
||||
)
|
||||
.setColor("Gold");
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
72
bot/commands/economy/exam.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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"];
|
||||
|
||||
export const exam = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("exam")
|
||||
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
|
||||
execute: async (interaction) => {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
// First, try to take the exam or check status
|
||||
const result = await examService.takeExam(interaction.user.id);
|
||||
|
||||
if (result.status === ExamStatus.NOT_REGISTERED) {
|
||||
// Register the user
|
||||
const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username);
|
||||
const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` +
|
||||
`Come back on <t:${nextRegTimestamp}:D> (<t:${nextRegTimestamp}:R>) to take your first exam!`,
|
||||
"Exam Registration Successful"
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000);
|
||||
|
||||
if (result.status === ExamStatus.COOLDOWN) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
||||
`Next exam available: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === ExamStatus.MISSED) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You missed your exam day! Your exam day is **${DAYS[result.examDay!]}** (Server Time).\n` +
|
||||
`You verify your attendance but score a **0**.\n` +
|
||||
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
|
||||
"Exam Failed"
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If it reached here with AVAILABLE, it means they passed
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`**XP Gained:** ${result.xpDiff?.toString()}\n` +
|
||||
`**Multiplier:** x${result.multiplier?.toFixed(2)}\n` +
|
||||
`**Reward:** ${result.reward?.toString()} Currency\n\n` +
|
||||
`See you next week: <t:${nextExamTimestamp}:D>`,
|
||||
"Exam Passed!"
|
||||
)]
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
63
bot/commands/economy/pay.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
||||
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 { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const pay = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("pay")
|
||||
.setDescription("Transfer Astral Units to another user")
|
||||
.addUserOption(option =>
|
||||
option.setName("user")
|
||||
.setDescription("The user to pay")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addIntegerOption(option =>
|
||||
option.setName("amount")
|
||||
.setDescription("Amount to transfer")
|
||||
.setMinValue(1)
|
||||
.setRequired(true)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
const targetUser = await userService.getOrCreateUser(interaction.options.getUser("user", true).id, interaction.options.getUser("user", true).username);
|
||||
const discordUser = interaction.options.getUser("user", true);
|
||||
|
||||
if (discordUser.bot) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed("You cannot send money to bots.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
const amount = BigInt(interaction.options.getInteger("amount", true));
|
||||
const senderId = interaction.user.id;
|
||||
if (!targetUser) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed("User not found.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
const receiverId = targetUser.id;
|
||||
|
||||
if (amount < config.economy.transfers.minAmount) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(`Amount must be at least ${config.economy.transfers.minAmount}.`)], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
if (senderId === receiverId.toString()) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed("You cannot pay yourself.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
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}>` });
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, EmbedBuilder, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, ThreadAutoArchiveDuration, MessageFlags } from "discord.js";
|
||||
import { TradeService } from "@/modules/trade/trade.service";
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags } from "discord.js";
|
||||
import { tradeService } from "@shared/modules/trade/trade.service";
|
||||
import { getTradeDashboard } from "@/modules/trade/trade.view";
|
||||
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
|
||||
|
||||
export const trade = createCommand({
|
||||
@@ -58,32 +59,15 @@ export const trade = createCommand({
|
||||
}
|
||||
|
||||
// Setup Session
|
||||
TradeService.createSession(thread.id,
|
||||
const session = tradeService.createSession(thread.id,
|
||||
{ id: interaction.user.id, username: interaction.user.username },
|
||||
{ id: targetUser.id, username: targetUser.username }
|
||||
);
|
||||
|
||||
// Send Dashboard to Thread
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("🤝 Trading Session")
|
||||
.setDescription(`Trade started between ${interaction.user} and ${targetUser}.\nUse the controls below to build your offer.`)
|
||||
.setColor(0xFFD700)
|
||||
.addFields(
|
||||
{ name: interaction.user.username, value: "*Empty Offer*", inline: true },
|
||||
{ name: targetUser.username, value: "*Empty Offer*", inline: true }
|
||||
)
|
||||
.setFooter({ text: "Both parties must click Lock to confirm trade." });
|
||||
const dashboard = getTradeDashboard(session);
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder().setCustomId('trade_add_item').setLabel('Add Item').setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId('trade_add_money').setLabel('Add Money').setStyle(ButtonStyle.Success),
|
||||
new ButtonBuilder().setCustomId('trade_remove_item').setLabel('Remove Item').setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId('trade_lock').setLabel('Lock / Unlock').setStyle(ButtonStyle.Primary),
|
||||
new ButtonBuilder().setCustomId('trade_cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger),
|
||||
);
|
||||
|
||||
await thread.send({ content: `${interaction.user} ${targetUser} Welcome to your trading session!`, embeds: [embed], components: [row] });
|
||||
await thread.send({ content: `${interaction.user} ${targetUser} Welcome to your trading session!`, ...dashboard });
|
||||
|
||||
// Update original reply
|
||||
await interaction.editReply({ content: `✅ Trade opened: <#${thread.id}>` });
|
||||
108
bot/commands/economy/trivia.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { triviaService } from "@shared/modules/trivia/trivia.service";
|
||||
import { getTriviaQuestionView } from "@/modules/trivia/trivia.view";
|
||||
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()
|
||||
.setName("trivia")
|
||||
.setDescription("Play trivia to win currency! Answer correctly within the time limit.")
|
||||
.addStringOption(option =>
|
||||
option.setName('category')
|
||||
.setDescription('Select a specific category')
|
||||
.setRequired(false)
|
||||
.addChoices(
|
||||
{ name: 'General Knowledge', value: String(TriviaCategory.GENERAL_KNOWLEDGE) },
|
||||
{ name: 'Books', value: String(TriviaCategory.BOOKS) },
|
||||
{ name: 'Film', value: String(TriviaCategory.FILM) },
|
||||
{ name: 'Music', value: String(TriviaCategory.MUSIC) },
|
||||
{ name: 'Video Games', value: String(TriviaCategory.VIDEO_GAMES) },
|
||||
{ name: 'Science & Nature', value: String(TriviaCategory.SCIENCE_NATURE) },
|
||||
{ name: 'Computers', value: String(TriviaCategory.COMPUTERS) },
|
||||
{ name: 'Mathematics', value: String(TriviaCategory.MATHEMATICS) },
|
||||
{ name: 'Mythology', value: String(TriviaCategory.MYTHOLOGY) },
|
||||
{ name: 'Sports', value: String(TriviaCategory.SPORTS) },
|
||||
{ name: 'Geography', value: String(TriviaCategory.GEOGRAPHY) },
|
||||
{ name: 'History', value: String(TriviaCategory.HISTORY) },
|
||||
{ name: 'Politics', value: String(TriviaCategory.POLITICS) },
|
||||
{ name: 'Art', value: String(TriviaCategory.ART) },
|
||||
{ name: 'Animals', value: String(TriviaCategory.ANIMALS) },
|
||||
{ name: 'Anime & Manga', value: String(TriviaCategory.ANIME_MANGA) },
|
||||
)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
try {
|
||||
const categoryId = interaction.options.getString('category');
|
||||
|
||||
// Check if user can play BEFORE deferring
|
||||
const canPlay = await triviaService.canPlayTrivia(interaction.user.id);
|
||||
|
||||
if (!canPlay.canPlay) {
|
||||
// Cooldown error - ephemeral
|
||||
const timestamp = Math.floor(canPlay.nextAvailable!.getTime() / 1000);
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You're on cooldown! Try again <t:${timestamp}:R>.`
|
||||
)],
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 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,
|
||||
interaction.user.username,
|
||||
categoryId ? parseInt(categoryId) : undefined
|
||||
);
|
||||
|
||||
// Generate Components v2 message
|
||||
const { components, flags } = getTriviaQuestionView(session, interaction.user.username);
|
||||
|
||||
// Reply with Components v2 question
|
||||
await interaction.editReply({
|
||||
components,
|
||||
flags
|
||||
});
|
||||
|
||||
// Set up automatic timeout cleanup
|
||||
setTimeout(async () => {
|
||||
const stillActive = triviaService.getSession(session.sessionId);
|
||||
if (stillActive) {
|
||||
// User didn't answer - clean up session with no reward
|
||||
try {
|
||||
await triviaService.submitAnswer(session.sessionId, interaction.user.id, false);
|
||||
} catch (error) {
|
||||
// Session already cleaned up, ignore
|
||||
}
|
||||
}
|
||||
}, config.trivia.timeoutSeconds * 1000 + 5000); // 5 seconds grace period
|
||||
}
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
// Handle errors from the pre-defer canPlayTrivia check
|
||||
if (error instanceof UserError) {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed(error.message)],
|
||||
ephemeral: true
|
||||
});
|
||||
} else {
|
||||
console.error("Error in trivia command:", error);
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
31
bot/commands/feedback/feedback.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { getFeedbackTypeMenu } from "@/modules/feedback/feedback.view";
|
||||
|
||||
export const feedback = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.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 (!guildConfig.feedbackChannelId) {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("Feedback system is not configured. Please contact an administrator.")],
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Show feedback type selection menu
|
||||
const menu = getFeedbackTypeMenu();
|
||||
await interaction.reply({
|
||||
content: "## 🌟 Share Your Thoughts\n\nThank you for helping improve Aurora! Please select the type of feedback you'd like to submit:",
|
||||
...menu,
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
});
|
||||
322
bot/commands/inventory/inventory.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
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, 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) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("Bots do not have inventories.", "Inventory Check")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
||||
if (!user) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("Failed to load user data.", "Error")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const ownerId = user.id.toString();
|
||||
const entries = await inventoryService.getInventory(ownerId);
|
||||
|
||||
if (!entries || entries.length === 0) {
|
||||
await interaction.editReply(getEmptyInventoryMessage(user.username) as any);
|
||||
return;
|
||||
}
|
||||
|
||||
let currentPage = 0;
|
||||
let selectedItemId: number | null = null;
|
||||
|
||||
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(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
73
bot/commands/inventory/use.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
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 { getLootboxResultMessage } from "@/modules/inventory/inventory.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
|
||||
export const use = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("use")
|
||||
.setDescription("Use an item from your inventory")
|
||||
.addNumberOption(option =>
|
||||
option.setName("item")
|
||||
.setDescription("The item to use")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
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);
|
||||
if (!user) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await inventoryService.useItem(user.id.toString(), itemId);
|
||||
|
||||
const usageData = result.usageData;
|
||||
if (usageData) {
|
||||
for (const effect of usageData.effects) {
|
||||
if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') {
|
||||
try {
|
||||
const member = await interaction.guild?.members.fetch(user.id.toString());
|
||||
if (member) {
|
||||
if (effect.type === 'TEMP_ROLE') {
|
||||
await member.roles.add(effect.roleId);
|
||||
} else if (effect.type === 'COLOR_ROLE') {
|
||||
// Remove existing color roles
|
||||
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);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to assign role in /use command:", e);
|
||||
result.results.push("⚠️ Failed to assign role (Check bot permissions)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const message = getLootboxResultMessage(result.results, result.item);
|
||||
await interaction.editReply(message as any);
|
||||
}
|
||||
);
|
||||
},
|
||||
autocomplete: async (interaction) => {
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
const userId = interaction.user.id;
|
||||
|
||||
const results = await inventoryService.getAutocompleteItems(userId, focusedValue);
|
||||
|
||||
await interaction.respond(results);
|
||||
}
|
||||
});
|
||||
61
bot/commands/leveling/leaderboard.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { users, items, inventory } from "@db/schema";
|
||||
import { desc, sql, eq } from "drizzle-orm";
|
||||
import { createWarningEmbed } from "@lib/embeds";
|
||||
import { getLeaderboardEmbed } from "@/modules/leveling/leveling.view";
|
||||
|
||||
export const leaderboard = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("leaderboard")
|
||||
.setDescription("View the top players")
|
||||
.addStringOption(option =>
|
||||
option.setName("type")
|
||||
.setDescription("Sort by XP, Balance, or Net Worth")
|
||||
.setRequired(true)
|
||||
.addChoices(
|
||||
{ name: "Level / XP", value: "xp" },
|
||||
{ name: "Balance", value: "balance" },
|
||||
{ name: "Net Worth", value: "networth" }
|
||||
)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
|
||||
const type = interaction.options.getString("type", true);
|
||||
|
||||
let leaders;
|
||||
|
||||
if (type === 'networth') {
|
||||
leaders = await DrizzleClient.select({
|
||||
username: users.username,
|
||||
level: users.level,
|
||||
xp: users.xp,
|
||||
balance: users.balance,
|
||||
netWorth: sql<bigint>`${users.balance} + COALESCE(SUM(${items.price} * ${inventory.quantity}), 0)`.as('net_worth')
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(inventory, eq(users.id, inventory.userId))
|
||||
.leftJoin(items, eq(inventory.itemId, items.id))
|
||||
.groupBy(users.id)
|
||||
.orderBy(desc(sql`net_worth`))
|
||||
.limit(10);
|
||||
} else {
|
||||
const isXp = type === "xp";
|
||||
leaders = await DrizzleClient.query.users.findMany({
|
||||
orderBy: isXp ? desc(users.xp) : desc(users.balance),
|
||||
limit: 10
|
||||
});
|
||||
}
|
||||
|
||||
if (leaders.length === 0) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("No users found.", "Leaderboard")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = getLeaderboardEmbed(leaders, type as 'xp' | 'balance' | 'networth');
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
});
|
||||
99
bot/commands/quest/quests.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
||||
import { questService } from "@shared/modules/quest/quest.service";
|
||||
import { createSuccessEmbed } from "@lib/embeds";
|
||||
import {
|
||||
getQuestListComponents,
|
||||
getAvailableQuestsComponents,
|
||||
getQuestActionRows
|
||||
} from "@/modules/quest/quest.view";
|
||||
import { QUEST_CUSTOM_IDS } from "@modules/quest/quest.types";
|
||||
|
||||
export const quests = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("quests")
|
||||
.setDescription("View your active and available quests"),
|
||||
execute: async (interaction) => {
|
||||
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 userQuests = await questService.getUserQuests(userId);
|
||||
const availableQuests = await questService.getAvailableQuests(userId);
|
||||
|
||||
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
|
||||
const totalItems = viewType === 'active' ? activeQuests.length : availableQuests.length;
|
||||
|
||||
const containers = viewType === 'active'
|
||||
? getQuestListComponents(userQuests, page)
|
||||
: getAvailableQuestsComponents(availableQuests, page);
|
||||
|
||||
const actionRows = getQuestActionRows(viewType, totalItems, page);
|
||||
|
||||
await interaction.editReply({
|
||||
content: null,
|
||||
embeds: null as any,
|
||||
components: [...containers, ...actionRows] as any,
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
allowedMentions: { parse: [] }
|
||||
});
|
||||
};
|
||||
|
||||
// Initial view
|
||||
await updateView('active');
|
||||
|
||||
const collector = response.createMessageComponentCollector({
|
||||
time: 120000, // 2 minutes
|
||||
componentType: undefined // Allow buttons
|
||||
});
|
||||
|
||||
collector.on('collect', async (i) => {
|
||||
if (i.user.id !== interaction.user.id) return;
|
||||
|
||||
try {
|
||||
if (i.customId === QUEST_CUSTOM_IDS.VIEW_ACTIVE) {
|
||||
await i.deferUpdate();
|
||||
await updateView('active', 0);
|
||||
} else if (i.customId === QUEST_CUSTOM_IDS.VIEW_AVAILABLE) {
|
||||
await i.deferUpdate();
|
||||
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);
|
||||
await questService.assignQuest(userId, questId);
|
||||
|
||||
await i.reply({
|
||||
embeds: [createSuccessEmbed(`You have accepted a new quest!`, "Quest Accepted")],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
|
||||
// Stay on current view/page but refresh (accepted quest disappears from available)
|
||||
await updateView(currentView, currentPage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Quest interaction error:", error);
|
||||
await i.followUp({
|
||||
content: "Something went wrong while processing your quest interaction.",
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
collector.on('end', () => {
|
||||
interaction.editReply({ components: [] }).catch(() => {});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, AttachmentBuilder } from "discord.js";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { generateStudentIdCard } from "@/graphics/studentID";
|
||||
import { createWarningEmbed } from "@/lib/embeds";
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { Events } from "discord.js";
|
||||
import type { Event } from "@lib/types";
|
||||
import { config } from "@lib/config";
|
||||
import { userService } from "@modules/user/user.service";
|
||||
import type { Event } from "@shared/lib/types";
|
||||
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,8 +28,10 @@ const event: Event<Events.GuildMemberAdd> = {
|
||||
}
|
||||
console.log(`Restored student role to ${member.user.tag}`);
|
||||
} else {
|
||||
await member.roles.add(config.visitorRole);
|
||||
console.log(`Assigned visitor role to ${member.user.tag}`);
|
||||
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) {
|
||||
22
bot/events/interactionCreate.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Events } from "discord.js";
|
||||
import { ComponentInteractionHandler, AutocompleteHandler, CommandHandler } from "@/lib/handlers";
|
||||
import type { Event } from "@shared/lib/types";
|
||||
|
||||
const event: Event<Events.InteractionCreate> = {
|
||||
name: Events.InteractionCreate,
|
||||
execute: async (interaction) => {
|
||||
if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
|
||||
return ComponentInteractionHandler.handle(interaction);
|
||||
}
|
||||
|
||||
if (interaction.isAutocomplete()) {
|
||||
return AutocompleteHandler.handle(interaction);
|
||||
}
|
||||
|
||||
if (interaction.isChatInputCommand()) {
|
||||
return CommandHandler.handle(interaction);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default event;
|
||||
22
bot/events/messageCreate.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Events } from "discord.js";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { levelingService } from "@shared/modules/leveling/leveling.service";
|
||||
import type { Event } from "@shared/lib/types";
|
||||
|
||||
const event: Event<Events.MessageCreate> = {
|
||||
name: Events.MessageCreate,
|
||||
execute: async (message) => {
|
||||
if (message.author.bot) return;
|
||||
if (!message.guild) return;
|
||||
|
||||
const user = await userService.getUserById(message.author.id);
|
||||
if (!user) return;
|
||||
|
||||
levelingService.processChatXp(message.author.id);
|
||||
|
||||
// Activity Tracking for Lootdrops
|
||||
import("@modules/economy/lootdrop.handler").then(m => m.processLootdropMessage(message));
|
||||
},
|
||||
};
|
||||
|
||||
export default event;
|
||||
16
bot/events/ready.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Events } from "discord.js";
|
||||
import { schedulerService } from "@/modules/system/scheduler";
|
||||
import type { Event } from "@shared/lib/types";
|
||||
|
||||
const event: Event<Events.ClientReady> = {
|
||||
name: Events.ClientReady,
|
||||
once: true,
|
||||
execute: async (c) => {
|
||||
console.log(`Ready! Logged in as ${c.user.tag}`);
|
||||
schedulerService.start();
|
||||
|
||||
|
||||
},
|
||||
};
|
||||
|
||||
export default event;
|
||||
135
bot/graphics/lootdrop.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas';
|
||||
import path from 'path';
|
||||
|
||||
// Register Fonts (same as studentID.ts)
|
||||
const fontDir = path.join(process.cwd(), 'bot', 'assets', 'fonts');
|
||||
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold');
|
||||
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
|
||||
|
||||
export async function generateLootdropCard(amount: number, currency: string): Promise<Buffer> {
|
||||
const templatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'lootdrop', 'template.png');
|
||||
const template = await loadImage(templatePath);
|
||||
|
||||
const canvas = createCanvas(template.width, template.height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Draw Template
|
||||
ctx.drawImage(template, 0, 0);
|
||||
|
||||
// Draw Lootdrop Text (Title-ish)
|
||||
ctx.save();
|
||||
ctx.font = '48px IBMPlexSansCondensed-SemiBold';
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = 'rgba(255, 255, 255, 0.5)';
|
||||
// Center of lower half (512-1024) is roughly 768
|
||||
ctx.fillText('A STAR IS FALLING', canvas.width / 2, 660);
|
||||
ctx.restore();
|
||||
|
||||
// Draw Reward Amount
|
||||
ctx.save();
|
||||
ctx.font = '72px IBMPlexMono-Bold';
|
||||
ctx.fillStyle = '#DAC7A1';
|
||||
ctx.textAlign = 'center';
|
||||
//ctx.shadowBlur = 15;
|
||||
//ctx.shadowColor = 'rgba(255, 215, 0, 0.8)';
|
||||
ctx.fillText(`${amount} ${currency}`, canvas.width / 2, 760); // Below title
|
||||
ctx.restore();
|
||||
|
||||
// Crop the image by 64px on all sides
|
||||
const croppedWidth = template.width - 128;
|
||||
const croppedHeight = template.height - 128;
|
||||
const croppedCanvas = createCanvas(croppedWidth, croppedHeight);
|
||||
const croppedCtx = croppedCanvas.getContext('2d');
|
||||
|
||||
// Draw the original canvas onto the cropped canvas, shifted by -64
|
||||
croppedCtx.drawImage(canvas, -64, -64);
|
||||
|
||||
return croppedCanvas.toBuffer('image/png');
|
||||
}
|
||||
|
||||
export async function generateClaimedLootdropCard(amount: number, currency: string, username: string, avatarUrl: string): Promise<Buffer> {
|
||||
const templatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'lootdrop', 'template.png');
|
||||
const template = await loadImage(templatePath);
|
||||
|
||||
const canvas = createCanvas(template.width, template.height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Draw Template
|
||||
ctx.drawImage(template, 0, 0);
|
||||
|
||||
// Add a colored overlay to signify "claimed"
|
||||
ctx.fillStyle = 'rgba(10, 10, 20, 0.85)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw Claimed Text (Title-ish)
|
||||
ctx.save();
|
||||
ctx.font = '48px IBMPlexSansCondensed-SemiBold';
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = 'rgba(255, 255, 255, 0.5)';
|
||||
ctx.fillText('STAR CLAIMED', canvas.width / 2, 660);
|
||||
ctx.restore();
|
||||
|
||||
// Draw "by username" with Avatar
|
||||
ctx.save();
|
||||
ctx.font = '36px IBMPlexSansCondensed-SemiBold';
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
|
||||
// Calculate layout for centering Group (Avatar + Text)
|
||||
const text = `by ${username}`;
|
||||
const textMetrics = ctx.measureText(text);
|
||||
const textWidth = textMetrics.width;
|
||||
const avatarSize = 50;
|
||||
const gap = 15;
|
||||
const totalWidth = avatarSize + gap + textWidth;
|
||||
|
||||
const startX = (canvas.width - totalWidth) / 2;
|
||||
const baselineY = 830;
|
||||
|
||||
// Draw Avatar
|
||||
try {
|
||||
const avatar = await loadImage(avatarUrl);
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
// Center avatar vertically relative to text roughly (baseline - ~half cap height)
|
||||
// 36px text ~ 27px cap height. Center roughly at baselineY - 14
|
||||
const avatarCenterY = baselineY - 14;
|
||||
ctx.arc(startX + avatarSize / 2, avatarCenterY, avatarSize / 2, 0, Math.PI * 2);
|
||||
ctx.closePath();
|
||||
ctx.clip();
|
||||
ctx.drawImage(avatar, startX, avatarCenterY - avatarSize / 2, avatarSize, avatarSize);
|
||||
ctx.restore();
|
||||
} catch (e) {
|
||||
// Fallback if avatar fails to load, just don't draw it (or maybe shift text?)
|
||||
// For now, let's just proceed, the text will be off-center if avatar is missing but that's acceptable edge case
|
||||
console.error("Failed to load avatar", e);
|
||||
}
|
||||
|
||||
// Draw Text
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(text, startX + avatarSize + gap, baselineY);
|
||||
ctx.restore();
|
||||
|
||||
ctx.save();
|
||||
ctx.font = '72px IBMPlexMono-Bold'; // Match Amount size
|
||||
ctx.fillStyle = '#E6D2B5'; // Lighter gold/beige for better contrast
|
||||
ctx.textAlign = 'center';
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = 'rgba(0, 0, 0, 0.8)'; // Dark shadow for contrast
|
||||
ctx.fillText(`${amount} ${currency}`, canvas.width / 2, 760); // Same position as Unclaimed Amount
|
||||
ctx.restore();
|
||||
|
||||
// Crop the image by 64px on all sides
|
||||
const croppedWidth = template.width - 128;
|
||||
const croppedHeight = template.height - 128;
|
||||
const croppedCanvas = createCanvas(croppedWidth, croppedHeight);
|
||||
const croppedCtx = croppedCanvas.getContext('2d');
|
||||
|
||||
// Draw the original canvas onto the cropped canvas, shifted by -64
|
||||
croppedCtx.drawImage(canvas, -64, -64);
|
||||
|
||||
return croppedCanvas.toBuffer('image/png');
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas';
|
||||
import { levelingService } from '@/modules/leveling/leveling.service';
|
||||
import { levelingService } from '@shared/modules/leveling/leveling.service';
|
||||
import path from 'path';
|
||||
|
||||
// Register Fonts
|
||||
const fontDir = path.join(process.cwd(), 'src', 'assets', 'fonts');
|
||||
const fontDir = path.join(process.cwd(), 'bot', 'assets', 'fonts');
|
||||
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold');
|
||||
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
|
||||
|
||||
@@ -18,8 +18,8 @@ interface StudentCardData {
|
||||
}
|
||||
|
||||
export async function generateStudentIdCard(data: StudentCardData): Promise<Buffer> {
|
||||
const templatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'studentID', 'template.png');
|
||||
const classTemplatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'studentID', `Constellation-${data.className}.png`);
|
||||
const templatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'studentID', 'template.png');
|
||||
const classTemplatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'studentID', `Constellation-${data.className}.png`);
|
||||
|
||||
const template = await loadImage(templatePath);
|
||||
const classTemplate = await loadImage(classTemplatePath);
|
||||
@@ -97,9 +97,12 @@ export async function generateStudentIdCard(data: StudentCardData): Promise<Buff
|
||||
ctx.restore();
|
||||
|
||||
// Draw XP Bar
|
||||
const xpForNextLevel = levelingService.getXpForLevel(data.level);
|
||||
const xpForThisLevel = levelingService.getXpForNextLevel(data.level); // The total size of the current level bucket
|
||||
const xpAtStartOfLevel = levelingService.getXpToReachLevel(data.level); // The accumulated XP when this level started
|
||||
const currentLevelProgress = Number(data.xp) - xpAtStartOfLevel; // How much XP into this level
|
||||
|
||||
const xpBarMaxWidth = 382;
|
||||
const xpBarWidth = xpBarMaxWidth * Number(data.xp) / Number(xpForNextLevel);
|
||||
const xpBarWidth = Math.max(0, Math.min(xpBarMaxWidth, xpBarMaxWidth * currentLevelProgress / xpForThisLevel));
|
||||
const xpBarHeight = 3;
|
||||
ctx.save();
|
||||
ctx.fillStyle = '#B3AD93';
|
||||
54
bot/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { env } from "@shared/lib/env";
|
||||
import { initializeConfig } from "@shared/lib/config";
|
||||
import { registerDomainEventListeners } from "@shared/lib/eventWiring";
|
||||
import { createWebServer } from "../api/src/server";
|
||||
|
||||
// Initialize config from database
|
||||
await initializeConfig();
|
||||
|
||||
// Register domain event listeners before loading commands/events
|
||||
registerDomainEventListeners();
|
||||
|
||||
// Load commands & events
|
||||
await AuroraClient.loadCommands();
|
||||
await AuroraClient.loadEvents();
|
||||
await AuroraClient.deployCommands();
|
||||
await AuroraClient.setupSystemEvents();
|
||||
|
||||
console.log("🌐 Starting web server...");
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
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 createWebServer({
|
||||
port: webPort,
|
||||
hostname: webHost,
|
||||
});
|
||||
|
||||
// login with the token from .env
|
||||
if (!env.DISCORD_BOT_TOKEN) {
|
||||
throw new Error("❌ DISCORD_BOT_TOKEN is not set in environment variables.");
|
||||
}
|
||||
AuroraClient.login(env.DISCORD_BOT_TOKEN);
|
||||
|
||||
// Handle graceful shutdown
|
||||
const shutdownHandler = async () => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
console.log("🛑 Shutdown signal received. Stopping services...");
|
||||
|
||||
// Stop web server
|
||||
await webServer.stop();
|
||||
|
||||
// Stop bot
|
||||
AuroraClient.shutdown();
|
||||
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", shutdownHandler);
|
||||
process.on("SIGTERM", shutdownHandler);
|
||||
112
bot/lib/BotClient.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, test, mock, beforeEach, spyOn } from "bun:test";
|
||||
import { systemEvents, EVENTS } from "@shared/lib/events";
|
||||
|
||||
// Mock Discord.js Client and related classes
|
||||
mock.module("discord.js", () => ({
|
||||
Client: class {
|
||||
constructor() { }
|
||||
on() { }
|
||||
once() { }
|
||||
login() { }
|
||||
destroy() { }
|
||||
removeAllListeners() { }
|
||||
},
|
||||
Collection: Map,
|
||||
GatewayIntentBits: { Guilds: 1, MessageContent: 1, GuildMessages: 1, GuildMembers: 1 },
|
||||
REST: class {
|
||||
setToken() { return this; }
|
||||
put() { return Promise.resolve([]); }
|
||||
},
|
||||
Routes: {
|
||||
applicationGuildCommands: () => 'guild_route',
|
||||
applicationCommands: () => 'global_route'
|
||||
},
|
||||
MessageFlags: {}
|
||||
}));
|
||||
|
||||
// Mock loaders to avoid filesystem access during client init
|
||||
mock.module("../lib/loaders/CommandLoader", () => ({
|
||||
CommandLoader: class {
|
||||
constructor() { }
|
||||
loadFromDirectory() { return Promise.resolve({ loaded: 0, skipped: 0, errors: [] }); }
|
||||
}
|
||||
}));
|
||||
mock.module("../lib/loaders/EventLoader", () => ({
|
||||
EventLoader: class {
|
||||
constructor() { }
|
||||
loadFromDirectory() { return Promise.resolve({ loaded: 0, skipped: 0, errors: [] }); }
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock dashboard service to prevent network/db calls during event handling
|
||||
mock.module("@shared/modules/economy/lootdrop.service", () => ({
|
||||
lootdropService: { clearCaches: mock(async () => { }) }
|
||||
}));
|
||||
mock.module("@shared/modules/trade/trade.service", () => ({
|
||||
tradeService: { clearSessions: mock(() => { }) }
|
||||
}));
|
||||
mock.module("@/modules/admin/item_wizard", () => ({
|
||||
clearDraftSessions: mock(() => { })
|
||||
}));
|
||||
mock.module("@shared/modules/dashboard/dashboard.service", () => ({
|
||||
dashboardService: {
|
||||
recordEvent: mock(() => Promise.resolve())
|
||||
}
|
||||
}));
|
||||
|
||||
describe("AuroraClient System Events", () => {
|
||||
let AuroraClient: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
systemEvents.removeAllListeners();
|
||||
const module = await import("./BotClient");
|
||||
AuroraClient = module.AuroraClient;
|
||||
AuroraClient.maintenanceMode = false;
|
||||
// MUST call explicitly now
|
||||
await AuroraClient.setupSystemEvents();
|
||||
});
|
||||
|
||||
/**
|
||||
* Test Case: Maintenance Mode Toggle
|
||||
* Requirement: Client state should update when event is received
|
||||
*/
|
||||
test("should toggle maintenanceMode when MAINTENANCE_MODE event is received", async () => {
|
||||
systemEvents.emit(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled: true, reason: "Testing" });
|
||||
await new Promise(resolve => setTimeout(resolve, 30));
|
||||
expect(AuroraClient.maintenanceMode).toBe(true);
|
||||
|
||||
systemEvents.emit(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled: false });
|
||||
await new Promise(resolve => setTimeout(resolve, 30));
|
||||
expect(AuroraClient.maintenanceMode).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Test Case: Command Reload
|
||||
* Requirement: loadCommands and deployCommands should be called
|
||||
*/
|
||||
test("should reload commands when RELOAD_COMMANDS event is received", async () => {
|
||||
const loadSpy = spyOn(AuroraClient, "loadCommands").mockImplementation(() => Promise.resolve());
|
||||
const deploySpy = spyOn(AuroraClient, "deployCommands").mockImplementation(() => Promise.resolve());
|
||||
|
||||
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
expect(loadSpy).toHaveBeenCalled();
|
||||
expect(deploySpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* Test Case: Cache Clearance
|
||||
* Requirement: Service clear methods should be triggered
|
||||
*/
|
||||
test("should trigger service cache clearance when CLEAR_CACHE is received", async () => {
|
||||
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||
const { tradeService } = await import("@shared/modules/trade/trade.service");
|
||||
|
||||
systemEvents.emit(EVENTS.ACTIONS.CLEAR_CACHE);
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
expect(lootdropService.clearCaches).toHaveBeenCalled();
|
||||
expect(tradeService.clearSessions).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
200
bot/lib/BotClient.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes, MessageFlags } from "discord.js";
|
||||
import { join } from "node:path";
|
||||
import type { Command } from "@shared/lib/types";
|
||||
import { env } from "@shared/lib/env";
|
||||
import { CommandLoader } from "@lib/loaders/CommandLoader";
|
||||
import { EventLoader } from "@lib/loaders/EventLoader";
|
||||
|
||||
export class Client extends DiscordClient {
|
||||
|
||||
commands: Collection<string, Command>;
|
||||
knownCommands: Map<string, string>;
|
||||
lastCommandTimestamp: number | null = null;
|
||||
maintenanceMode: boolean = false;
|
||||
private commandLoader: CommandLoader;
|
||||
private eventLoader: EventLoader;
|
||||
|
||||
constructor({ intents }: { intents: number[] }) {
|
||||
super({ intents });
|
||||
this.commands = new Collection<string, Command>();
|
||||
this.knownCommands = new Map<string, string>();
|
||||
this.commandLoader = new CommandLoader(this);
|
||||
this.eventLoader = new EventLoader(this);
|
||||
}
|
||||
|
||||
public async setupSystemEvents() {
|
||||
const { systemEvents, EVENTS } = await import("@shared/lib/events");
|
||||
|
||||
systemEvents.on(EVENTS.ACTIONS.RELOAD_COMMANDS, async () => {
|
||||
console.log("🔄 System Action: Reloading commands...");
|
||||
try {
|
||||
await this.loadCommands(true);
|
||||
await this.deployCommands();
|
||||
|
||||
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||
await dashboardService.recordEvent({
|
||||
type: "success",
|
||||
message: "Bot: Commands reloaded and redeployed",
|
||||
icon: "✅"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to reload commands:", error);
|
||||
}
|
||||
});
|
||||
|
||||
systemEvents.on(EVENTS.ACTIONS.CLEAR_CACHE, async () => {
|
||||
console.log("<22> System Action: Clearing all internal caches...");
|
||||
|
||||
try {
|
||||
// 1. Lootdrop Service
|
||||
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||
await lootdropService.clearCaches();
|
||||
|
||||
// 2. Trade Service
|
||||
const { tradeService } = await import("@shared/modules/trade/trade.service");
|
||||
tradeService.clearSessions();
|
||||
|
||||
// 3. Item Wizard
|
||||
const { clearDraftSessions } = await import("@/modules/admin/item_wizard");
|
||||
clearDraftSessions();
|
||||
|
||||
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||
await dashboardService.recordEvent({
|
||||
type: "success",
|
||||
message: "Bot: All internal caches and sessions cleared",
|
||||
icon: "🧼"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to clear caches:", error);
|
||||
}
|
||||
});
|
||||
|
||||
systemEvents.on(EVENTS.ACTIONS.MAINTENANCE_MODE, async (data: { enabled: boolean, reason?: string }) => {
|
||||
const { enabled, reason } = data;
|
||||
console.log(`🛠️ System Action: Maintenance mode ${enabled ? "ON" : "OFF"}${reason ? ` (${reason})` : ""}`);
|
||||
this.maintenanceMode = enabled;
|
||||
});
|
||||
|
||||
systemEvents.on(EVENTS.QUEST.COMPLETED, async (data: { userId: string, quest: any, rewards: any }) => {
|
||||
const { userId, quest, rewards } = data;
|
||||
try {
|
||||
const user = await this.users.fetch(userId);
|
||||
if (!user) return;
|
||||
|
||||
const { getQuestCompletionComponents } = await import("@/modules/quest/quest.view");
|
||||
const components = getQuestCompletionComponents(quest, rewards);
|
||||
|
||||
// Try to send to the user's DM
|
||||
await user.send({
|
||||
components: components as any,
|
||||
flags: [MessageFlags.IsComponentsV2]
|
||||
}).catch(async () => {
|
||||
console.warn(`Could not DM user ${userId} quest completion message. User might have DMs disabled.`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send quest completion notification:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadCommands(reload: boolean = false) {
|
||||
if (reload) {
|
||||
this.commands.clear();
|
||||
this.knownCommands.clear();
|
||||
console.log("♻️ Reloading commands...");
|
||||
}
|
||||
|
||||
const commandsPath = join(import.meta.dir, '../commands');
|
||||
const result = await this.commandLoader.loadFromDirectory(commandsPath, reload);
|
||||
|
||||
console.log(`📦 Command loading complete: ${result.loaded} loaded, ${result.skipped} skipped, ${result.errors.length} errors`);
|
||||
}
|
||||
|
||||
async loadEvents(reload: boolean = false) {
|
||||
if (reload) {
|
||||
this.removeAllListeners();
|
||||
console.log("♻️ Reloading events...");
|
||||
}
|
||||
|
||||
const eventsPath = join(import.meta.dir, '../events');
|
||||
const result = await this.eventLoader.loadFromDirectory(eventsPath, reload);
|
||||
|
||||
console.log(`📦 Event loading complete: ${result.loaded} loaded, ${result.skipped} skipped, ${result.errors.length} errors`);
|
||||
}
|
||||
|
||||
|
||||
|
||||
async deployCommands() {
|
||||
// We use env.DISCORD_BOT_TOKEN directly so this can run without client.login()
|
||||
const token = env.DISCORD_BOT_TOKEN;
|
||||
if (!token) {
|
||||
console.error("DISCORD_BOT_TOKEN is not set.");
|
||||
return;
|
||||
}
|
||||
|
||||
const rest = new REST().setToken(token);
|
||||
const commandsData = this.commands.map(c => c.data.toJSON());
|
||||
const guildId = env.DISCORD_GUILD_ID;
|
||||
const clientId = env.DISCORD_CLIENT_ID;
|
||||
|
||||
if (!clientId) {
|
||||
console.error("DISCORD_CLIENT_ID is not set.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Started refreshing ${commandsData.length} application (/) commands.`);
|
||||
|
||||
let data;
|
||||
if (guildId) {
|
||||
console.log(`Registering commands to guild: ${guildId}`);
|
||||
data = await rest.put(
|
||||
Routes.applicationGuildCommands(clientId, guildId),
|
||||
{ body: commandsData },
|
||||
);
|
||||
// Clear global commands to avoid duplicates
|
||||
await rest.put(Routes.applicationCommands(clientId), { body: [] });
|
||||
} else {
|
||||
console.log('Registering commands globally');
|
||||
data = await rest.put(
|
||||
Routes.applicationCommands(clientId),
|
||||
{ body: commandsData },
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Successfully reloaded ${(data as any).length} application (/) commands.`);
|
||||
} catch (error: any) {
|
||||
if (error.code === 50001) {
|
||||
console.warn("Missing Access: The bot is not in the guild or lacks 'applications.commands' scope.");
|
||||
console.warn(" If you are testing locally, make sure you invited the bot with scope 'bot applications.commands'.");
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
const { setShuttingDown, waitForTransactions } = await import("./shutdown");
|
||||
const { closeDatabase } = await import("@shared/db/DrizzleClient");
|
||||
|
||||
console.log("🛑 Shutdown signal received. Starting graceful shutdown...");
|
||||
setShuttingDown(true);
|
||||
|
||||
// Wait for transactions to complete
|
||||
console.log("⏳ Waiting for active transactions to complete...");
|
||||
await waitForTransactions(10000);
|
||||
|
||||
// Destroy Discord client
|
||||
console.log("🔌 Disconnecting from Discord...");
|
||||
this.destroy();
|
||||
|
||||
// Close database
|
||||
console.log("🗄️ Closing database connection...");
|
||||
await closeDatabase();
|
||||
|
||||
console.log("👋 Graceful shutdown complete. Exiting.");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers, GatewayIntentBits.DirectMessages] });
|
||||
77
bot/lib/clientStats.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, test, expect, beforeEach, mock, afterEach } from "bun:test";
|
||||
import { getClientStats, clearStatsCache } from "./clientStats";
|
||||
|
||||
// Mock AuroraClient
|
||||
mock.module("./BotClient", () => ({
|
||||
AuroraClient: {
|
||||
guilds: {
|
||||
cache: {
|
||||
size: 5,
|
||||
},
|
||||
},
|
||||
ws: {
|
||||
ping: 42,
|
||||
},
|
||||
users: {
|
||||
cache: {
|
||||
size: 100,
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
size: 20,
|
||||
},
|
||||
knownCommands: {
|
||||
size: 20,
|
||||
},
|
||||
lastCommandTimestamp: 1641481200000,
|
||||
},
|
||||
}));
|
||||
|
||||
describe("clientStats", () => {
|
||||
beforeEach(() => {
|
||||
clearStatsCache();
|
||||
});
|
||||
|
||||
test("should return client stats", () => {
|
||||
const stats = getClientStats();
|
||||
|
||||
expect(stats.guilds).toBe(5);
|
||||
expect(stats.ping).toBe(42);
|
||||
expect(stats.cachedUsers).toBe(100);
|
||||
expect(stats.commandsRegistered).toBe(20);
|
||||
expect(typeof stats.uptime).toBe("number"); // Can't mock process.uptime easily
|
||||
expect(stats.lastCommandTimestamp).toBe(1641481200000);
|
||||
});
|
||||
|
||||
test("should cache stats for 30 seconds", () => {
|
||||
const stats1 = getClientStats();
|
||||
const stats2 = getClientStats();
|
||||
|
||||
// Should return same object (cached)
|
||||
expect(stats1).toBe(stats2);
|
||||
});
|
||||
|
||||
test("should refresh cache after TTL expires", async () => {
|
||||
const stats1 = getClientStats();
|
||||
|
||||
// Wait for cache to expire (simulate by clearing and waiting)
|
||||
await new Promise(resolve => setTimeout(resolve, 35));
|
||||
clearStatsCache();
|
||||
|
||||
const stats2 = getClientStats();
|
||||
|
||||
// Should be different objects (new fetch)
|
||||
expect(stats1).not.toBe(stats2);
|
||||
// But values should be the same (mocked client)
|
||||
expect(stats1.guilds).toBe(stats2.guilds);
|
||||
});
|
||||
|
||||
test("clearStatsCache should invalidate cache", () => {
|
||||
const stats1 = getClientStats();
|
||||
clearStatsCache();
|
||||
const stats2 = getClientStats();
|
||||
|
||||
// Should be different objects
|
||||
expect(stats1).not.toBe(stats2);
|
||||
});
|
||||
});
|
||||
50
bot/lib/clientStats.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { AuroraClient } from "./BotClient";
|
||||
import type { ClientStats } from "@shared/modules/dashboard/dashboard.types";
|
||||
|
||||
// Cache for client stats (30 second TTL)
|
||||
let cachedStats: ClientStats | null = null;
|
||||
let lastFetchTime: number = 0;
|
||||
const CACHE_TTL_MS = 30 * 1000; // 30 seconds
|
||||
|
||||
/**
|
||||
* Get Discord client statistics with caching
|
||||
* Respects rate limits by caching for 30 seconds
|
||||
*/
|
||||
export function getClientStats(): ClientStats {
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached stats if still valid
|
||||
if (cachedStats && (now - lastFetchTime) < CACHE_TTL_MS) {
|
||||
return cachedStats;
|
||||
}
|
||||
|
||||
// Fetch fresh stats
|
||||
const stats: ClientStats = {
|
||||
bot: {
|
||||
name: AuroraClient.user?.username || "Aurora",
|
||||
avatarUrl: AuroraClient.user?.displayAvatarURL() || null,
|
||||
status: AuroraClient.user?.presence.activities[0]?.state || AuroraClient.user?.presence.activities[0]?.name || null,
|
||||
},
|
||||
guilds: AuroraClient.guilds.cache.size,
|
||||
ping: AuroraClient.ws.ping,
|
||||
cachedUsers: AuroraClient.users.cache.size,
|
||||
commandsRegistered: AuroraClient.commands.size,
|
||||
commandsKnown: AuroraClient.knownCommands.size,
|
||||
uptime: process.uptime(),
|
||||
lastCommandTimestamp: AuroraClient.lastCommandTimestamp,
|
||||
};
|
||||
|
||||
// Update cache
|
||||
cachedStats = stats;
|
||||
lastFetchTime = now;
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the stats cache (useful for testing)
|
||||
*/
|
||||
export function clearStatsCache(): void {
|
||||
cachedStats = null;
|
||||
lastFetchTime = 0;
|
||||
}
|
||||
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
@@ -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;
|
||||
}
|
||||
}
|
||||