forked from syntaxbullet/aurorabot
Compare commits
164 Commits
73ad889018
...
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 |
7
.citrine
Normal file
7
.citrine
Normal file
@@ -0,0 +1,7 @@
|
||||
### Frontend
|
||||
[8bb0] [>] implement items page
|
||||
[de51] [ ] implement classes page
|
||||
[d108] [ ] implement quests page
|
||||
[8bbe] [ ] implement lootdrops page
|
||||
[094e] [ ] implement moderation page
|
||||
[220d] [ ] implement transactions page
|
||||
@@ -20,7 +20,15 @@ DISCORD_BOT_TOKEN=your-discord-bot-token
|
||||
DISCORD_CLIENT_ID=your-discord-client-id
|
||||
DISCORD_GUILD_ID=your-discord-guild-id
|
||||
|
||||
# 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
|
||||
132
.gitea/workflows/ci-deploy.yml
Normal file
132
.gitea/workflows/ci-deploy.yml
Normal file
@@ -0,0 +1,132 @@
|
||||
name: CI / Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: aurora_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
run: |
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
echo "$HOME/.bun/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Create Config File
|
||||
run: |
|
||||
mkdir -p shared/config
|
||||
cat <<EOF > shared/config/config.json
|
||||
{
|
||||
"leveling": { "base": 100, "exponent": 2.5, "chat": { "cooldownMs": 60000, "minXp": 15, "maxXp": 25 } },
|
||||
"economy": {
|
||||
"daily": { "amount": "100", "streakBonus": "10", "weeklyBonus": "50", "cooldownMs": 86400000 },
|
||||
"transfers": { "allowSelfTransfer": false, "minAmount": "1" },
|
||||
"exam": { "multMin": 0.05, "multMax": 0.03 }
|
||||
},
|
||||
"inventory": { "maxStackSize": "99", "maxSlots": 50 },
|
||||
"commands": {},
|
||||
"lootdrop": {
|
||||
"activityWindowMs": 120000, "minMessages": 1, "spawnChance": 1, "cooldownMs": 3000,
|
||||
"reward": { "min": 40, "max": 150, "currency": "Astral Units" }
|
||||
},
|
||||
"studentRole": "123", "visitorRole": "456", "colorRoles": [],
|
||||
"moderation": {
|
||||
"prune": { "maxAmount": 100, "confirmThreshold": 50, "batchSize": 100, "batchDelayMs": 1000 },
|
||||
"cases": { "dmOnWarn": false }
|
||||
},
|
||||
"trivia": {
|
||||
"entryFee": "50", "rewardMultiplier": 1.5, "timeoutSeconds": 30, "cooldownMs": 60000,
|
||||
"categories": [], "difficulty": "random"
|
||||
},
|
||||
"system": {}
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Typecheck
|
||||
run: bunx tsc --noEmit
|
||||
|
||||
- name: Build Panel
|
||||
run: bun run panel:build
|
||||
|
||||
- name: Setup Test Database
|
||||
run: bun run db:push:local
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/aurora_test
|
||||
DISCORD_BOT_TOKEN: test_token
|
||||
DISCORD_CLIENT_ID: "123"
|
||||
DISCORD_GUILD_ID: "123"
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
cat <<EOF > .env.test
|
||||
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/aurora_test"
|
||||
DISCORD_BOT_TOKEN="test_token"
|
||||
DISCORD_CLIENT_ID="123456789"
|
||||
DISCORD_GUILD_ID="123456789"
|
||||
DISCORD_CLIENT_SECRET="test-client-secret"
|
||||
SESSION_SECRET="test-session-secret"
|
||||
ADMIN_TOKEN="admin_token_123"
|
||||
LOG_LEVEL="error"
|
||||
EOF
|
||||
bash shared/scripts/test-isolated.sh --integration
|
||||
env:
|
||||
NODE_ENV: test
|
||||
|
||||
deploy:
|
||||
needs: test
|
||||
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Configure SSH
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||
run: |
|
||||
install -m 700 -d ~/.ssh
|
||||
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Deploy on VPS
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||
VPS_USER: ${{ secrets.VPS_USER }}
|
||||
VPS_PROJECT_PATH: ${{ secrets.VPS_PROJECT_PATH }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REMOTE_DIR="${VPS_PROJECT_PATH:-~/Aurora}"
|
||||
ssh -o BatchMode=yes "$VPS_USER@$VPS_HOST" "cd $REMOTE_DIR && bash shared/scripts/deploy.sh"
|
||||
|
||||
- name: Post-deploy Health Check
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||
VPS_USER: ${{ secrets.VPS_USER }}
|
||||
VPS_PROJECT_PATH: ${{ secrets.VPS_PROJECT_PATH }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REMOTE_DIR="${VPS_PROJECT_PATH:-~/Aurora}"
|
||||
ssh -o BatchMode=yes "$VPS_USER@$VPS_HOST" "cd $REMOTE_DIR && curl -fsS http://127.0.0.1:3000/api/health >/dev/null"
|
||||
10
.github/workflows/deploy.yml
vendored
10
.github/workflows/deploy.yml
vendored
@@ -38,9 +38,9 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
run: |
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
echo "$HOME/.bun/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Install Dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
# Create .env.test for test-sequential.sh / bun test
|
||||
# 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"
|
||||
@@ -95,6 +95,6 @@ jobs:
|
||||
ADMIN_TOKEN="admin_token_123"
|
||||
LOG_LEVEL="error"
|
||||
EOF
|
||||
bash shared/scripts/test-sequential.sh
|
||||
bash shared/scripts/test-isolated.sh --integration
|
||||
env:
|
||||
NODE_ENV: test
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -3,6 +3,7 @@ node_modules
|
||||
docker-compose.override.yml
|
||||
shared/db-logs
|
||||
shared/db/data
|
||||
shared/db/backups
|
||||
shared/db/loga
|
||||
.cursor
|
||||
# dependencies (bun install)
|
||||
@@ -46,5 +47,8 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
src/db/data
|
||||
src/db/log
|
||||
scratchpad/
|
||||
tickets/
|
||||
bot/assets/graphics/items
|
||||
tickets/
|
||||
.citrine.local
|
||||
.worktrees/
|
||||
.superpowers/
|
||||
|
||||
297
AGENTS.md
297
AGENTS.md
@@ -1,242 +1,135 @@
|
||||
# AGENTS.md - AI Coding Agent Guidelines
|
||||
# AGENTS.md
|
||||
|
||||
## Project Overview
|
||||
This file documents the current implementation shape of the Aurora repository.
|
||||
|
||||
AuroraBot is a Discord bot with a REST API built using Bun, Discord.js, and PostgreSQL with Drizzle ORM.
|
||||
|
||||
## Build/Lint/Test Commands
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
bun --watch bot/index.ts # Run bot + API server with hot reload
|
||||
# App
|
||||
bun run dev # bot + API in one Bun process with watch mode
|
||||
docker compose up # app + db
|
||||
docker compose up app # app only
|
||||
docker compose up db # database only
|
||||
|
||||
# Testing
|
||||
bun test # Run all tests
|
||||
bun test path/to/file.test.ts # Run single test file
|
||||
bun test --watch # Watch mode
|
||||
bun test shared/modules/economy # Run tests in directory
|
||||
bun test # Bun's native runner
|
||||
bun run test # repo test wrapper script
|
||||
bun run test:ci # include CI/integration path
|
||||
|
||||
# Database
|
||||
bun run generate # Generate Drizzle migrations (Docker)
|
||||
bun run migrate # Run migrations (Docker)
|
||||
bun run db:push # Push schema changes (Docker)
|
||||
bun run db:push:local # Push schema changes (local)
|
||||
bun run db:studio # Open Drizzle Studio
|
||||
bun run db:push # drizzle-kit push via Docker
|
||||
bun run db:push:local # drizzle-kit push locally
|
||||
bun run db:generate # drizzle-kit generate via Docker
|
||||
bun run db:migrate # drizzle-kit migrate via Docker
|
||||
bun run db:studio # local Drizzle Studio on :4983
|
||||
|
||||
# Docker (recommended for local dev)
|
||||
docker compose up # Start bot, API, and database
|
||||
docker compose up app # Start just the app (bot + API)
|
||||
docker compose up db # Start just the database
|
||||
# Panel
|
||||
bun run panel:dev # Vite dev server on :5173
|
||||
bun run panel:build # build panel/dist
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
## Architecture
|
||||
|
||||
```
|
||||
bot/ # Discord bot
|
||||
├── commands/ # Slash commands by category
|
||||
├── events/ # Discord event handlers
|
||||
├── lib/ # Bot core (BotClient, handlers, loaders)
|
||||
├── modules/ # Feature modules (views, interactions)
|
||||
└── graphics/ # Canvas image generation
|
||||
Aurora is a single-process Bun application:
|
||||
|
||||
shared/ # Shared between bot and web
|
||||
├── db/ # Database schema and migrations
|
||||
├── lib/ # Utils, config, errors, types
|
||||
└── modules/ # Domain services (economy, user, etc.)
|
||||
- `bot/index.ts` boots shared config, registers domain listeners, starts the API server, then logs into Discord.
|
||||
- `api/src/server.ts` hosts REST routes, WebSocket traffic, and built panel assets.
|
||||
- `shared/modules/*` contains the business logic used by both the bot and the API.
|
||||
- `shared/games/*` contains reusable game plugins; `api/src/games/*` runs rooms and WebSocket orchestration.
|
||||
|
||||
web/ # API server
|
||||
└── src/routes/ # API route handlers
|
||||
Current high-level layout:
|
||||
|
||||
```text
|
||||
bot/ Discord commands, events, views, interactions
|
||||
api/ Bun HTTP + WebSocket server
|
||||
panel/ React dashboard
|
||||
shared/db/ Drizzle client and schema
|
||||
shared/lib/ config, env, errors, logger, events, constants
|
||||
shared/modules/ domain services
|
||||
shared/games/ game plugins shared by API and panel
|
||||
```
|
||||
|
||||
## Import Conventions
|
||||
## Import conventions
|
||||
|
||||
Use path aliases defined in tsconfig.json:
|
||||
Use path aliases from the repo `tsconfig.json`:
|
||||
|
||||
```typescript
|
||||
// External packages first
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
- `@/*` -> `bot/*`
|
||||
- `@commands/*` -> `bot/commands/*`
|
||||
- `@db/*` -> `shared/db/*`
|
||||
- `@lib/*` -> `bot/lib/*`
|
||||
- `@modules/*` -> `bot/modules/*`
|
||||
- `@shared/*` -> `shared/*`
|
||||
|
||||
// Path aliases second
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { users } from "@db/schema";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { handleTradeInteraction } from "@modules/trade/trade.interaction";
|
||||
Import order in the repo is generally:
|
||||
|
||||
// Relative imports last
|
||||
import { localHelper } from "./helper";
|
||||
```
|
||||
1. external packages
|
||||
2. aliases
|
||||
3. relative imports
|
||||
|
||||
**Available Aliases:**
|
||||
## File patterns
|
||||
|
||||
- `@/*` - bot/
|
||||
- `@shared/*` - shared/
|
||||
- `@db/*` - shared/db/
|
||||
- `@lib/*` - bot/lib/
|
||||
- `@modules/*` - bot/modules/
|
||||
- `@commands/*` - bot/commands/
|
||||
- `*.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
|
||||
|
||||
## Naming Conventions
|
||||
## Runtime config
|
||||
|
||||
| Element | Convention | Example |
|
||||
| ---------------- | ----------------------- | ---------------------------------------- |
|
||||
| Files | camelCase or kebab-case | `BotClient.ts`, `economy.service.ts` |
|
||||
| Classes | PascalCase | `CommandHandler`, `UserError` |
|
||||
| Functions | camelCase | `createCommand`, `handleShopInteraction` |
|
||||
| Constants | UPPER_SNAKE_CASE | `EVENTS`, `BRANDING` |
|
||||
| Enums | PascalCase | `TimerType`, `TransactionType` |
|
||||
| Services | camelCase singleton | `economyService`, `userService` |
|
||||
| Types/Interfaces | PascalCase | `Command`, `Event`, `GameConfigType` |
|
||||
| DB tables | snake_case | `users`, `moderation_cases` |
|
||||
| Custom IDs | snake_case with prefix | `shop_buy_`, `trade_accept_` |
|
||||
- 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`.
|
||||
|
||||
## Code Patterns
|
||||
## Interaction routing
|
||||
|
||||
### Command Definition
|
||||
Global component routing is defined in `bot/lib/interaction.routes.ts` and consumed by `ComponentInteractionHandler`.
|
||||
|
||||
```typescript
|
||||
export const commandName = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("commandname")
|
||||
.setDescription("Description"),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
// Implementation
|
||||
},
|
||||
});
|
||||
```
|
||||
Current route table:
|
||||
|
||||
### Service Pattern (Singleton Object)
|
||||
- `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`
|
||||
|
||||
```typescript
|
||||
export const serviceName = {
|
||||
methodName: async (params: ParamType): Promise<ReturnType> => {
|
||||
return await withTransaction(async (tx) => {
|
||||
// Database operations
|
||||
});
|
||||
},
|
||||
};
|
||||
```
|
||||
Some features still use local collectors instead of the global route table, notably inventory.
|
||||
|
||||
### Module File Organization
|
||||
## Commands and access control
|
||||
|
||||
- `*.view.ts` - Creates Discord embeds/components
|
||||
- `*.interaction.ts` - Handles button/select/modal interactions
|
||||
- `*.types.ts` - Module-specific TypeScript types
|
||||
- `*.service.ts` - Business logic (in shared/modules/)
|
||||
- `*.test.ts` - Test files (co-located with source)
|
||||
- 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.
|
||||
|
||||
## Error Handling
|
||||
## API and panel
|
||||
|
||||
### Custom Error Classes
|
||||
- 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.
|
||||
|
||||
```typescript
|
||||
import { UserError, SystemError } from "@shared/lib/errors";
|
||||
## Database notes
|
||||
|
||||
// User-facing errors (shown to user)
|
||||
throw new UserError("You don't have enough coins!");
|
||||
|
||||
// System errors (logged, generic message shown)
|
||||
throw new SystemError("Database connection failed");
|
||||
```
|
||||
|
||||
### Standard Error Pattern
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const result = await service.method();
|
||||
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
||||
} catch (error) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Unexpected error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("An unexpected error occurred.")],
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Database Patterns
|
||||
|
||||
### Transaction Usage
|
||||
|
||||
```typescript
|
||||
import { withTransaction } from "@/lib/db";
|
||||
|
||||
return await withTransaction(async (tx) => {
|
||||
const user = await tx.query.users.findFirst({
|
||||
where: eq(users.id, discordId),
|
||||
});
|
||||
|
||||
await tx
|
||||
.update(users)
|
||||
.set({ coins: newBalance })
|
||||
.where(eq(users.id, discordId));
|
||||
await tx.insert(transactions).values({ userId: discordId, amount, type });
|
||||
|
||||
return user;
|
||||
}, existingTx); // Pass existing tx if in nested transaction
|
||||
```
|
||||
|
||||
### Schema Notes
|
||||
|
||||
- Use `bigint` mode for Discord IDs and currency amounts
|
||||
- Relations defined separately from table definitions
|
||||
- Schema modules: `shared/db/schema/*.ts` (users, inventory, economy, quests, moderation)
|
||||
- Docker Compose uses PostgreSQL 17.
|
||||
- Discord IDs and currency/xp values are stored as `bigint`.
|
||||
- `withTransaction()` lives in `bot/lib/db.ts` and is the normal way shared services compose DB work.
|
||||
|
||||
## Testing
|
||||
|
||||
### Test File Structure
|
||||
- Tests use `bun:test`.
|
||||
- Mock modules before importing the unit under test.
|
||||
- Most service tests stub `DrizzleClient` or `withTransaction()` rather than hitting the real database.
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||
## Key entrypoints
|
||||
|
||||
// Mock modules BEFORE imports
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: { query: mockQuery },
|
||||
}));
|
||||
|
||||
describe("serviceName", () => {
|
||||
beforeEach(() => {
|
||||
mockFn.mockClear();
|
||||
});
|
||||
|
||||
it("should handle expected case", async () => {
|
||||
// Arrange
|
||||
mockFn.mockResolvedValue(testData);
|
||||
|
||||
// Act
|
||||
const result = await service.method(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(expected);
|
||||
expect(mockFn).toHaveBeenCalledWith(expectedArgs);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime:** Bun 1.0+
|
||||
- **Bot:** Discord.js 14.x
|
||||
- **Web:** Bun HTTP Server (REST API)
|
||||
- **Database:** PostgreSQL 16+ with Drizzle ORM
|
||||
- **UI:** Discord embeds and components
|
||||
- **Validation:** Zod
|
||||
- **Testing:** Bun Test
|
||||
- **Container:** Docker
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
| Purpose | File |
|
||||
| ------------- | ---------------------- |
|
||||
| Bot entry | `bot/index.ts` |
|
||||
| DB schema | `shared/db/schema.ts` |
|
||||
| Error classes | `shared/lib/errors.ts` |
|
||||
| Config loader | `shared/lib/config.ts` |
|
||||
| Environment | `shared/lib/env.ts` |
|
||||
| Embed helpers | `bot/lib/embeds.ts` |
|
||||
| Command utils | `shared/lib/utils.ts` |
|
||||
- `bot/index.ts`
|
||||
- `bot/lib/BotClient.ts`
|
||||
- `api/src/server.ts`
|
||||
- `api/src/routes/index.ts`
|
||||
- `shared/lib/config.ts`
|
||||
- `shared/db/DrizzleClient.ts`
|
||||
- `shared/db/schema/index.ts`
|
||||
|
||||
42
Dockerfile
42
Dockerfile
@@ -16,6 +16,7 @@ FROM base AS deps
|
||||
|
||||
# Copy only package files first (better layer caching)
|
||||
COPY package.json bun.lock ./
|
||||
COPY panel/package.json panel/
|
||||
|
||||
# Install dependencies
|
||||
RUN bun install --frozen-lockfile
|
||||
@@ -33,3 +34,44 @@ 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"]
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
# =============================================================================
|
||||
# Stage 1: Dependencies & Build
|
||||
# =============================================================================
|
||||
FROM oven/bun:latest AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies needed for build
|
||||
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install root project dependencies
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# =============================================================================
|
||||
# Stage 2: Production Runtime
|
||||
# =============================================================================
|
||||
FROM oven/bun:latest AS production
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user for security (bun user already exists with 1000:1000)
|
||||
# No need to create user/group
|
||||
|
||||
|
||||
|
||||
# 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/web/src ./web/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/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"]
|
||||
269
README.md
269
README.md
@@ -1,158 +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.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
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.
|
||||
## What exists today
|
||||
|
||||
**New in v1.0:** Aurora now includes a fully integrated **REST API** for accessing bot data, statistics, and configuration, running alongside the bot in a single process.
|
||||
- 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
|
||||
|
||||
### Discord Bot
|
||||
* **Class System**: Users can join different classes.
|
||||
* **Economy**: Complete economy system with balance, transactions, and daily rewards.
|
||||
* **Inventory & Items**: Sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management.
|
||||
* **Leveling**: XP-based leveling system to track user activity and progress.
|
||||
* **Quests**: Quest system with requirements and rewards.
|
||||
* **Trading**: Secure trading system between users.
|
||||
* **Lootdrops**: Random loot drops in channels to engage users.
|
||||
* **Admin Tools**: Administrative commands for server management.
|
||||
```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
|
||||
```
|
||||
|
||||
### REST API
|
||||
* **Live Analytics**: Real-time statistics endpoint (commands, transactions).
|
||||
* **Configuration Management**: Update bot settings via API.
|
||||
* **Database Inspection**: Integrated Drizzle Studio access.
|
||||
* **WebSocket Support**: Real-time event streaming for live updates.
|
||||
Important points:
|
||||
|
||||
## 🏗️ Architecture
|
||||
- `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`.
|
||||
|
||||
Aurora uses a **Single Process Monolith** architecture to maximize performance and simplify resource sharing.
|
||||
|
||||
* **Unified Runtime**: Both the Discord Client and the REST API run within the same Bun process.
|
||||
* **Shared State**: This allows the API to access live bot memory (caches, gateways) directly without complex inter-process communication (IPC).
|
||||
* **Simplified Deployment**: You only need to deploy a single Docker container.
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
* **Runtime**: [Bun](https://bun.sh/)
|
||||
* **Bot Framework**: [Discord.js](https://discord.js.org/)
|
||||
* **API Framework**: Bun HTTP Server (REST API)
|
||||
* **UI**: Discord embeds and components
|
||||
* **Database**: [PostgreSQL](https://www.postgresql.org/)
|
||||
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
||||
* **Validation**: [Zod](https://zod.dev/)
|
||||
* **Containerization**: [Docker](https://www.docker.com/)
|
||||
|
||||
## 🚀 Getting Started
|
||||
## Getting started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
* [Bun](https://bun.sh/) (latest version)
|
||||
* [Docker](https://www.docker.com/) & Docker Compose
|
||||
- Bun
|
||||
- Docker and Docker Compose
|
||||
- A Discord application with bot token, client ID, and client secret
|
||||
|
||||
### Installation
|
||||
### Setup
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd aurora
|
||||
```
|
||||
1. Install dependencies.
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
3. **Environment Setup**
|
||||
Copy the example environment file and configure it:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
Edit `.env` with your Discord bot token, Client ID, and database credentials.
|
||||
2. Create your environment file.
|
||||
|
||||
> **Note**: The `DATABASE_URL` in `.env.example` is pre-configured for Docker.
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
4. **Start the Database**
|
||||
Run the database service using Docker Compose:
|
||||
```bash
|
||||
docker compose up -d db
|
||||
```
|
||||
3. Start PostgreSQL.
|
||||
|
||||
5. **Run Migrations**
|
||||
```bash
|
||||
bun run migrate
|
||||
```
|
||||
OR
|
||||
```bash
|
||||
bun run db:push
|
||||
```
|
||||
```bash
|
||||
docker compose up -d db
|
||||
```
|
||||
|
||||
### Running the Bot & API
|
||||
4. Initialize the schema.
|
||||
|
||||
```bash
|
||||
bun run db:push:local
|
||||
```
|
||||
|
||||
If you prefer running schema changes through Docker:
|
||||
|
||||
```bash
|
||||
bun run migrate
|
||||
```
|
||||
|
||||
5. Start the bot and API.
|
||||
|
||||
**Development Mode** (with hot reload):
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
* Bot: Online in Discord
|
||||
* API: http://localhost:3000
|
||||
|
||||
**Production Mode**:
|
||||
Build and run with Docker (recommended):
|
||||
The Bun server listens on `http://localhost:3000`.
|
||||
|
||||
### Panel development
|
||||
|
||||
The Bun server can serve a built panel, but day-to-day panel work is done with Vite:
|
||||
|
||||
```bash
|
||||
docker compose up -d app
|
||||
bun run panel:dev
|
||||
```
|
||||
|
||||
### 🔐 Accessing Production Services (SSH Tunnel)
|
||||
The panel dev server runs on `http://localhost:5173` and proxies `/api`, `/auth`, `/assets`, and `/ws` to `http://localhost:3000`.
|
||||
|
||||
For security, the Production Database and API are **not exposed** to the public internet by default. They are only accessible via localhost on the server.
|
||||
To build the panel for the integrated Bun server:
|
||||
|
||||
To access them from your local machine, use the included SSH tunnel script.
|
||||
|
||||
1. Add your VPS details to your local `.env` file:
|
||||
```env
|
||||
VPS_USER=root
|
||||
VPS_HOST=123.45.67.89
|
||||
```
|
||||
|
||||
2. Run the remote connection script:
|
||||
```bash
|
||||
bun run remote
|
||||
```
|
||||
|
||||
This will establish secure tunnels for:
|
||||
* **API**: http://localhost:3000
|
||||
* **Drizzle Studio**: http://localhost:4983
|
||||
|
||||
## 📜 Scripts
|
||||
|
||||
* `bun run dev`: Start the bot and API server in watch mode.
|
||||
* `bun run remote`: Open SSH tunnel to production services.
|
||||
* `bun run generate`: Generate Drizzle migrations.
|
||||
* `bun run migrate`: Apply migrations (via Docker).
|
||||
* `bun run db:studio`: Open Drizzle Studio to inspect the database.
|
||||
* `bun test`: Run tests.
|
||||
|
||||
## 📂 Project Structure
|
||||
|
||||
```
|
||||
├── bot # Discord Bot logic & entry point
|
||||
├── web # REST API Server
|
||||
├── shared # Shared code (Database, Config, Types)
|
||||
├── drizzle # Drizzle migration files
|
||||
├── scripts # Utility scripts
|
||||
├── docker-compose.yml
|
||||
└── package.json
|
||||
```bash
|
||||
bun run panel:build
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
## Useful scripts
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
```bash
|
||||
# App
|
||||
bun run dev
|
||||
docker compose up
|
||||
docker compose up app
|
||||
docker compose up db
|
||||
|
||||
## 📄 License
|
||||
# Database
|
||||
bun run db:push
|
||||
bun run db:push:local
|
||||
bun run db:generate
|
||||
bun run db:migrate
|
||||
bun run db:studio
|
||||
bun run db:backup
|
||||
bun run db:restore
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
# Panel
|
||||
bun run panel:dev
|
||||
bun run panel:build
|
||||
|
||||
# Tests
|
||||
bun test
|
||||
bun run test
|
||||
bun run test:ci
|
||||
|
||||
# Ops
|
||||
bun run remote
|
||||
bun run deploy
|
||||
bun run deploy:remote
|
||||
```
|
||||
|
||||
## Environment notes
|
||||
|
||||
The main variables you need in `.env` are:
|
||||
|
||||
- `DISCORD_BOT_TOKEN`
|
||||
- `DISCORD_CLIENT_ID`
|
||||
- `DISCORD_CLIENT_SECRET`
|
||||
- `DISCORD_GUILD_ID`
|
||||
- `ADMIN_USER_IDS`
|
||||
- `SESSION_SECRET`
|
||||
- `DB_USER`
|
||||
- `DB_PASSWORD`
|
||||
- `DB_NAME`
|
||||
- `DATABASE_URL`
|
||||
- `PANEL_BASE_URL`
|
||||
|
||||
Players can authenticate into the panel only after they exist in the `users` table. Admin access is determined by `ADMIN_USER_IDS`, and panel sessions are stored in signed cookies keyed by `SESSION_SECRET`.
|
||||
|
||||
## API and panel summary
|
||||
|
||||
- Public routes: `/auth/*`, `/api/health`
|
||||
- Player-accessible API routes: `/api/stats`, `/api/health`, `/api/me`, `/api/me/inventory`
|
||||
- Admin-only API routes: the rest of `/api/*`
|
||||
- WebSocket: `/ws` with cookie-based auth
|
||||
- Static assets: `/assets/*`
|
||||
|
||||
## Project structure
|
||||
|
||||
```text
|
||||
bot/
|
||||
commands/
|
||||
events/
|
||||
lib/
|
||||
modules/
|
||||
|
||||
api/
|
||||
src/
|
||||
routes/
|
||||
games/
|
||||
|
||||
panel/
|
||||
src/
|
||||
|
||||
shared/
|
||||
db/
|
||||
games/
|
||||
lib/
|
||||
modules/
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [AGENTS.md](AGENTS.md): repo-wide implementation guidance
|
||||
- [api/README.md](api/README.md): API surface and auth model
|
||||
- [docs/new-design/DESIGN.md](docs/new-design/DESIGN.md): current panel design language
|
||||
|
||||
0
web/.gitignore → api/.gitignore
vendored
0
web/.gitignore → api/.gitignore
vendored
130
api/README.md
Normal file
130
api/README.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Aurora API
|
||||
|
||||
Aurora's API is a Bun server that runs inside the same process as the Discord bot. It serves REST routes, the authenticated WebSocket endpoint, static assets, and built panel files.
|
||||
|
||||
## Runtime model
|
||||
|
||||
- Entry point: `api/src/server.ts`
|
||||
- Route dispatcher: `api/src/routes/index.ts`
|
||||
- Auth: Discord OAuth with signed session cookies
|
||||
- WebSocket: `/ws`
|
||||
- Static assets: `/assets/*`
|
||||
- Built panel fallback: `panel/dist`
|
||||
|
||||
## Access model
|
||||
|
||||
Public:
|
||||
|
||||
- `GET /api/health`
|
||||
- `/auth/discord`
|
||||
- `/auth/callback`
|
||||
- `POST /auth/logout`
|
||||
- `GET /auth/me`
|
||||
|
||||
Player-accessible API routes:
|
||||
|
||||
- `GET /api/stats`
|
||||
- `GET /api/health`
|
||||
- `GET /api/me`
|
||||
- `GET /api/me/inventory`
|
||||
|
||||
Admin-only API routes:
|
||||
|
||||
- everything else under `/api/*`
|
||||
|
||||
Admin vs player is derived from `ADMIN_USER_IDS`. A user must already exist in the `users` table to complete panel login.
|
||||
|
||||
## Route summary
|
||||
|
||||
### Auth
|
||||
|
||||
- `GET /auth/discord`
|
||||
- `GET /auth/callback`
|
||||
- `POST /auth/logout`
|
||||
- `GET /auth/me`
|
||||
|
||||
### Dashboard and system
|
||||
|
||||
- `GET /api/health`
|
||||
- `GET /api/stats`
|
||||
- `GET /api/stats/activity`
|
||||
- `POST /api/actions/reload-commands`
|
||||
- `POST /api/actions/clear-cache`
|
||||
- `POST /api/actions/maintenance-mode`
|
||||
|
||||
### Settings
|
||||
|
||||
- `GET /api/settings`
|
||||
- `POST /api/settings`
|
||||
- `GET /api/settings/meta`
|
||||
- `GET /api/guilds/:guildId/settings`
|
||||
- `PUT|PATCH /api/guilds/:guildId/settings`
|
||||
- `DELETE /api/guilds/:guildId/settings`
|
||||
|
||||
### Users, classes, and inventory
|
||||
|
||||
- `GET /api/me`
|
||||
- `GET /api/me/inventory`
|
||||
- `GET /api/users`
|
||||
- `GET /api/users/:id`
|
||||
- `PUT /api/users/:id`
|
||||
- `GET /api/users/:id/inventory`
|
||||
- `POST /api/users/:id/inventory`
|
||||
- `DELETE /api/users/:id/inventory/:itemId`
|
||||
- `GET /api/classes`
|
||||
- `POST /api/classes`
|
||||
- `PUT /api/classes/:id`
|
||||
- `DELETE /api/classes/:id`
|
||||
|
||||
### Game content
|
||||
|
||||
- `GET /api/items`
|
||||
- `POST /api/items`
|
||||
- `GET /api/items/:id`
|
||||
- `PUT /api/items/:id`
|
||||
- `DELETE /api/items/:id`
|
||||
- `POST /api/items/:id/icon`
|
||||
- `GET /api/quests`
|
||||
- `POST /api/quests`
|
||||
- `PUT /api/quests/:id`
|
||||
- `DELETE /api/quests/:id`
|
||||
- `GET /api/lootdrops`
|
||||
- `POST /api/lootdrops`
|
||||
- `DELETE /api/lootdrops/:messageId`
|
||||
|
||||
### Moderation and economy history
|
||||
|
||||
- `GET /api/moderation`
|
||||
- `POST /api/moderation`
|
||||
- `GET /api/transactions`
|
||||
|
||||
## WebSocket
|
||||
|
||||
`/ws` requires a valid `aurora_session` cookie.
|
||||
|
||||
Current behavior:
|
||||
|
||||
- dashboard clients subscribe to `dashboard`
|
||||
- game clients also use lobby and room-scoped traffic through `GameServer`
|
||||
- `PING` from the client returns `PONG`
|
||||
- dashboard stats are broadcast every 5 seconds while at least one client is connected
|
||||
- hard limits in `api/src/server.ts`:
|
||||
- 200 concurrent connections
|
||||
- 16 KB max payload
|
||||
- 60 second idle timeout
|
||||
|
||||
## Development
|
||||
|
||||
Start the backend:
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Optional panel dev server:
|
||||
|
||||
```bash
|
||||
bun run panel:dev
|
||||
```
|
||||
|
||||
Panel dev runs on `http://localhost:5173` and proxies API/auth/assets/WebSocket requests to `http://localhost:3000`.
|
||||
0
web/bun-env.d.ts → api/bun-env.d.ts
vendored
0
web/bun-env.d.ts → api/bun-env.d.ts
vendored
68
api/src/AGENTS.md
Normal file
68
api/src/AGENTS.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# API layer
|
||||
|
||||
## Server shape
|
||||
|
||||
- Aurora uses Bun's native `serve()` API in `api/src/server.ts`.
|
||||
- Route modules are aggregated in `api/src/routes/index.ts`.
|
||||
- A route module returns `null` when it does not match so the dispatcher can continue.
|
||||
- After route handling, the server tries `panel/dist` for SPA/static files.
|
||||
|
||||
## Authentication and authorization
|
||||
|
||||
- OAuth routes live in `api/src/routes/auth.routes.ts`.
|
||||
- Sessions are stored in signed `aurora_session` cookies.
|
||||
- Session TTL is 7 days.
|
||||
- Login succeeds only for users already present in the `users` table.
|
||||
- Role is `admin` if the Discord ID is in `ADMIN_USER_IDS`, otherwise `player`.
|
||||
- Redirects after login are intentionally restricted to localhost or relative paths.
|
||||
|
||||
Current access rules from `api/src/routes/index.ts`:
|
||||
|
||||
- public: `/auth/*`, `/api/health`
|
||||
- player allow-list: `/api/stats`, `/api/health`, `/api/me`
|
||||
- everything else under `/api/*`: admin-only
|
||||
|
||||
`/api/me/inventory` is handled by `users.routes.ts` and still depends on a valid session.
|
||||
|
||||
## Response conventions
|
||||
|
||||
- `jsonResponse()` serializes `bigint` values as strings.
|
||||
- `errorResponse()` returns `{ error, details? }`.
|
||||
- `parseBody()` and `parseQuery()` validate with Zod and return a `Response` on failure.
|
||||
- The API does not use a framework-level middleware stack; each route handles its own parsing and branching.
|
||||
|
||||
## WebSocket
|
||||
|
||||
- Endpoint: `/ws`
|
||||
- Requires an authenticated session
|
||||
- Dashboard channel: `dashboard`
|
||||
- Lobby channel: `lobby`
|
||||
- Room-specific messaging is handled inside `GameServer`
|
||||
- Dashboard broadcasts `STATS_UPDATE` every 5 seconds while clients are connected
|
||||
- `NEW_EVENT` broadcasts are wired from `shared/lib/events`
|
||||
|
||||
Hard limits:
|
||||
|
||||
- max connections: 200
|
||||
- max payload: 16 KB
|
||||
- idle timeout: 60 seconds
|
||||
|
||||
## Static files
|
||||
|
||||
- Built panel assets are served from `panel/dist`
|
||||
- `/assets/*` serves files from `bot/assets/graphics`
|
||||
- `/api/*`, `/auth/*`, `/ws`, and `/assets/*` bypass the SPA fallback
|
||||
|
||||
## Route notes
|
||||
|
||||
- `items.routes.ts` supports both JSON and multipart form data for item creation.
|
||||
- `settings.routes.ts` writes DB-backed game settings and emits the reload-commands event.
|
||||
- `guild-settings.routes.ts` invalidates the guild config cache after writes.
|
||||
- `lootdrops.routes.ts` delegates spawning/deletion to bot-side handlers because Discord message creation happens there.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Some runtime caches are in-memory only and are lost on restart.
|
||||
- The server registers game plugins at startup; duplicate registration throws.
|
||||
- BigInt-safe JSON matters for nearly every domain route.
|
||||
- The panel's auth flow depends on `PANEL_BASE_URL` matching the OAuth callback origin.
|
||||
540
api/src/games/GameServer.ts
Normal file
540
api/src/games/GameServer.ts
Normal file
@@ -0,0 +1,540 @@
|
||||
import { RoomManager } from "./RoomManager";
|
||||
import { GameWsClientSchema } from "./types";
|
||||
import type { PlayerInfo } from "./types";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { TransactionType } from "@shared/lib/constants";
|
||||
import { gameRegistry } from "@shared/games/registry";
|
||||
import type { Server, ServerWebSocket } from "bun";
|
||||
|
||||
export interface WsConnectionData {
|
||||
session: { discordId: string; username: string; role: string };
|
||||
rooms: Set<string>;
|
||||
}
|
||||
|
||||
export class GameServer {
|
||||
readonly roomManager = new RoomManager();
|
||||
private connections = new Map<string, ServerWebSocket<WsConnectionData>>();
|
||||
private replacedConnections = new Map<string, ServerWebSocket<WsConnectionData>>();
|
||||
private bunServer: Server<WsConnectionData> | null = null;
|
||||
|
||||
constructor() {
|
||||
// Subscribe to room events and route them to the right clients
|
||||
|
||||
this.roomManager.emitter.on("room:created", ({ roomId, gameSlug }) => {
|
||||
// The creating connection will subscribe itself; just broadcast room list
|
||||
this.publishRoomListUpdate();
|
||||
});
|
||||
|
||||
this.roomManager.emitter.on("game:started", ({ roomId, spectatorView, playerViews }) => {
|
||||
// Send personalised state to each player
|
||||
for (const [playerId, view] of playerViews) {
|
||||
this.sendToPlayer(playerId, {
|
||||
type: "GAME_STATE",
|
||||
roomId,
|
||||
state: view,
|
||||
});
|
||||
}
|
||||
// Broadcast started event with spectator view to the room channel
|
||||
this.publish(`room:${roomId}`, {
|
||||
type: "GAME_STARTED",
|
||||
roomId,
|
||||
state: spectatorView,
|
||||
});
|
||||
});
|
||||
|
||||
this.roomManager.emitter.on("game:updated", ({ roomId, spectatorView, playerViews }) => {
|
||||
// Each player gets their personalised view directly
|
||||
for (const [playerId, view] of playerViews) {
|
||||
this.sendToPlayer(playerId, {
|
||||
type: "GAME_STATE",
|
||||
roomId,
|
||||
state: view,
|
||||
});
|
||||
}
|
||||
// Spectators/others get the spectator view via pub/sub
|
||||
this.publish(`room:${roomId}`, {
|
||||
type: "GAME_UPDATE",
|
||||
roomId,
|
||||
state: spectatorView,
|
||||
});
|
||||
});
|
||||
|
||||
this.roomManager.emitter.on("game:ended", ({ roomId, winner, reason, payouts }) => {
|
||||
const room = this.roomManager.getRoom(roomId);
|
||||
const betAmount = room?.betAmount ?? 0;
|
||||
|
||||
// Handle bet payouts asynchronously — broadcast happens after settlement
|
||||
if (betAmount > 0) {
|
||||
this.settleBets(roomId, winner, betAmount, payouts).then((payout) => {
|
||||
this.publish(`room:${roomId}`, {
|
||||
type: "GAME_ENDED",
|
||||
roomId,
|
||||
winner,
|
||||
reason,
|
||||
payout,
|
||||
});
|
||||
this.publishRoomListUpdate();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.publish(`room:${roomId}`, {
|
||||
type: "GAME_ENDED",
|
||||
roomId,
|
||||
winner,
|
||||
reason,
|
||||
});
|
||||
this.publishRoomListUpdate();
|
||||
});
|
||||
|
||||
this.roomManager.emitter.on("round:settled", async ({ roomId, roundSettlements }) => {
|
||||
const room = this.roomManager.getRoom(roomId);
|
||||
if (!room || room.betAmount <= 0) return;
|
||||
const gameName = gameRegistry.get(room.gameSlug)?.name ?? "Game";
|
||||
const settlementDetails: typeof roundSettlements = {};
|
||||
|
||||
for (const [playerId, settlement] of Object.entries(roundSettlements)) {
|
||||
try {
|
||||
if (settlement.payout > 0) {
|
||||
await economyService.modifyUserBalance(
|
||||
playerId,
|
||||
BigInt(settlement.payout),
|
||||
TransactionType.GAME_WIN,
|
||||
`${gameName} round payout (room ${roomId.slice(0, 8)})`,
|
||||
);
|
||||
}
|
||||
settlementDetails[playerId] = settlement;
|
||||
} catch (err) {
|
||||
logger.error("web", `Round payout failed for ${playerId} in room ${roomId}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(settlementDetails).length > 0) {
|
||||
this.publish(`room:${roomId}`, { type: "ROUND_SETTLED", roomId, settlements: settlementDetails });
|
||||
}
|
||||
});
|
||||
|
||||
this.roomManager.emitter.on("player:left", ({ roomId, playerId }) => {
|
||||
this.publish(`room:${roomId}`, {
|
||||
type: "PLAYER_LEFT",
|
||||
roomId,
|
||||
playerId,
|
||||
});
|
||||
});
|
||||
|
||||
this.roomManager.emitter.on("room:deleted", ({ roomId }) => {
|
||||
const channel = `room:${roomId}`;
|
||||
for (const [, ws] of this.connections) {
|
||||
if (ws.data.rooms.has(roomId)) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: "Room not found" }));
|
||||
ws.unsubscribe(channel);
|
||||
ws.data.rooms.delete(roomId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.roomManager.emitter.on("room:list:changed", () => {
|
||||
this.publishRoomListUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
setServer(server: Server<WsConnectionData>): void {
|
||||
this.bunServer = server;
|
||||
}
|
||||
|
||||
handleOpen(ws: ServerWebSocket<WsConnectionData>): void {
|
||||
const discordId = ws.data.session.discordId;
|
||||
const existing = this.connections.get(discordId);
|
||||
if (existing && existing !== ws) {
|
||||
this.replacedConnections.set(discordId, existing);
|
||||
}
|
||||
this.connections.set(discordId, ws);
|
||||
ws.send(JSON.stringify({ type: "ROOM_LIST_UPDATE", rooms: this.roomManager.listRooms() }));
|
||||
}
|
||||
|
||||
async handleMessage(ws: ServerWebSocket<WsConnectionData>, raw: unknown): Promise<void> {
|
||||
const parsed = GameWsClientSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: "Invalid message format" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = parsed.data;
|
||||
const { discordId, username, role } = ws.data.session;
|
||||
|
||||
switch (msg.type) {
|
||||
case "CREATE_ROOM": {
|
||||
// Solo mode forces betAmount to 0
|
||||
const options = msg.options ? { ...msg.options } : {};
|
||||
if (options.soloMode) options.betAmount = 0;
|
||||
|
||||
const result = this.roomManager.createRoom(msg.gameType, discordId, options);
|
||||
if (!result.ok) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: result.error }));
|
||||
return;
|
||||
}
|
||||
ws.subscribe(`room:${result.roomId}`);
|
||||
ws.data.rooms.add(result.roomId);
|
||||
ws.send(JSON.stringify({ type: "ROOM_CREATED", roomId: result.roomId, gameSlug: msg.gameType }));
|
||||
logger.info("web", `Room created: ${result.roomId} (${msg.gameType}) by ${discordId}`);
|
||||
|
||||
// Solo mode: auto-fill and start immediately
|
||||
if (options.soloMode) {
|
||||
const fillResult = this.roomManager.fillRoom(result.roomId, discordId);
|
||||
if (!fillResult.ok) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: fillResult.error }));
|
||||
}
|
||||
// fillRoom with betAmount=0 calls startGame internally
|
||||
}
|
||||
|
||||
// Auto-start if room is immediately full (e.g. maxPlayers: 1) — skip for manualStart games
|
||||
const plugin = gameRegistry.get(msg.gameType);
|
||||
const createdRoom = this.roomManager.getRoom(result.roomId);
|
||||
if (!options.soloMode && plugin && !plugin.manualStart && createdRoom && createdRoom.players.length >= plugin.maxPlayers && createdRoom.status === "waiting") {
|
||||
if (createdRoom.betAmount > 0) {
|
||||
this.deductBetsAndStart(result.roomId, createdRoom.betAmount, createdRoom.players, ws);
|
||||
} else {
|
||||
this.roomManager.startGame(result.roomId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "JOIN_ROOM": {
|
||||
const result = this.roomManager.joinRoom(msg.roomId, discordId, msg.preferAs, msg.role ?? role);
|
||||
if (!result.ok) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: result.error }));
|
||||
return;
|
||||
}
|
||||
|
||||
ws.subscribe(`room:${msg.roomId}`);
|
||||
ws.data.rooms.add(msg.roomId);
|
||||
|
||||
const room = this.roomManager.getRoom(msg.roomId);
|
||||
const roomStatus = room?.status ?? "waiting";
|
||||
|
||||
// Determine the current state to send to this client
|
||||
let state: unknown = undefined;
|
||||
if (room && room.status === "playing") {
|
||||
state = result.joinedAs === "spectator"
|
||||
? this.roomManager.getSpectatorView(msg.roomId)
|
||||
: this.roomManager.getPlayerView(msg.roomId, discordId);
|
||||
}
|
||||
|
||||
// Build player/spectator lists for JOIN_RESULT
|
||||
const resolveInfo = (ids: string[]): PlayerInfo[] =>
|
||||
ids.map(id => ({
|
||||
discordId: id,
|
||||
username: this.connections.get(id)?.data.session.username ?? id,
|
||||
}));
|
||||
|
||||
const players = resolveInfo(room?.players ?? []);
|
||||
const spectators = resolveInfo(Array.from(room?.spectators ?? new Set()));
|
||||
|
||||
// Notify replaced connection in the same room (multi-tab detection)
|
||||
const replacedWs = this.replacedConnections.get(discordId);
|
||||
if (replacedWs && replacedWs.data.rooms.has(msg.roomId)) {
|
||||
replacedWs.send(JSON.stringify({ type: "SESSION_REPLACED", roomId: msg.roomId }));
|
||||
replacedWs.data.rooms.delete(msg.roomId);
|
||||
replacedWs.unsubscribe(`room:${msg.roomId}`);
|
||||
}
|
||||
this.replacedConnections.delete(discordId);
|
||||
|
||||
// Build room options for the client
|
||||
const roomOptions = room
|
||||
? {
|
||||
...(room.betAmount > 0 ? { betAmount: room.betAmount } : {}),
|
||||
...(typeof room.options?.timeControl === "string" ? { timeControl: room.options.timeControl } : {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Respond with JOIN_RESULT
|
||||
ws.send(JSON.stringify({
|
||||
type: "JOIN_RESULT",
|
||||
roomId: msg.roomId,
|
||||
joinedAs: result.joinedAs,
|
||||
roomStatus,
|
||||
players,
|
||||
spectators,
|
||||
state,
|
||||
roomOptions,
|
||||
}));
|
||||
|
||||
// Notify other room members
|
||||
const playerInfo: PlayerInfo = { discordId, username };
|
||||
this.publish(`room:${msg.roomId}`, {
|
||||
type: "PLAYER_JOINED",
|
||||
roomId: msg.roomId,
|
||||
player: playerInfo,
|
||||
joinedAs: result.joinedAs,
|
||||
});
|
||||
|
||||
logger.info("web", `${discordId} joined room ${msg.roomId} as ${result.joinedAs}`);
|
||||
|
||||
// Handle async bet deduction when room is ready to start
|
||||
if (result.readyToStart && room) {
|
||||
this.deductBetsAndStart(msg.roomId, room.betAmount, room.players, ws);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "LEAVE_ROOM": {
|
||||
this.roomManager.leaveRoom(msg.roomId, discordId);
|
||||
ws.unsubscribe(`room:${msg.roomId}`);
|
||||
ws.data.rooms.delete(msg.roomId);
|
||||
break;
|
||||
}
|
||||
|
||||
case "GAME_ACTION": {
|
||||
// Action cost pre-check: deduct bet before processing split/double/place_bet
|
||||
const actionRoom = this.roomManager.getRoom(msg.roomId);
|
||||
if (actionRoom && actionRoom.betAmount > 0 && actionRoom.state) {
|
||||
const actionPlugin = gameRegistry.get(actionRoom.gameSlug);
|
||||
if (actionPlugin?.getActionCost) {
|
||||
const cost = actionPlugin.getActionCost(actionRoom.state, msg.action, discordId);
|
||||
if (cost > 0) {
|
||||
const amount = actionRoom.betAmount * cost;
|
||||
const gameName = actionPlugin.name ?? actionRoom.gameSlug;
|
||||
try {
|
||||
await economyService.modifyUserBalance(
|
||||
discordId,
|
||||
-BigInt(amount),
|
||||
TransactionType.GAME_BET,
|
||||
`${gameName} action bet (room ${msg.roomId.slice(0, 8)})`,
|
||||
);
|
||||
} catch {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: "Insufficient funds for this action" }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = this.roomManager.handleAction(msg.roomId, discordId, msg.action);
|
||||
if (!result.ok) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: result.error }));
|
||||
return;
|
||||
}
|
||||
// game:updated event handler will dispatch views to players and spectators
|
||||
break;
|
||||
}
|
||||
|
||||
case "START_GAME": {
|
||||
const room = this.roomManager.getRoom(msg.roomId);
|
||||
if (!room) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: "Room not found" }));
|
||||
return;
|
||||
}
|
||||
if (room.host !== discordId) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: "Only the host can start the game" }));
|
||||
return;
|
||||
}
|
||||
if (room.status !== "waiting") {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: "Game is not in waiting state" }));
|
||||
return;
|
||||
}
|
||||
const startPlugin = gameRegistry.get(room.gameSlug);
|
||||
if (startPlugin && room.players.length < startPlugin.minPlayers) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: `Need at least ${startPlugin.minPlayers} player(s) to start` }));
|
||||
return;
|
||||
}
|
||||
if (room.betAmount > 0) {
|
||||
this.deductBetsAndStart(msg.roomId, room.betAmount, room.players, ws);
|
||||
} else {
|
||||
const startResult = this.roomManager.startGame(msg.roomId);
|
||||
if (!startResult.ok) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: startResult.error }));
|
||||
}
|
||||
}
|
||||
logger.info("web", `Host ${discordId} started game in room ${msg.roomId}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "FILL_ROOM": {
|
||||
if (role !== "admin") {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: "Only admins can fill a room for solo testing" }));
|
||||
return;
|
||||
}
|
||||
const fillResult = this.roomManager.fillRoom(msg.roomId, discordId);
|
||||
if (!fillResult.ok) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: fillResult.error }));
|
||||
return;
|
||||
}
|
||||
if (fillResult.readyToStart) {
|
||||
const room = this.roomManager.getRoom(msg.roomId);
|
||||
if (room) this.deductBetsAndStart(msg.roomId, room.betAmount, room.players, ws);
|
||||
}
|
||||
logger.info("web", `Admin ${discordId} filled room ${msg.roomId} for solo testing`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleClose(ws: ServerWebSocket<WsConnectionData>): void {
|
||||
// If this is a replaced (displaced) connection, just clean up the registry and stop
|
||||
for (const [id, prevWs] of this.replacedConnections) {
|
||||
if (prevWs === ws) {
|
||||
this.replacedConnections.delete(id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (const roomId of ws.data.rooms) {
|
||||
this.roomManager.leaveRoom(roomId, ws.data.session.discordId);
|
||||
}
|
||||
this.connections.delete(ws.data.session.discordId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduct bet amounts from all players, then start the game.
|
||||
* If any player can't afford the bet, refund already-deducted players
|
||||
* and remove the failing player from the room.
|
||||
*/
|
||||
private async deductBetsAndStart(
|
||||
roomId: string,
|
||||
betAmount: number,
|
||||
playerIds: string[],
|
||||
triggeringWs: ServerWebSocket<WsConnectionData>,
|
||||
): Promise<void> {
|
||||
const room = this.roomManager.getRoom(roomId);
|
||||
if (!room || room.betsPending) return;
|
||||
|
||||
// Games with getActionCost handle per-round betting themselves (e.g., blackjack).
|
||||
// Skip the upfront deduction — just start the game.
|
||||
const plugin = gameRegistry.get(room.gameSlug);
|
||||
if (plugin?.getActionCost) {
|
||||
const startResult = this.roomManager.startGame(roomId);
|
||||
if (!startResult.ok) {
|
||||
triggeringWs.send(JSON.stringify({ type: "ERROR", message: startResult.error }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
room.betsPending = true;
|
||||
|
||||
const uniquePlayers = [...new Set(playerIds)];
|
||||
const deducted: string[] = [];
|
||||
|
||||
try {
|
||||
const gameName = gameRegistry.get(room.gameSlug)?.name ?? room.gameSlug;
|
||||
for (const pid of uniquePlayers) {
|
||||
await economyService.modifyUserBalance(
|
||||
pid,
|
||||
-BigInt(betAmount),
|
||||
TransactionType.GAME_BET,
|
||||
`${gameName} wager (room ${roomId.slice(0, 8)})`,
|
||||
);
|
||||
deducted.push(pid);
|
||||
}
|
||||
|
||||
// All deductions succeeded — start the game
|
||||
const startResult = this.roomManager.startGame(roomId);
|
||||
if (!startResult.ok) {
|
||||
// Shouldn't happen, but refund if it does
|
||||
await this.refundPlayers(deducted, betAmount, roomId);
|
||||
}
|
||||
} catch (err) {
|
||||
// Refund anyone already deducted
|
||||
await this.refundPlayers(deducted, betAmount, roomId);
|
||||
|
||||
// Find the player who couldn't afford the bet
|
||||
const failedPlayer = uniquePlayers.find(p => !deducted.includes(p));
|
||||
if (failedPlayer) {
|
||||
this.roomManager.removePlayer(roomId, failedPlayer);
|
||||
this.sendToPlayer(failedPlayer, {
|
||||
type: "ERROR",
|
||||
message: "Insufficient funds for the bet. You have been removed from the room.",
|
||||
});
|
||||
this.publish(`room:${roomId}`, {
|
||||
type: "PLAYER_LEFT",
|
||||
roomId,
|
||||
playerId: failedPlayer,
|
||||
});
|
||||
}
|
||||
|
||||
logger.warn("web", `Bet deduction failed for room ${roomId}: ${err}`);
|
||||
} finally {
|
||||
if (room) room.betsPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Pay out winnings or refund bets on game end. */
|
||||
private async settleBets(
|
||||
roomId: string,
|
||||
winner: string | null,
|
||||
betAmount: number,
|
||||
payouts?: Record<string, number>,
|
||||
): Promise<{ amount: number; refunded?: boolean }> {
|
||||
const room = this.roomManager.getRoom(roomId);
|
||||
const uniquePlayers = [...new Set(room?.players ?? [])];
|
||||
const gameName = gameRegistry.get(room?.gameSlug ?? "")?.name ?? "Game";
|
||||
|
||||
try {
|
||||
// Custom payouts override default pot logic (used by house-edge games like blackjack)
|
||||
if (payouts) {
|
||||
let totalPaid = 0;
|
||||
for (const [playerId, multiplier] of Object.entries(payouts)) {
|
||||
if (multiplier <= 0) continue;
|
||||
const amount = Math.floor(betAmount * multiplier);
|
||||
await economyService.modifyUserBalance(
|
||||
playerId,
|
||||
BigInt(amount),
|
||||
TransactionType.GAME_WIN,
|
||||
`${gameName} payout (room ${roomId.slice(0, 8)})`,
|
||||
);
|
||||
totalPaid = Math.max(totalPaid, amount);
|
||||
}
|
||||
const isRefund = !winner && totalPaid === betAmount;
|
||||
return { amount: totalPaid, refunded: isRefund };
|
||||
}
|
||||
|
||||
// Default pot logic: winner takes all, draw refunds everyone
|
||||
const pot = betAmount * uniquePlayers.length;
|
||||
if (winner) {
|
||||
await economyService.modifyUserBalance(
|
||||
winner,
|
||||
BigInt(pot),
|
||||
TransactionType.GAME_WIN,
|
||||
`${gameName} wager won (room ${roomId.slice(0, 8)})`,
|
||||
);
|
||||
return { amount: pot };
|
||||
} else {
|
||||
await this.refundPlayers(uniquePlayers, betAmount, roomId, gameName);
|
||||
return { amount: betAmount, refunded: true };
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("web", `Bet settlement failed for room ${roomId}: ${err}`);
|
||||
return { amount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
private async refundPlayers(playerIds: string[], betAmount: number, roomId: string, gameName = "Game"): Promise<void> {
|
||||
for (const pid of playerIds) {
|
||||
try {
|
||||
await economyService.modifyUserBalance(
|
||||
pid,
|
||||
BigInt(betAmount),
|
||||
TransactionType.GAME_WIN,
|
||||
`${gameName} wager refund (room ${roomId.slice(0, 8)})`,
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error("web", `Failed to refund ${pid} for room ${roomId}: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private publish(channel: string, message: unknown): void {
|
||||
this.bunServer?.publish(channel, JSON.stringify(message));
|
||||
}
|
||||
|
||||
private sendToPlayer(discordId: string, message: unknown): void {
|
||||
this.connections.get(discordId)?.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
private publishRoomListUpdate(): void {
|
||||
this.publish("lobby", { type: "ROOM_LIST_UPDATE", rooms: this.roomManager.listRooms() });
|
||||
}
|
||||
}
|
||||
|
||||
export const gameServer = new GameServer();
|
||||
187
api/src/games/RoomManager.test.ts
Normal file
187
api/src/games/RoomManager.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect, beforeEach } from "bun:test";
|
||||
import { RoomManager } from "./RoomManager";
|
||||
import { gameRegistry } from "@shared/games/registry";
|
||||
import type { GamePlugin } from "@shared/games/types";
|
||||
|
||||
// Minimal stub plugin for testing the room system
|
||||
const stubPlugin: GamePlugin<{ turn: number }, { type: string }> = {
|
||||
slug: "stub",
|
||||
name: "Stub Game",
|
||||
minPlayers: 2,
|
||||
maxPlayers: 2,
|
||||
createInitialState: (players) => ({ turn: 0 }),
|
||||
handleAction: (state, action, playerId) => ({ ok: true, state: { ...state, turn: state.turn + 1 } }),
|
||||
getPlayerView: (state) => state,
|
||||
getSpectatorView: (state) => state,
|
||||
};
|
||||
|
||||
if (!gameRegistry.get("stub")) {
|
||||
gameRegistry.register(stubPlugin);
|
||||
}
|
||||
|
||||
describe("RoomManager", () => {
|
||||
let manager: RoomManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new RoomManager();
|
||||
});
|
||||
|
||||
describe("createRoom", () => {
|
||||
it("should create a room and return its id", () => {
|
||||
const result = manager.createRoom("stub", "player1");
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.roomId).toBeDefined();
|
||||
expect(typeof result.roomId).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject unknown game type", () => {
|
||||
const result = manager.createRoom("unknown-game", "player1");
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("should add creator as first player", () => {
|
||||
const result = manager.createRoom("stub", "player1");
|
||||
if (result.ok) {
|
||||
const room = manager.getRoom(result.roomId);
|
||||
expect(room?.players).toContain("player1");
|
||||
expect(room?.host).toBe("player1");
|
||||
expect(room?.status).toBe("waiting");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("joinRoom", () => {
|
||||
it("should add a player to a waiting room", () => {
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
const join = manager.joinRoom(create.roomId, "player2", "player");
|
||||
expect(join.ok).toBe(true);
|
||||
if (join.ok) {
|
||||
expect(join.joinedAs).toBe("player");
|
||||
}
|
||||
});
|
||||
|
||||
it("should auto-start when room reaches maxPlayers", () => {
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
manager.joinRoom(create.roomId, "player2", "player");
|
||||
const room = manager.getRoom(create.roomId);
|
||||
expect(room?.status).toBe("playing");
|
||||
expect(room?.state).toBeDefined();
|
||||
});
|
||||
|
||||
it("should allow joining as spectator when game is playing", () => {
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
manager.joinRoom(create.roomId, "player2", "player");
|
||||
const spec = manager.joinRoom(create.roomId, "spectator1", "spectator");
|
||||
expect(spec.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("should downgrade to spectator when joining full room as player", () => {
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
manager.joinRoom(create.roomId, "player2", "player");
|
||||
const result = manager.joinRoom(create.roomId, "player3", "player");
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.joinedAs).toBe("spectator");
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject joining nonexistent room", () => {
|
||||
const result = manager.joinRoom("fake-id", "player1", "player");
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAction", () => {
|
||||
it("should apply a valid game action", () => {
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
manager.joinRoom(create.roomId, "player2", "player");
|
||||
const result = manager.handleAction(create.roomId, "player1", { type: "action" });
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject action from spectator", () => {
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
manager.joinRoom(create.roomId, "player2", "player");
|
||||
manager.joinRoom(create.roomId, "spectator1", "spectator");
|
||||
const result = manager.handleAction(create.roomId, "spectator1", { type: "action" });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("leaveRoom", () => {
|
||||
it("should remove a player from the room", () => {
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
manager.leaveRoom(create.roomId, "player1");
|
||||
const room = manager.getRoom(create.roomId);
|
||||
expect(room).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should remove a spectator from the room", () => {
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
manager.joinRoom(create.roomId, "player2", "player");
|
||||
manager.joinRoom(create.roomId, "spec1", "spectator");
|
||||
manager.leaveRoom(create.roomId, "spec1");
|
||||
const room = manager.getRoom(create.roomId);
|
||||
expect(room?.spectators.has("spec1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listRooms", () => {
|
||||
it("should return summaries of all rooms", () => {
|
||||
manager.createRoom("stub", "player1");
|
||||
manager.createRoom("stub", "player2");
|
||||
const rooms = manager.listRooms();
|
||||
expect(rooms.length).toBe(2);
|
||||
expect(rooms[0].gameSlug).toBe("stub");
|
||||
expect(rooms[0].status).toBe("waiting");
|
||||
});
|
||||
|
||||
it("should filter by game type", () => {
|
||||
manager.createRoom("stub", "player1");
|
||||
const rooms = manager.listRooms("stub");
|
||||
expect(rooms.length).toBe(1);
|
||||
const empty = manager.listRooms("blackjack");
|
||||
expect(empty.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("waiting room cleanup", () => {
|
||||
it("should remove waiting rooms after the configured timeout", async () => {
|
||||
const shortLivedManager = new RoomManager({ WAITING_CLEANUP_MS: 20 });
|
||||
const create = shortLivedManager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 35));
|
||||
|
||||
expect(shortLivedManager.getRoom(create.roomId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should refresh the waiting room timeout when the room is active", async () => {
|
||||
const shortLivedManager = new RoomManager({ WAITING_CLEANUP_MS: 25 });
|
||||
const create = shortLivedManager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 15));
|
||||
|
||||
const spectatorJoin = shortLivedManager.joinRoom(create.roomId, "spectator1", "spectator");
|
||||
expect(spectatorJoin.ok).toBe(true);
|
||||
expect(shortLivedManager.getRoom(create.roomId)).toBeDefined();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 15));
|
||||
expect(shortLivedManager.getRoom(create.roomId)).toBeDefined();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
expect(shortLivedManager.getRoom(create.roomId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
327
api/src/games/RoomManager.ts
Normal file
327
api/src/games/RoomManager.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import mitt from "mitt";
|
||||
import { gameRegistry } from "@shared/games/registry";
|
||||
import type { Room, RoomSummary } from "./types";
|
||||
import type { RoundSettlement } from "@shared/games/types";
|
||||
|
||||
const DEFAULT_ROOM_CONFIG = {
|
||||
WAITING_CLEANUP_MS: 15 * 60_000,
|
||||
FINISHED_CLEANUP_MS: 60_000,
|
||||
PLAYING_MAX_MS: 30 * 60_000, // 30 minutes — safety net for stuck games
|
||||
} as const;
|
||||
|
||||
type RoomManagerConfig = typeof DEFAULT_ROOM_CONFIG;
|
||||
|
||||
type ActionResult =
|
||||
| { ok: true; state: unknown; gameOver: { winner: string | null; reason: string } | null; roundSettlements?: Record<string, RoundSettlement> }
|
||||
| { ok: false; error: string };
|
||||
|
||||
type CreateResult = { ok: true; roomId: string } | { ok: false; error: string };
|
||||
type JoinResult =
|
||||
| { ok: true; joinedAs: "player" | "spectator"; started: boolean; readyToStart?: boolean }
|
||||
| { ok: false; error: string };
|
||||
type FillResult = { ok: true; readyToStart?: boolean } | { ok: false; error: string };
|
||||
|
||||
type RoomEvents = {
|
||||
"room:created": { roomId: string; gameSlug: string; hostId: string };
|
||||
"player:joined": { roomId: string; playerId: string; username: string; joinedAs: "player" | "spectator" };
|
||||
"game:started": { roomId: string; spectatorView: unknown; playerViews: Map<string, unknown> };
|
||||
"game:updated": { roomId: string; spectatorView: unknown; playerViews: Map<string, unknown> };
|
||||
"game:ended": { roomId: string; winner: string | null; reason: string; payouts?: Record<string, number> };
|
||||
"round:settled": { roomId: string; roundSettlements: Record<string, RoundSettlement> };
|
||||
"player:left": { roomId: string; playerId: string };
|
||||
"room:deleted": { roomId: string };
|
||||
"room:list:changed": void;
|
||||
};
|
||||
|
||||
export class RoomManager {
|
||||
private rooms = new Map<string, Room>();
|
||||
private cleanupTimers = new Map<string, Timer>();
|
||||
private readonly config: RoomManagerConfig;
|
||||
readonly emitter = mitt<RoomEvents>();
|
||||
|
||||
constructor(config: Partial<RoomManagerConfig> = {}) {
|
||||
this.config = { ...DEFAULT_ROOM_CONFIG, ...config };
|
||||
}
|
||||
|
||||
createRoom(gameSlug: string, hostId: string, options?: Record<string, unknown>): CreateResult {
|
||||
const plugin = gameRegistry.get(gameSlug);
|
||||
if (!plugin) return { ok: false, error: `Unknown game type: ${gameSlug}` };
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const betAmount = typeof options?.betAmount === "number" && options.betAmount > 0 ? options.betAmount : 0;
|
||||
const room: Room = {
|
||||
id,
|
||||
gameSlug,
|
||||
host: hostId,
|
||||
players: [hostId],
|
||||
spectators: new Set(),
|
||||
state: null,
|
||||
status: "waiting",
|
||||
createdAt: Date.now(),
|
||||
options,
|
||||
betAmount,
|
||||
};
|
||||
|
||||
this.rooms.set(id, room);
|
||||
this.refreshWaitingCleanup(id, room);
|
||||
|
||||
this.emitter.emit("room:created", { roomId: id, gameSlug, hostId });
|
||||
this.emitter.emit("room:list:changed");
|
||||
|
||||
return { ok: true, roomId: id };
|
||||
}
|
||||
|
||||
joinRoom(roomId: string, playerId: string, preferAs: "player" | "spectator", role?: string): JoinResult {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return { ok: false, error: "Room not found" };
|
||||
|
||||
// Reconnecting player: must be checked before the in-progress spectator guard.
|
||||
if (preferAs !== "spectator" && room.players.includes(playerId)) {
|
||||
room.spectators.delete(playerId);
|
||||
this.refreshWaitingCleanup(roomId, room);
|
||||
return { ok: true, joinedAs: "player", started: room.status === "playing" };
|
||||
}
|
||||
|
||||
if (preferAs === "spectator" || room.status !== "waiting") {
|
||||
room.spectators.add(playerId);
|
||||
this.refreshWaitingCleanup(roomId, room);
|
||||
return { ok: true, joinedAs: "spectator", started: room.status === "playing" };
|
||||
}
|
||||
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
const isAdmin = role === "admin";
|
||||
|
||||
if (room.players.length >= plugin.maxPlayers && !isAdmin) {
|
||||
// Downgrade to spectator — room is full but still waiting, so game hasn't started
|
||||
room.spectators.add(playerId);
|
||||
return { ok: true, joinedAs: "spectator", started: false };
|
||||
}
|
||||
|
||||
room.players.push(playerId);
|
||||
|
||||
if (room.players.length >= plugin.maxPlayers && !plugin.manualStart) {
|
||||
// Defer start when bets are involved — GameServer handles async deduction first
|
||||
if (room.betAmount > 0) {
|
||||
this.refreshWaitingCleanup(roomId, room);
|
||||
this.emitter.emit("room:list:changed");
|
||||
return { ok: true, joinedAs: "player", started: false, readyToStart: true };
|
||||
}
|
||||
this.startGame(roomId);
|
||||
return { ok: true, joinedAs: "player", started: true };
|
||||
}
|
||||
|
||||
this.refreshWaitingCleanup(roomId, room);
|
||||
this.emitter.emit("room:list:changed");
|
||||
return { ok: true, joinedAs: "player", started: false };
|
||||
}
|
||||
|
||||
handleAction(roomId: string, playerId: string, action: unknown): ActionResult {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return { ok: false, error: "Room not found" };
|
||||
if (room.status !== "playing") return { ok: false, error: "Game is not in progress" };
|
||||
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
|
||||
// Spectator-to-player promotion for actions like "sit_down"
|
||||
if (!room.players.includes(playerId)) {
|
||||
if (room.spectators.has(playerId) && plugin.isSpectatorAction?.(action as any)) {
|
||||
room.spectators.delete(playerId);
|
||||
room.players.push(playerId);
|
||||
} else {
|
||||
return { ok: false, error: "You are not a player in this game" };
|
||||
}
|
||||
}
|
||||
|
||||
const result = plugin.handleAction(room.state, action, playerId);
|
||||
if (!result.ok) return result;
|
||||
|
||||
room.state = result.state;
|
||||
const gameOver = plugin.isGameOver?.(room.state) ?? null;
|
||||
if (gameOver) {
|
||||
room.status = "finished";
|
||||
this.scheduleCleanup(roomId, this.config.FINISHED_CLEANUP_MS);
|
||||
}
|
||||
|
||||
const spectatorView = plugin.getSpectatorView(room.state);
|
||||
const playerViews = new Map<string, unknown>();
|
||||
for (const pid of new Set(room.players)) {
|
||||
playerViews.set(pid, plugin.getPlayerView(room.state, pid));
|
||||
}
|
||||
this.emitter.emit("game:updated", { roomId, spectatorView, playerViews });
|
||||
|
||||
// Emit round payouts for mid-game settlement (continuous-play games)
|
||||
if (result.roundSettlements && !gameOver) {
|
||||
this.emitter.emit("round:settled", { roomId, roundSettlements: result.roundSettlements });
|
||||
}
|
||||
|
||||
if (gameOver) {
|
||||
this.emitter.emit("game:ended", { roomId, winner: gameOver.winner, reason: gameOver.reason, payouts: gameOver.payouts });
|
||||
this.emitter.emit("room:list:changed");
|
||||
}
|
||||
|
||||
return { ok: true, state: room.state, gameOver, roundSettlements: result.roundSettlements };
|
||||
}
|
||||
|
||||
leaveRoom(roomId: string, playerId: string): void {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return;
|
||||
|
||||
room.spectators.delete(playerId);
|
||||
|
||||
const playerIdx = room.players.indexOf(playerId);
|
||||
if (playerIdx !== -1) {
|
||||
room.players.splice(playerIdx, 1);
|
||||
|
||||
if (room.status === "playing") {
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
if (plugin.onPlayerDisconnect) {
|
||||
room.state = plugin.onPlayerDisconnect(room.state, playerId);
|
||||
const gameOver = plugin.isGameOver?.(room.state) ?? null;
|
||||
if (gameOver) {
|
||||
room.status = "finished";
|
||||
this.scheduleCleanup(roomId, this.config.FINISHED_CLEANUP_MS);
|
||||
this.emitter.emit("game:ended", { roomId, winner: gameOver.winner, reason: gameOver.reason, payouts: gameOver.payouts });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (room.players.length === 0 && room.status === "waiting") {
|
||||
this.deleteRoom(roomId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.refreshWaitingCleanup(roomId, room);
|
||||
|
||||
this.emitter.emit("player:left", { roomId, playerId });
|
||||
this.emitter.emit("room:list:changed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills empty seats with the admin's own ID for solo testing.
|
||||
* This means `createInitialState` will receive duplicate player IDs
|
||||
* (e.g. ["alice", "alice"]). Plugin authors should be aware that
|
||||
* solo-test mode produces non-unique player arrays.
|
||||
*/
|
||||
fillRoom(roomId: string, adminId: string): FillResult {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return { ok: false, error: "Room not found" };
|
||||
if (room.status !== "waiting") return { ok: false, error: "Game is not in waiting state" };
|
||||
if (!room.players.includes(adminId)) return { ok: false, error: "You are not in this room" };
|
||||
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
while (room.players.length < plugin.maxPlayers) {
|
||||
room.players.push(adminId);
|
||||
}
|
||||
|
||||
// Defer start when bets are involved
|
||||
if (room.betAmount > 0) {
|
||||
this.refreshWaitingCleanup(roomId, room);
|
||||
this.emitter.emit("room:list:changed");
|
||||
return { ok: true, readyToStart: true };
|
||||
}
|
||||
|
||||
this.startGame(roomId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Initialize game state and transition room to playing. */
|
||||
startGame(roomId: string): { ok: true } | { ok: false; error: string } {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return { ok: false, error: "Room not found" };
|
||||
if (room.status !== "waiting") return { ok: false, error: "Game is not in waiting state" };
|
||||
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
room.state = plugin.createInitialState(room.players, room.options);
|
||||
room.status = "playing";
|
||||
this.scheduleCleanup(roomId, this.config.PLAYING_MAX_MS);
|
||||
|
||||
const spectatorView = plugin.getSpectatorView(room.state);
|
||||
const playerViews = new Map<string, unknown>();
|
||||
for (const pid of new Set(room.players)) {
|
||||
playerViews.set(pid, plugin.getPlayerView(room.state, pid));
|
||||
}
|
||||
this.emitter.emit("game:started", { roomId, spectatorView, playerViews });
|
||||
this.emitter.emit("room:list:changed");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Remove a player from a waiting room (used when bet deduction fails). */
|
||||
removePlayer(roomId: string, playerId: string): void {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room || room.status !== "waiting") return;
|
||||
const idx = room.players.indexOf(playerId);
|
||||
if (idx !== -1) room.players.splice(idx, 1);
|
||||
if (room.players.length === 0) {
|
||||
this.deleteRoom(roomId);
|
||||
return;
|
||||
}
|
||||
this.refreshWaitingCleanup(roomId, room);
|
||||
this.emitter.emit("room:list:changed");
|
||||
}
|
||||
|
||||
getRoom(roomId: string): Room | undefined {
|
||||
return this.rooms.get(roomId);
|
||||
}
|
||||
|
||||
listRooms(gameSlug?: string): RoomSummary[] {
|
||||
const summaries: RoomSummary[] = [];
|
||||
for (const room of this.rooms.values()) {
|
||||
if (gameSlug && room.gameSlug !== gameSlug) continue;
|
||||
const plugin = gameRegistry.get(room.gameSlug);
|
||||
summaries.push({
|
||||
id: room.id,
|
||||
gameSlug: room.gameSlug,
|
||||
gameName: plugin?.name ?? room.gameSlug,
|
||||
host: room.host,
|
||||
playerCount: room.players.length,
|
||||
maxPlayers: plugin?.maxPlayers ?? 0,
|
||||
spectatorCount: room.spectators.size,
|
||||
status: room.status,
|
||||
betAmount: room.betAmount,
|
||||
});
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
getPlayerView(roomId: string, playerId: string): unknown {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room || !room.state) return null;
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
return plugin.getPlayerView(room.state, playerId);
|
||||
}
|
||||
|
||||
getSpectatorView(roomId: string): unknown {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room || !room.state) return null;
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
return plugin.getSpectatorView(room.state);
|
||||
}
|
||||
|
||||
private scheduleCleanup(roomId: string, ms: number): void {
|
||||
this.clearCleanup(roomId);
|
||||
const timer = setTimeout(() => this.deleteRoom(roomId), ms);
|
||||
this.cleanupTimers.set(roomId, timer);
|
||||
}
|
||||
|
||||
private refreshWaitingCleanup(roomId: string, room: Room): void {
|
||||
if (room.status !== "waiting") return;
|
||||
this.scheduleCleanup(roomId, this.config.WAITING_CLEANUP_MS);
|
||||
}
|
||||
|
||||
private clearCleanup(roomId: string): void {
|
||||
const existing = this.cleanupTimers.get(roomId);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
this.cleanupTimers.delete(roomId);
|
||||
}
|
||||
}
|
||||
|
||||
private deleteRoom(roomId: string): void {
|
||||
this.clearCleanup(roomId);
|
||||
if (this.rooms.delete(roomId)) {
|
||||
this.emitter.emit("room:deleted", { roomId });
|
||||
this.emitter.emit("room:list:changed");
|
||||
}
|
||||
}
|
||||
}
|
||||
65
api/src/games/types.ts
Normal file
65
api/src/games/types.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { RoundSettlement } from "@shared/games/types";
|
||||
import { z } from "zod";
|
||||
|
||||
export interface Room {
|
||||
id: string;
|
||||
gameSlug: string;
|
||||
host: string;
|
||||
players: string[];
|
||||
spectators: Set<string>;
|
||||
state: unknown;
|
||||
status: "waiting" | "playing" | "finished";
|
||||
createdAt: number;
|
||||
options?: Record<string, unknown>;
|
||||
betAmount: number;
|
||||
/** Guard against double bet-deduction when two joins race */
|
||||
betsPending?: boolean;
|
||||
}
|
||||
|
||||
export interface RoomSummary {
|
||||
id: string;
|
||||
gameSlug: string;
|
||||
gameName: string;
|
||||
host: string;
|
||||
playerCount: number;
|
||||
maxPlayers: number;
|
||||
spectatorCount: number;
|
||||
status: "waiting" | "playing" | "finished";
|
||||
betAmount: number;
|
||||
}
|
||||
|
||||
export interface PlayerInfo {
|
||||
discordId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export const GameWsClientSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("CREATE_ROOM"), gameType: z.string(), options: z.looseObject({}).optional() }),
|
||||
z.object({
|
||||
type: z.literal("JOIN_ROOM"),
|
||||
roomId: z.string(),
|
||||
preferAs: z.enum(["player", "spectator"]),
|
||||
role: z.enum(["player", "admin"]).optional(),
|
||||
}),
|
||||
z.object({ type: z.literal("LEAVE_ROOM"), roomId: z.string() }),
|
||||
// Use looseObject for GAME_ACTION to avoid Zod bug with record()
|
||||
z.object({ type: z.literal("GAME_ACTION"), roomId: z.string(), action: z.looseObject({}, { message: "Invalid action" }) }),
|
||||
z.object({ type: z.literal("FILL_ROOM"), roomId: z.string() }),
|
||||
z.object({ type: z.literal("START_GAME"), roomId: z.string() }),
|
||||
]);
|
||||
|
||||
export type GameWsClientMessage = z.infer<typeof GameWsClientSchema>;
|
||||
|
||||
export type GameWsServerMessage =
|
||||
| { type: "ROOM_LIST_UPDATE"; rooms: RoomSummary[] }
|
||||
| { type: "GAME_STATE"; roomId: string; state: unknown }
|
||||
| { type: "GAME_UPDATE"; roomId: string; state: unknown }
|
||||
| { type: "PLAYER_JOINED"; roomId: string; player: PlayerInfo; joinedAs: "player" | "spectator" }
|
||||
| { type: "PLAYER_LEFT"; roomId: string; playerId: string }
|
||||
| { type: "GAME_STARTED"; roomId: string; state: unknown }
|
||||
| { type: "GAME_ENDED"; roomId: string; winner: string | null; reason: string; payout?: { amount: number; refunded?: boolean } }
|
||||
| { type: "ROOM_CREATED"; roomId: string; gameSlug: string }
|
||||
| { type: "JOIN_RESULT"; roomId: string; joinedAs: "player" | "spectator"; roomStatus: "waiting" | "playing" | "finished"; players: PlayerInfo[]; spectators: PlayerInfo[]; state?: unknown; roomOptions?: { betAmount?: number; timeControl?: string } }
|
||||
| { type: "ROUND_SETTLED"; roomId: string; settlements: Record<string, RoundSettlement> }
|
||||
| { type: "SESSION_REPLACED"; roomId: string }
|
||||
| { type: "ERROR"; message: string };
|
||||
@@ -66,7 +66,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
|
||||
"Cache-Control": "no-cache",
|
||||
}
|
||||
});
|
||||
}
|
||||
103
api/src/routes/auth.routes.test.ts
Normal file
103
api/src/routes/auth.routes.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
|
||||
|
||||
const findFirst = mock(async () => ({ id: 123n }));
|
||||
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: {
|
||||
query: {
|
||||
users: {
|
||||
findFirst,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("@shared/lib/logger", () => ({
|
||||
logger: {
|
||||
error: () => { },
|
||||
info: () => { },
|
||||
warn: () => { },
|
||||
debug: () => { },
|
||||
},
|
||||
}));
|
||||
|
||||
import { authRoutes, getSession } from "./auth.routes";
|
||||
|
||||
describe("Auth Routes", () => {
|
||||
let fetchSpy: ReturnType<typeof spyOn> | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.DISCORD_CLIENT_ID = "client-id";
|
||||
process.env.DISCORD_CLIENT_SECRET = "client-secret";
|
||||
process.env.SESSION_SECRET = "session-secret";
|
||||
process.env.PANEL_BASE_URL = "http://localhost:3000";
|
||||
process.env.ADMIN_USER_IDS = "123";
|
||||
findFirst.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy?.mockRestore();
|
||||
fetchSpy = null;
|
||||
});
|
||||
|
||||
it("creates a signed session cookie during OAuth callback", async () => {
|
||||
const loginUrl = new URL("http://localhost/auth/discord?return_to=http://localhost:5173/admin");
|
||||
const loginRes = await authRoutes.handler({
|
||||
req: new Request(loginUrl, { method: "GET" }),
|
||||
url: loginUrl,
|
||||
method: "GET",
|
||||
pathname: "/auth/discord",
|
||||
});
|
||||
|
||||
expect(loginRes?.status).toBe(302);
|
||||
|
||||
const redirectLocation = loginRes?.headers.get("Location");
|
||||
expect(redirectLocation).not.toBeNull();
|
||||
|
||||
const state = new URL(redirectLocation!).searchParams.get("state");
|
||||
expect(state).not.toBeNull();
|
||||
|
||||
fetchSpy = spyOn(globalThis, "fetch");
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ access_token: "discord-token" }), { status: 200 }));
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
id: "123",
|
||||
username: "aurora-admin",
|
||||
avatar: null,
|
||||
}), { status: 200 }));
|
||||
|
||||
const callbackUrl = new URL(`http://localhost/auth/callback?code=oauth-code&state=${encodeURIComponent(state!)}`);
|
||||
const callbackRes = await authRoutes.handler({
|
||||
req: new Request(callbackUrl, { method: "GET" }),
|
||||
url: callbackUrl,
|
||||
method: "GET",
|
||||
pathname: "/auth/callback",
|
||||
});
|
||||
|
||||
expect(callbackRes?.status).toBe(302);
|
||||
expect(callbackRes?.headers.get("Location")).toBe("/admin");
|
||||
|
||||
const setCookie = callbackRes?.headers.get("Set-Cookie");
|
||||
expect(setCookie).toContain("aurora_session=");
|
||||
|
||||
const sessionCookie = setCookie!.split(";")[0]!;
|
||||
const session = getSession(new Request("http://localhost/api/me", {
|
||||
headers: { cookie: sessionCookie },
|
||||
}));
|
||||
|
||||
expect(session).toEqual({
|
||||
discordId: "123",
|
||||
username: "aurora-admin",
|
||||
avatar: null,
|
||||
role: "admin",
|
||||
expiresAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects tampered session cookies", () => {
|
||||
const session = getSession(new Request("http://localhost/api/me", {
|
||||
headers: { cookie: "aurora_session=not-a-valid-token" },
|
||||
}));
|
||||
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
});
|
||||
349
api/src/routes/auth.routes.ts
Normal file
349
api/src/routes/auth.routes.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* @fileoverview Discord OAuth2 authentication routes for the admin panel.
|
||||
* Handles login flow, callback, logout, and session management.
|
||||
*/
|
||||
|
||||
import { Buffer } from "node:buffer";
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import { jsonResponse, errorResponse } from "./utils";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { users } from "@shared/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
// Signed session payload stored in the aurora_session cookie.
|
||||
export interface Session {
|
||||
discordId: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
role: "admin" | "player";
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface SessionTokenPayload extends Session {
|
||||
v: 1;
|
||||
}
|
||||
|
||||
interface OAuthStatePayload {
|
||||
exp: number;
|
||||
returnTo: string;
|
||||
v: 1;
|
||||
}
|
||||
|
||||
const COOKIE_NAME = "aurora_session";
|
||||
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
const OAUTH_STATE_MAX_AGE = 10 * 60 * 1000; // 10 minutes
|
||||
const TOKEN_NAMESPACE = "aurora.auth";
|
||||
const TOKEN_VERSION = "v1";
|
||||
|
||||
function getEnv(key: string): string {
|
||||
const val = process.env[key];
|
||||
if (!val) throw new Error(`Missing env: ${key}`);
|
||||
return val;
|
||||
}
|
||||
|
||||
function getSessionSecret(required: boolean = false): string | null {
|
||||
const secret = process.env.SESSION_SECRET ?? process.env.DISCORD_CLIENT_SECRET ?? null;
|
||||
if (!secret && required) {
|
||||
throw new Error("Missing env: SESSION_SECRET or DISCORD_CLIENT_SECRET");
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
function requireSessionSecret(): string {
|
||||
return getSessionSecret(true)!;
|
||||
}
|
||||
|
||||
function getAdminIds(): string[] {
|
||||
const raw = process.env.ADMIN_USER_IDS ?? "";
|
||||
return raw.split(",").map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function encodeBase64Url(value: string): string {
|
||||
return Buffer.from(value, "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
function decodeBase64Url(value: string): string {
|
||||
return Buffer.from(value, "base64url").toString("utf8");
|
||||
}
|
||||
|
||||
function signValue(kind: string, encodedPayload: string, secret: string): string {
|
||||
return createHmac("sha256", secret)
|
||||
.update(`${TOKEN_NAMESPACE}.${kind}.${encodedPayload}`)
|
||||
.digest("base64url");
|
||||
}
|
||||
|
||||
function serializeSignedToken(kind: string, payload: SessionTokenPayload | OAuthStatePayload, secret: string): string {
|
||||
const encodedPayload = encodeBase64Url(JSON.stringify(payload));
|
||||
const signature = signValue(kind, encodedPayload, secret);
|
||||
return `${TOKEN_VERSION}.${encodedPayload}.${signature}`;
|
||||
}
|
||||
|
||||
function parseSignedToken<T>(token: string | undefined, kind: string): T | null {
|
||||
if (!token) return null;
|
||||
|
||||
const secret = getSessionSecret();
|
||||
if (!secret) return null;
|
||||
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const version = parts[0];
|
||||
const encodedPayload = parts[1];
|
||||
const providedSignature = parts[2];
|
||||
if (version !== TOKEN_VERSION) return null;
|
||||
if (!encodedPayload || !providedSignature) return null;
|
||||
|
||||
const expectedSignature = signValue(kind, encodedPayload, secret);
|
||||
const providedBuffer = Buffer.from(providedSignature);
|
||||
const expectedBuffer = Buffer.from(expectedSignature);
|
||||
|
||||
if (providedBuffer.length !== expectedBuffer.length) return null;
|
||||
if (!timingSafeEqual(providedBuffer, expectedBuffer)) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(decodeBase64Url(encodedPayload)) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getBaseUrl(): string {
|
||||
return process.env.PANEL_BASE_URL ?? `http://localhost:3000`;
|
||||
}
|
||||
|
||||
function parseCookies(header: string | null): Record<string, string> {
|
||||
if (!header) return {};
|
||||
const cookies: Record<string, string> = {};
|
||||
for (const pair of header.split(";")) {
|
||||
const [key, ...rest] = pair.trim().split("=");
|
||||
if (key) cookies[key] = rest.join("=");
|
||||
}
|
||||
return cookies;
|
||||
}
|
||||
|
||||
function sanitizeReturnTo(rawReturnTo: string | null, baseUrl: string): string {
|
||||
if (!rawReturnTo || rawReturnTo.length > 1024) return "/";
|
||||
|
||||
try {
|
||||
if (rawReturnTo.startsWith("/") && !rawReturnTo.startsWith("//")) {
|
||||
return rawReturnTo;
|
||||
}
|
||||
|
||||
const parsed = new URL(rawReturnTo, baseUrl);
|
||||
const allowedBase = new URL(baseUrl);
|
||||
const isLocalhostRedirect = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
||||
|
||||
if (parsed.origin === allowedBase.origin || isLocalhostRedirect) {
|
||||
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
}
|
||||
} catch {
|
||||
return "/";
|
||||
}
|
||||
|
||||
return "/";
|
||||
}
|
||||
|
||||
function buildCookieAttributes(maxAgeSeconds?: number): string {
|
||||
const attrs = [
|
||||
"Path=/",
|
||||
"HttpOnly",
|
||||
"SameSite=Lax",
|
||||
];
|
||||
|
||||
try {
|
||||
if (new URL(getBaseUrl()).protocol === "https:") {
|
||||
attrs.push("Secure");
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid PANEL_BASE_URL here; handlers that need it will fail explicitly.
|
||||
}
|
||||
|
||||
if (typeof maxAgeSeconds === "number") {
|
||||
attrs.push(`Max-Age=${maxAgeSeconds}`);
|
||||
}
|
||||
|
||||
return attrs.join("; ");
|
||||
}
|
||||
|
||||
/** Get session from request cookie */
|
||||
export function getSession(req: Request): Session | null {
|
||||
const cookies = parseCookies(req.headers.get("cookie"));
|
||||
const payload = parseSignedToken<SessionTokenPayload>(cookies[COOKIE_NAME], "session");
|
||||
|
||||
if (!payload || payload.v !== 1) return null;
|
||||
if (Date.now() > payload.expiresAt) return null;
|
||||
|
||||
return {
|
||||
discordId: payload.discordId,
|
||||
username: payload.username,
|
||||
avatar: payload.avatar,
|
||||
role: payload.role,
|
||||
expiresAt: payload.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Check if request is authenticated as admin */
|
||||
export function isAuthenticated(req: Request): boolean {
|
||||
return getSession(req) !== null;
|
||||
}
|
||||
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method } = ctx;
|
||||
|
||||
// GET /auth/discord — redirect to Discord OAuth
|
||||
if (pathname === "/auth/discord" && method === "GET") {
|
||||
try {
|
||||
const clientId = getEnv("DISCORD_CLIENT_ID");
|
||||
const baseUrl = getBaseUrl();
|
||||
const redirectUri = encodeURIComponent(`${baseUrl}/auth/callback`);
|
||||
const scope = "identify+email";
|
||||
const secret = requireSessionSecret();
|
||||
|
||||
// Store return_to URL in signed OAuth state
|
||||
const returnTo = sanitizeReturnTo(ctx.url.searchParams.get("return_to"), baseUrl);
|
||||
const state = serializeSignedToken("oauth", {
|
||||
exp: Date.now() + OAUTH_STATE_MAX_AGE,
|
||||
returnTo,
|
||||
v: 1,
|
||||
}, secret);
|
||||
|
||||
const url = `https://discord.com/oauth2/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&scope=${scope}&state=${encodeURIComponent(state)}`;
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: url,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("auth", "Failed to initiate OAuth", e);
|
||||
return errorResponse("OAuth not configured", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /auth/callback — handle Discord OAuth callback
|
||||
if (pathname === "/auth/callback" && method === "GET") {
|
||||
const code = ctx.url.searchParams.get("code");
|
||||
if (!code) return errorResponse("Missing code parameter", 400);
|
||||
|
||||
try {
|
||||
const clientId = getEnv("DISCORD_CLIENT_ID");
|
||||
const clientSecret = getEnv("DISCORD_CLIENT_SECRET");
|
||||
const baseUrl = getBaseUrl();
|
||||
const redirectUri = `${baseUrl}/auth/callback`;
|
||||
const secret = requireSessionSecret();
|
||||
const statePayload = parseSignedToken<OAuthStatePayload>(ctx.url.searchParams.get("state") ?? undefined, "oauth");
|
||||
|
||||
if (!statePayload || statePayload.v !== 1 || Date.now() > statePayload.exp) {
|
||||
return errorResponse("Invalid OAuth state", 400);
|
||||
}
|
||||
|
||||
const tokenRes = await fetch("https://discord.com/api/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
logger.error("auth", `Token exchange failed: ${tokenRes.status}`);
|
||||
return errorResponse("OAuth token exchange failed", 401);
|
||||
}
|
||||
|
||||
const tokenData = await tokenRes.json() as { access_token: string };
|
||||
|
||||
// Fetch user info
|
||||
const userRes = await fetch("https://discord.com/api/users/@me", {
|
||||
headers: { Authorization: `Bearer ${tokenData.access_token}` },
|
||||
});
|
||||
|
||||
if (!userRes.ok) {
|
||||
return errorResponse("Failed to fetch Discord user", 401);
|
||||
}
|
||||
|
||||
const user = await userRes.json() as { id: string; username: string; avatar: string | null };
|
||||
|
||||
// Check enrollment — user must exist in the users table
|
||||
const dbUser = await DrizzleClient.query.users.findFirst({
|
||||
where: eq(users.id, BigInt(user.id)),
|
||||
});
|
||||
|
||||
if (!dbUser) {
|
||||
logger.info("auth", `Non-enrolled login attempt by ${user.username} (${user.id})`);
|
||||
return new Response(
|
||||
`<html><body><h1>Not Enrolled</h1><p>You need to use the Aurora bot in Discord before you can access this panel.</p><a href="/">Go back</a></body></html>`,
|
||||
{ status: 403, headers: { "Content-Type": "text/html" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Determine role
|
||||
const adminIds = getAdminIds();
|
||||
const role: "admin" | "player" = adminIds.includes(user.id) ? "admin" : "player";
|
||||
|
||||
// Create signed session cookie
|
||||
const sessionToken = serializeSignedToken("session", {
|
||||
discordId: user.id,
|
||||
username: user.username,
|
||||
avatar: user.avatar,
|
||||
role,
|
||||
expiresAt: Date.now() + SESSION_MAX_AGE,
|
||||
v: 1,
|
||||
}, secret);
|
||||
|
||||
logger.info("auth", `Login: ${user.username} (${user.id}) as ${role}`);
|
||||
|
||||
// Redirect to panel with session cookie
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: sanitizeReturnTo(statePayload.returnTo, baseUrl),
|
||||
"Set-Cookie": `${COOKIE_NAME}=${sessionToken}; ${buildCookieAttributes(SESSION_MAX_AGE / 1000)}`,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("auth", "OAuth callback error", e);
|
||||
return errorResponse("Authentication failed", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /auth/logout — clear session
|
||||
if (pathname === "/auth/logout" && method === "POST") {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Set-Cookie": `${COOKIE_NAME}=; ${buildCookieAttributes(0)}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// GET /auth/me — return current session info
|
||||
if (pathname === "/auth/me" && method === "GET") {
|
||||
const session = getSession(ctx.req);
|
||||
if (!session) return jsonResponse({ authenticated: false, enrolled: true });
|
||||
return jsonResponse({
|
||||
authenticated: true,
|
||||
enrolled: true,
|
||||
user: {
|
||||
discordId: session.discordId,
|
||||
username: session.username,
|
||||
avatar: session.avatar,
|
||||
role: session.role,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const authRoutes: RouteModule = {
|
||||
name: "auth",
|
||||
handler,
|
||||
};
|
||||
64
api/src/routes/guild-settings.routes.ts
Normal file
64
api/src/routes/guild-settings.routes.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @fileoverview Guild settings endpoints for Aurora API.
|
||||
* Provides endpoints for reading and updating per-guild configuration
|
||||
* stored in the database.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
|
||||
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
|
||||
import { invalidateGuildConfigCache } from "@shared/lib/config";
|
||||
|
||||
const GUILD_SETTINGS_PATTERN = /^\/api\/guilds\/(\d+)\/settings$/;
|
||||
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method, req } = ctx;
|
||||
|
||||
const match = pathname.match(GUILD_SETTINGS_PATTERN);
|
||||
if (!match || !match[1]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const guildId = match[1];
|
||||
|
||||
if (method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const settings = await guildSettingsService.getSettings(guildId);
|
||||
if (!settings) {
|
||||
return jsonResponse({ guildId, configured: false });
|
||||
}
|
||||
return jsonResponse({ ...settings, guildId, configured: true });
|
||||
}, "fetch guild settings");
|
||||
}
|
||||
|
||||
if (method === "PUT" || method === "PATCH") {
|
||||
try {
|
||||
const body = await req.json() as Record<string, unknown>;
|
||||
const { guildId: _, ...settings } = body;
|
||||
const result = await guildSettingsService.upsertSettings({
|
||||
guildId,
|
||||
...settings,
|
||||
} as Parameters<typeof guildSettingsService.upsertSettings>[0]);
|
||||
invalidateGuildConfigCache(guildId);
|
||||
return jsonResponse(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return errorResponse("Failed to save guild settings", 400, message);
|
||||
}
|
||||
}
|
||||
|
||||
if (method === "DELETE") {
|
||||
return withErrorHandling(async () => {
|
||||
await guildSettingsService.deleteSettings(guildId);
|
||||
invalidateGuildConfigCache(guildId);
|
||||
return jsonResponse({ success: true });
|
||||
}, "delete guild settings");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const guildSettingsRoutes: RouteModule = {
|
||||
name: "guild-settings",
|
||||
handler
|
||||
};
|
||||
114
api/src/routes/index.authz.test.ts
Normal file
114
api/src/routes/index.authz.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
|
||||
let currentSession: { discordId: string; username: string; role: "admin" | "player"; expiresAt: number } | null = null;
|
||||
|
||||
mock.module("./auth.routes", () => ({
|
||||
authRoutes: { name: "auth", handler: () => null },
|
||||
getSession: () => currentSession,
|
||||
}));
|
||||
|
||||
mock.module("./health.routes", () => ({
|
||||
healthRoutes: {
|
||||
name: "health",
|
||||
handler: ({ pathname }: { pathname: string }) =>
|
||||
pathname === "/api/health"
|
||||
? Response.json({ status: "ok" }, { status: 200 })
|
||||
: null,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("./stats.routes", () => ({
|
||||
statsRoutes: {
|
||||
name: "stats",
|
||||
handler: ({ pathname }: { pathname: string }) =>
|
||||
pathname === "/api/stats"
|
||||
? Response.json({ ok: true }, { status: 200 })
|
||||
: null,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("./actions.routes", () => ({ actionsRoutes: { name: "actions", handler: () => null } }));
|
||||
mock.module("./quests.routes", () => ({ questsRoutes: { name: "quests", handler: () => null } }));
|
||||
mock.module("./settings.routes", () => ({ settingsRoutes: { name: "settings", handler: () => null } }));
|
||||
mock.module("./guild-settings.routes", () => ({ guildSettingsRoutes: { name: "guild-settings", handler: () => null } }));
|
||||
mock.module("./items.routes", () => ({ itemsRoutes: { name: "items", handler: () => null } }));
|
||||
mock.module("./classes.routes", () => ({ classesRoutes: { name: "classes", handler: () => null } }));
|
||||
mock.module("./moderation.routes", () => ({ moderationRoutes: { name: "moderation", handler: () => null } }));
|
||||
mock.module("./transactions.routes", () => ({ transactionsRoutes: { name: "transactions", handler: () => null } }));
|
||||
mock.module("./lootdrops.routes", () => ({ lootdropsRoutes: { name: "lootdrops", handler: () => null } }));
|
||||
mock.module("./assets.routes", () => ({ assetsRoutes: { name: "assets", handler: () => null } }));
|
||||
mock.module("@shared/modules/user/user.service", () => ({
|
||||
userService: {
|
||||
getUserById: async (id: string) => ({ id, username: `user-${id}` }),
|
||||
},
|
||||
}));
|
||||
mock.module("@shared/modules/inventory/inventory.service", () => ({
|
||||
inventoryService: {
|
||||
getInventory: async (id: string) => [{ userId: id, itemId: 1, quantity: 1n }],
|
||||
},
|
||||
}));
|
||||
mock.module("@shared/lib/logger", () => ({
|
||||
logger: {
|
||||
error: () => { },
|
||||
info: () => { },
|
||||
warn: () => { },
|
||||
debug: () => { },
|
||||
},
|
||||
}));
|
||||
|
||||
import { handleRequest } from "./index";
|
||||
|
||||
describe("Route Authorization", () => {
|
||||
beforeEach(() => {
|
||||
currentSession = null;
|
||||
});
|
||||
|
||||
it("rejects unauthenticated protected API requests", async () => {
|
||||
const url = new URL("http://localhost/api/users/123");
|
||||
const res = await handleRequest(new Request(url, { method: "GET" }), url);
|
||||
|
||||
expect(res?.status).toBe(401);
|
||||
});
|
||||
|
||||
it("blocks players from admin user routes", async () => {
|
||||
currentSession = {
|
||||
discordId: "123",
|
||||
username: "player",
|
||||
role: "player",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
};
|
||||
|
||||
const url = new URL("http://localhost/api/users/456");
|
||||
const res = await handleRequest(new Request(url, { method: "GET" }), url);
|
||||
|
||||
expect(res?.status).toBe(403);
|
||||
});
|
||||
|
||||
it("allows players to access self-service API routes", async () => {
|
||||
currentSession = {
|
||||
discordId: "123",
|
||||
username: "player",
|
||||
role: "player",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
};
|
||||
|
||||
const url = new URL("http://localhost/api/me/inventory");
|
||||
const res = await handleRequest(new Request(url, { method: "GET" }), url);
|
||||
|
||||
expect(res?.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows admins to access admin user routes", async () => {
|
||||
currentSession = {
|
||||
discordId: "1",
|
||||
username: "admin",
|
||||
role: "admin",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
};
|
||||
|
||||
const url = new URL("http://localhost/api/users/456");
|
||||
const res = await handleRequest(new Request(url, { method: "GET" }), url);
|
||||
|
||||
expect(res?.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -4,11 +4,13 @@
|
||||
*/
|
||||
|
||||
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";
|
||||
@@ -16,17 +18,21 @@ import { moderationRoutes } from "./moderation.routes";
|
||||
import { transactionsRoutes } from "./transactions.routes";
|
||||
import { lootdropsRoutes } from "./lootdrops.routes";
|
||||
import { assetsRoutes } from "./assets.routes";
|
||||
import { errorResponse } from "./utils";
|
||||
|
||||
/**
|
||||
* All registered route modules in order of precedence.
|
||||
* Routes are checked in order; the first matching route wins.
|
||||
*/
|
||||
const routeModules: RouteModule[] = [
|
||||
/** 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,
|
||||
@@ -56,12 +62,32 @@ export async function handleRequest(req: Request, url: URL): Promise<Response |
|
||||
pathname: url.pathname,
|
||||
};
|
||||
|
||||
// Try each route module in order
|
||||
for (const module of routeModules) {
|
||||
// Try public routes first (auth, health)
|
||||
for (const module of publicRoutes) {
|
||||
const response = await module.handler(ctx);
|
||||
if (response !== null) {
|
||||
return response;
|
||||
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;
|
||||
@@ -72,5 +98,5 @@ export async function handleRequest(req: Request, url: URL): Promise<Response |
|
||||
* Useful for debugging and documentation.
|
||||
*/
|
||||
export function getRegisteredRoutes(): string[] {
|
||||
return routeModules.map(m => m.name);
|
||||
return [...publicRoutes, ...protectedRoutes].map(m => m.name);
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
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,
|
||||
@@ -121,7 +122,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
return withErrorHandling(async () => {
|
||||
const contentType = req.headers.get("content-type") || "";
|
||||
|
||||
let itemData: any;
|
||||
let itemData: CreateItemDTO | null = null;
|
||||
let imageFile: File | null = null;
|
||||
|
||||
if (contentType.includes("multipart/form-data")) {
|
||||
@@ -130,12 +131,16 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
imageFile = formData.get("image") as File | null;
|
||||
|
||||
if (typeof jsonData === "string") {
|
||||
itemData = JSON.parse(jsonData);
|
||||
itemData = JSON.parse(jsonData) as CreateItemDTO;
|
||||
} else {
|
||||
return errorResponse("Missing item data", 400);
|
||||
}
|
||||
} else {
|
||||
itemData = await req.json();
|
||||
itemData = await req.json() as CreateItemDTO;
|
||||
}
|
||||
|
||||
if (!itemData) {
|
||||
return errorResponse("Missing item data", 400);
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
@@ -183,7 +188,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const filePath = join(assetsDir, fileName);
|
||||
await Bun.write(filePath, buffer);
|
||||
|
||||
const assetUrl = `/assets/items/${fileName}`;
|
||||
const assetUrl = `/assets/items/${fileName}?v=${Date.now()}`;
|
||||
await itemsService.updateItem(item.id, {
|
||||
iconUrl: assetUrl,
|
||||
imageUrl: assetUrl,
|
||||
@@ -235,7 +240,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
if (!id) return null;
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const data = await req.json() as Record<string, any>;
|
||||
const data = await req.json() as Partial<UpdateItemDTO>;
|
||||
|
||||
const existing = await itemsService.getItemById(id);
|
||||
if (!existing) {
|
||||
@@ -250,7 +255,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
}
|
||||
|
||||
// Build update data
|
||||
const updateData: any = {};
|
||||
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;
|
||||
@@ -347,7 +352,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const filePath = join(assetsDir, fileName);
|
||||
await Bun.write(filePath, buffer);
|
||||
|
||||
const assetUrl = `/assets/items/${fileName}`;
|
||||
const assetUrl = `/assets/items/${fileName}?v=${Date.now()}`;
|
||||
const updatedItem = await itemsService.updateItem(id, {
|
||||
iconUrl: assetUrl,
|
||||
imageUrl: assetUrl,
|
||||
@@ -73,7 +73,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
*/
|
||||
if (pathname === "/api/lootdrops" && method === "POST") {
|
||||
return withErrorHandling(async () => {
|
||||
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||
const { spawnLootdrop } = await import("../../../bot/modules/economy/lootdrop.handler");
|
||||
const { AuroraClient } = await import("../../../bot/lib/BotClient");
|
||||
const { TextChannel } = await import("discord.js");
|
||||
|
||||
@@ -89,7 +89,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
return errorResponse("Invalid channel. Must be a TextChannel.", 400);
|
||||
}
|
||||
|
||||
await lootdropService.spawnLootdrop(channel, data.amount, data.currency);
|
||||
await spawnLootdrop(channel, data.amount, data.currency);
|
||||
|
||||
return jsonResponse({ success: true }, 201);
|
||||
}, "spawn lootdrop");
|
||||
@@ -110,8 +110,8 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
if (!messageId) return null;
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||
const success = await lootdropService.deleteLootdrop(messageId);
|
||||
const { deleteLootdrop } = await import("../../../bot/modules/economy/lootdrop.handler");
|
||||
const success = await deleteLootdrop(messageId);
|
||||
|
||||
if (!success) {
|
||||
return errorResponse("Lootdrop not found", 404);
|
||||
@@ -29,7 +29,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { ModerationService } = await import("@shared/modules/moderation/moderation.service");
|
||||
const { moderationService } = await import("@shared/modules/moderation/moderation.service");
|
||||
|
||||
/**
|
||||
* @route GET /api/moderation
|
||||
@@ -78,7 +78,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
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);
|
||||
const cases = await moderationService.searchCases(filter);
|
||||
return jsonResponse({ cases });
|
||||
}, "fetch moderation cases");
|
||||
}
|
||||
@@ -97,7 +97,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const caseId = pathname.split("/").pop()!.toUpperCase();
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const moderationCase = await ModerationService.getCaseById(caseId);
|
||||
const moderationCase = await moderationService.getCaseById(caseId);
|
||||
|
||||
if (!moderationCase) {
|
||||
return errorResponse("Case not found", 404);
|
||||
@@ -148,7 +148,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
);
|
||||
}
|
||||
|
||||
const newCase = await ModerationService.createCase({
|
||||
const newCase = await moderationService.createCase({
|
||||
type: data.type,
|
||||
userId: data.userId,
|
||||
username: data.username,
|
||||
@@ -193,7 +193,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
return errorResponse("Missing required fields: clearedBy, clearedByName", 400);
|
||||
}
|
||||
|
||||
const updatedCase = await ModerationService.clearCase({
|
||||
const updatedCase = await moderationService.clearCase({
|
||||
caseId,
|
||||
clearedBy: data.clearedBy,
|
||||
clearedByName: data.clearedByName,
|
||||
@@ -110,7 +110,7 @@ export const UpdateUserSchema = z.object({
|
||||
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()]).optional(),
|
||||
classId: z.union([z.string(), z.number()]).nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -7,13 +7,6 @@
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
|
||||
|
||||
/**
|
||||
* JSON replacer for BigInt serialization.
|
||||
*/
|
||||
function jsonReplacer(_key: string, value: unknown): unknown {
|
||||
return typeof value === "bigint" ? value.toString() : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings routes handler.
|
||||
*
|
||||
@@ -32,26 +25,31 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
|
||||
/**
|
||||
* @route GET /api/settings
|
||||
* @description Returns the current bot configuration.
|
||||
* @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
|
||||
* @response 200 - Full configuration object (DB format with strings for BigInts)
|
||||
* @response 500 - Error fetching settings
|
||||
*
|
||||
* @example
|
||||
* // Response
|
||||
* {
|
||||
* "economy": { "dailyReward": 100, "streakBonus": 10 },
|
||||
* "leveling": { "xpPerMessage": 15, "levelUpChannel": "123456789" },
|
||||
* "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 { config } = await import("@shared/lib/config");
|
||||
return new Response(JSON.stringify(config, jsonReplacer), {
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -61,7 +59,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
* Only the provided fields will be updated; other settings remain unchanged.
|
||||
* After updating, commands are automatically reloaded.
|
||||
*
|
||||
* @body Partial configuration object
|
||||
* @body Partial configuration object (DB format with strings for BigInts)
|
||||
* @response 200 - `{ success: true }`
|
||||
* @response 400 - Validation error
|
||||
* @response 500 - Error saving settings
|
||||
@@ -69,19 +67,15 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
* @example
|
||||
* // Request - Only update economy daily reward
|
||||
* POST /api/settings
|
||||
* { "economy": { "dailyReward": 150 } }
|
||||
* { "economy": { "daily": { "amount": "150" } } }
|
||||
*/
|
||||
if (pathname === "/api/settings" && method === "POST") {
|
||||
try {
|
||||
const partialConfig = await req.json();
|
||||
const { saveConfig, config: currentConfig } = await import("@shared/lib/config");
|
||||
const { deepMerge } = await import("@shared/lib/utils");
|
||||
|
||||
// Merge partial update into current config
|
||||
const mergedConfig = deepMerge(currentConfig, partialConfig);
|
||||
|
||||
// saveConfig throws if validation fails
|
||||
saveConfig(mergedConfig);
|
||||
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);
|
||||
@@ -145,7 +139,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return jsonResponse({ roles, channels, commands });
|
||||
return jsonResponse({ guildId: env.DISCORD_GUILD_ID, roles, channels, commands });
|
||||
}, "fetch settings meta");
|
||||
}
|
||||
|
||||
140
api/src/routes/users.routes.test.ts
Normal file
140
api/src/routes/users.routes.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
|
||||
let currentSession: { discordId: string; username: string; role: "admin" | "player"; expiresAt: number } | null = null;
|
||||
|
||||
const getUserById = mock(async (id: string) => ({
|
||||
id,
|
||||
username: id === "123" ? "player-one" : "user",
|
||||
level: 5,
|
||||
xp: 100n,
|
||||
balance: 250n,
|
||||
className: null,
|
||||
}));
|
||||
|
||||
const updateUser = mock(async (id: string, data: Record<string, unknown>) => ({
|
||||
id,
|
||||
...data,
|
||||
}));
|
||||
|
||||
const getInventory = mock(async (id: string) => [{ userId: id, itemId: 1, quantity: 2n }]);
|
||||
const addItem = mock(async (userId: string, itemId: number, quantity: bigint) => ({ userId, itemId, quantity }));
|
||||
const removeItem = mock(async () => undefined);
|
||||
|
||||
mock.module("./auth.routes", () => ({
|
||||
getSession: () => currentSession,
|
||||
}));
|
||||
|
||||
mock.module("@shared/modules/user/user.service", () => ({
|
||||
userService: {
|
||||
getUserById,
|
||||
updateUser,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("@shared/modules/inventory/inventory.service", () => ({
|
||||
inventoryService: {
|
||||
getInventory,
|
||||
addItem,
|
||||
removeItem,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("@shared/lib/logger", () => ({
|
||||
logger: {
|
||||
error: () => { },
|
||||
info: () => { },
|
||||
warn: () => { },
|
||||
debug: () => { },
|
||||
},
|
||||
}));
|
||||
|
||||
import { usersRoutes } from "./users.routes";
|
||||
|
||||
describe("Users Routes", () => {
|
||||
beforeEach(() => {
|
||||
currentSession = {
|
||||
discordId: "123",
|
||||
username: "player",
|
||||
role: "player",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
};
|
||||
getUserById.mockClear();
|
||||
updateUser.mockClear();
|
||||
getInventory.mockClear();
|
||||
addItem.mockClear();
|
||||
removeItem.mockClear();
|
||||
});
|
||||
|
||||
it("serves the authenticated user through /api/me", async () => {
|
||||
const url = new URL("http://localhost/api/me");
|
||||
const res = await usersRoutes.handler({
|
||||
req: new Request(url, { method: "GET" }),
|
||||
url,
|
||||
method: "GET",
|
||||
pathname: "/api/me",
|
||||
});
|
||||
|
||||
expect(res?.status).toBe(200);
|
||||
expect(getUserById).toHaveBeenCalledWith("123");
|
||||
});
|
||||
|
||||
it("serves the authenticated user's inventory through /api/me/inventory", async () => {
|
||||
const url = new URL("http://localhost/api/me/inventory");
|
||||
const res = await usersRoutes.handler({
|
||||
req: new Request(url, { method: "GET" }),
|
||||
url,
|
||||
method: "GET",
|
||||
pathname: "/api/me/inventory",
|
||||
});
|
||||
|
||||
expect(res?.status).toBe(200);
|
||||
expect(getInventory).toHaveBeenCalledWith("123");
|
||||
});
|
||||
|
||||
it("validates user updates before calling the service", async () => {
|
||||
const url = new URL("http://localhost/api/users/123");
|
||||
const res = await usersRoutes.handler({
|
||||
req: new Request(url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ level: -1 }),
|
||||
}),
|
||||
url,
|
||||
method: "PUT",
|
||||
pathname: "/api/users/123",
|
||||
});
|
||||
|
||||
expect(res?.status).toBe(400);
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates inventory additions before calling the service", async () => {
|
||||
const url = new URL("http://localhost/api/users/123/inventory");
|
||||
const res = await usersRoutes.handler({
|
||||
req: new Request(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ itemId: 1, quantity: 0 }),
|
||||
}),
|
||||
url,
|
||||
method: "POST",
|
||||
pathname: "/api/users/123/inventory",
|
||||
});
|
||||
|
||||
expect(res?.status).toBe(400);
|
||||
expect(addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates inventory removal query params before calling the service", async () => {
|
||||
const url = new URL("http://localhost/api/users/123/inventory/1?amount=0");
|
||||
const res = await usersRoutes.handler({
|
||||
req: new Request(url, { method: "DELETE" }),
|
||||
url,
|
||||
method: "DELETE",
|
||||
pathname: "/api/users/123/inventory/1",
|
||||
});
|
||||
|
||||
expect(res?.status).toBe(400);
|
||||
expect(removeItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8,16 +8,19 @@ import {
|
||||
jsonResponse,
|
||||
errorResponse,
|
||||
parseBody,
|
||||
parseIdFromPath,
|
||||
parseQuery,
|
||||
parseStringIdFromPath,
|
||||
withErrorHandling
|
||||
} from "./utils";
|
||||
import { UpdateUserSchema, InventoryAddSchema } from "./schemas";
|
||||
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
|
||||
@@ -30,6 +33,37 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -55,12 +89,12 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
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 = url.searchParams.get("search") || undefined;
|
||||
const sortBy = url.searchParams.get("sortBy") || "balance";
|
||||
const sortOrder = url.searchParams.get("sortOrder") || "desc";
|
||||
const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
|
||||
const offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
|
||||
const { search, sortBy, sortOrder, limit, offset } = queryParams;
|
||||
|
||||
let query = DrizzleClient.select().from(users);
|
||||
|
||||
@@ -146,7 +180,10 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const { userService } = await import("@shared/modules/user/user.service");
|
||||
const data = await req.json() as Record<string, any>;
|
||||
const parsed = await parseBody(req, UpdateUserSchema);
|
||||
if (parsed instanceof Response) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const existing = await userService.getUserById(id);
|
||||
if (!existing) {
|
||||
@@ -155,14 +192,16 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
|
||||
// Build update data (only allow safe fields)
|
||||
const updateData: any = {};
|
||||
if (data.username !== undefined) updateData.username = data.username;
|
||||
if (data.balance !== undefined) updateData.balance = BigInt(data.balance);
|
||||
if (data.xp !== undefined) updateData.xp = BigInt(data.xp);
|
||||
if (data.level !== undefined) updateData.level = parseInt(data.level);
|
||||
if (data.dailyStreak !== undefined) updateData.dailyStreak = parseInt(data.dailyStreak);
|
||||
if (data.isActive !== undefined) updateData.isActive = Boolean(data.isActive);
|
||||
if (data.settings !== undefined) updateData.settings = data.settings;
|
||||
if (data.classId !== undefined) updateData.classId = BigInt(data.classId);
|
||||
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 });
|
||||
@@ -215,13 +254,12 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
|
||||
const data = await req.json() as Record<string, any>;
|
||||
|
||||
if (!data.itemId || !data.quantity) {
|
||||
return errorResponse("Missing required fields: itemId, quantity", 400);
|
||||
const parsed = await parseBody(req, InventoryAddSchema);
|
||||
if (parsed instanceof Response) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const entry = await inventoryService.addItem(id, data.itemId, BigInt(data.quantity));
|
||||
const entry = await inventoryService.addItem(id, parsed.itemId, BigInt(parsed.quantity));
|
||||
return jsonResponse({ success: true, entry }, 201);
|
||||
}, "add item to inventory");
|
||||
}
|
||||
@@ -245,11 +283,12 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
|
||||
const queryParams = parseQuery(url, InventoryRemoveQuerySchema);
|
||||
if (queryParams instanceof Response) {
|
||||
return queryParams;
|
||||
}
|
||||
|
||||
const amount = url.searchParams.get("amount");
|
||||
const quantity = amount ? BigInt(amount) : 1n;
|
||||
|
||||
await inventoryService.removeItem(userId, itemId, quantity);
|
||||
await inventoryService.removeItem(userId, itemId, BigInt(queryParams.amount));
|
||||
return new Response(null, { status: 204 });
|
||||
}, "remove item from inventory");
|
||||
}
|
||||
@@ -132,6 +132,12 @@ mock.module("@shared/lib/utils", () => ({
|
||||
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: {
|
||||
@@ -403,8 +409,11 @@ describe("Items API", () => {
|
||||
});
|
||||
|
||||
test("should prevent path traversal attacks", async () => {
|
||||
const response = await fetch(`${baseUrl}/assets/../../../etc/passwd`);
|
||||
// Should either return 403 (Forbidden) or 404 (Not found after sanitization)
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -1,40 +1,57 @@
|
||||
import { describe, expect, it, mock, beforeEach, afterEach, jest } from "bun:test";
|
||||
import { type WebServerInstance } from "./server";
|
||||
|
||||
// Mock the dependencies
|
||||
const mockConfig = {
|
||||
// Mock gameSettingsService — the route now uses this instead of config/saveConfig
|
||||
const mockSettings = {
|
||||
leveling: {
|
||||
base: 100,
|
||||
exponent: 1.5,
|
||||
chat: { minXp: 10, maxXp: 20, cooldownMs: 60000 }
|
||||
},
|
||||
economy: {
|
||||
daily: { amount: 100n, streakBonus: 10n, weeklyBonus: 50n, cooldownMs: 86400000 },
|
||||
transfers: { allowSelfTransfer: false, minAmount: 50n },
|
||||
daily: { amount: "100", streakBonus: "10", weeklyBonus: "50", cooldownMs: 86400000 },
|
||||
transfers: { allowSelfTransfer: false, minAmount: "1" },
|
||||
exam: { multMin: 1.5, multMax: 2.5 }
|
||||
},
|
||||
inventory: { maxStackSize: 99n, maxSlots: 20 },
|
||||
inventory: { maxStackSize: "99", maxSlots: 20 },
|
||||
lootdrop: {
|
||||
spawnChance: 0.1,
|
||||
cooldownMs: 3600000,
|
||||
minMessages: 10,
|
||||
activityWindowMs: 300000,
|
||||
reward: { min: 100, max: 500, currency: "gold" }
|
||||
},
|
||||
commands: { "help": true },
|
||||
system: {},
|
||||
moderation: {
|
||||
prune: { maxAmount: 100, confirmThreshold: 50, batchSize: 100, batchDelayMs: 1000 },
|
||||
cases: { dmOnWarn: true }
|
||||
},
|
||||
trivia: {
|
||||
entryFee: "50",
|
||||
rewardMultiplier: 1.5,
|
||||
timeoutSeconds: 30,
|
||||
cooldownMs: 60000,
|
||||
categories: [],
|
||||
difficulty: "random"
|
||||
}
|
||||
};
|
||||
|
||||
const mockSaveConfig = jest.fn();
|
||||
const mockGetSettings = jest.fn(() => Promise.resolve(mockSettings));
|
||||
const mockUpsertSettings = jest.fn(() => Promise.resolve(mockSettings));
|
||||
const mockGetDefaults = jest.fn(() => mockSettings);
|
||||
|
||||
// Mock @shared/lib/config using mock.module
|
||||
mock.module("@shared/lib/config", () => ({
|
||||
config: mockConfig,
|
||||
saveConfig: mockSaveConfig,
|
||||
GameConfigType: {}
|
||||
mock.module("@shared/modules/game-settings/game-settings.service", () => ({
|
||||
gameSettingsService: {
|
||||
getSettings: mockGetSettings,
|
||||
upsertSettings: mockUpsertSettings,
|
||||
getDefaults: mockGetDefaults,
|
||||
invalidateCache: jest.fn(),
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock DrizzleClient (dependency potentially imported transitively)
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: {}
|
||||
}));
|
||||
|
||||
// Mock @shared/lib/utils (deepMerge is used by settings API)
|
||||
@@ -93,6 +110,12 @@ mock.module("bun", () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock auth (bypass authentication)
|
||||
mock.module("./routes/auth.routes", () => ({
|
||||
authRoutes: { name: "auth", handler: () => null },
|
||||
getSession: () => ({ discordId: "123", username: "testuser", role: "admin", expiresAt: Date.now() + 3600000 }),
|
||||
}));
|
||||
|
||||
// Import createWebServer after mocks
|
||||
import { createWebServer } from "./server";
|
||||
|
||||
@@ -104,6 +127,8 @@ describe("Settings API", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
mockGetSettings.mockImplementation(() => Promise.resolve(mockSettings));
|
||||
mockUpsertSettings.mockImplementation(() => Promise.resolve(mockSettings));
|
||||
serverInstance = await createWebServer({ port: PORT, hostname: HOSTNAME });
|
||||
});
|
||||
|
||||
@@ -117,18 +142,14 @@ describe("Settings API", () => {
|
||||
const res = await fetch(`${BASE_URL}/api/settings`);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
// Check if BigInts are converted to strings
|
||||
const data = await res.json() as any;
|
||||
// Check values come through correctly
|
||||
expect(data.economy.daily.amount).toBe("100");
|
||||
expect(data.leveling.base).toBe(100);
|
||||
});
|
||||
|
||||
it("POST /api/settings should save valid configuration via merge", async () => {
|
||||
// We only send a partial update, expecting the server to merge it
|
||||
// Note: For now the server implementation might still default to overwrite if we haven't updated it yet.
|
||||
// But the user requested "partial vs full" fix.
|
||||
// Let's assume we implement the merge logic.
|
||||
const partialConfig = { studentRole: "new-role-partial" };
|
||||
const partialConfig = { economy: { daily: { amount: "200" } } };
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/settings`, {
|
||||
method: "POST",
|
||||
@@ -137,26 +158,27 @@ describe("Settings API", () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Expect saveConfig to be called with the MERGED result
|
||||
expect(mockSaveConfig).toHaveBeenCalledWith(expect.objectContaining({
|
||||
studentRole: "new-role-partial",
|
||||
leveling: mockConfig.leveling // Should keep existing values
|
||||
}));
|
||||
// upsertSettings should be called with the partial config
|
||||
expect(mockUpsertSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
economy: { daily: { amount: "200" } }
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /api/settings should return 400 when save fails", async () => {
|
||||
mockSaveConfig.mockImplementationOnce(() => {
|
||||
mockUpsertSettings.mockImplementationOnce(() => {
|
||||
throw new Error("Validation failed");
|
||||
});
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/settings`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}) // Empty might be valid partial, but mocks throw
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
const data = await res.json() as any;
|
||||
expect(data.details).toBe("Validation failed");
|
||||
});
|
||||
|
||||
@@ -164,7 +186,7 @@ describe("Settings API", () => {
|
||||
const res = await fetch(`${BASE_URL}/api/settings/meta`);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
const data = await res.json() as any;
|
||||
expect(data.roles).toHaveLength(2);
|
||||
expect(data.roles[0]).toEqual({ id: "role1", name: "Admin", color: "#ffffff" });
|
||||
expect(data.channels[0]).toEqual({ id: "chan1", name: "general", type: 0 });
|
||||
199
api/src/server.test.ts
Normal file
199
api/src/server.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { beforeEach, describe, test, expect, afterAll, mock } from "bun:test";
|
||||
import type { WebServerInstance } from "./server";
|
||||
|
||||
interface MockBotStats {
|
||||
bot: { name: string; avatarUrl: string | null };
|
||||
guilds: number;
|
||||
ping: number;
|
||||
cachedUsers: number;
|
||||
commandsRegistered: number;
|
||||
uptime: number;
|
||||
lastCommandTimestamp: number | null;
|
||||
}
|
||||
|
||||
// 1. Mock DrizzleClient (dependency of dashboardService)
|
||||
// Must provide full chainable builder for select().from().leftJoin().groupBy().orderBy().limit()
|
||||
mock.module("@shared/db/DrizzleClient", () => {
|
||||
const mockBuilder: Record<string, any> = {};
|
||||
// Every chainable method returns mock builder; terminal calls return resolved promise
|
||||
mockBuilder.where = mock(() => Promise.resolve([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]));
|
||||
mockBuilder.then = (onfulfilled: any) => onfulfilled([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]);
|
||||
mockBuilder.orderBy = mock(() => mockBuilder);
|
||||
mockBuilder.limit = mock(() => Promise.resolve([]));
|
||||
mockBuilder.leftJoin = mock(() => mockBuilder);
|
||||
mockBuilder.groupBy = mock(() => mockBuilder);
|
||||
mockBuilder.from = mock(() => mockBuilder);
|
||||
|
||||
return {
|
||||
DrizzleClient: {
|
||||
select: mock(() => mockBuilder),
|
||||
query: {
|
||||
transactions: { findMany: mock(() => Promise.resolve([])) },
|
||||
moderationCases: { findMany: mock(() => Promise.resolve([])) },
|
||||
users: {
|
||||
findFirst: mock(() => Promise.resolve({ username: "test" })),
|
||||
findMany: mock(() => Promise.resolve([])),
|
||||
},
|
||||
lootdrops: { findMany: mock(() => Promise.resolve([])) },
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// 2. Mock Bot Stats Provider
|
||||
mock.module("../../bot/lib/clientStats", () => ({
|
||||
getClientStats: mock((): MockBotStats => ({
|
||||
bot: { name: "TestBot", avatarUrl: null },
|
||||
guilds: 5,
|
||||
ping: 42,
|
||||
cachedUsers: 100,
|
||||
commandsRegistered: 10,
|
||||
uptime: 3600,
|
||||
lastCommandTimestamp: Date.now(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// 3. Mock config (used by lootdrop.service.getLootdropState)
|
||||
mock.module("@shared/lib/config", () => ({
|
||||
config: {
|
||||
lootdrop: {
|
||||
activityWindowMs: 120000,
|
||||
minMessages: 1,
|
||||
spawnChance: 1,
|
||||
cooldownMs: 3000,
|
||||
reward: { min: 40, max: 150, currency: "Astral Units" }
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
let currentSession: { discordId: string; username: string; role: "admin" | "player"; expiresAt: number } | null = {
|
||||
discordId: "123",
|
||||
username: "admin-user",
|
||||
role: "admin",
|
||||
expiresAt: Date.now() + 3600000,
|
||||
};
|
||||
|
||||
// 4. Mock auth with a mutable session so tests can exercise authz paths.
|
||||
mock.module("./routes/auth.routes", () => ({
|
||||
authRoutes: { name: "auth", handler: () => null },
|
||||
getSession: () => currentSession,
|
||||
}));
|
||||
|
||||
// 5. Mock BotClient (used by stats helper for maintenanceMode)
|
||||
mock.module("../../bot/lib/BotClient", () => ({
|
||||
AuroraClient: {
|
||||
maintenanceMode: false,
|
||||
guilds: { cache: { get: () => null } },
|
||||
commands: [],
|
||||
knownCommands: new Map(),
|
||||
}
|
||||
}));
|
||||
|
||||
// Import after all mocks are set up
|
||||
import { createWebServer } from "./server";
|
||||
|
||||
describe("WebServer Security & Limits", () => {
|
||||
const port = 3001;
|
||||
const hostname = "127.0.0.1";
|
||||
let serverInstance: WebServerInstance | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
currentSession = {
|
||||
discordId: "123",
|
||||
username: "admin-user",
|
||||
role: "admin",
|
||||
expiresAt: Date.now() + 3600000,
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (serverInstance) {
|
||||
await serverInstance.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("should reject unauthorized websocket requests", async () => {
|
||||
serverInstance = await createWebServer({ port, hostname });
|
||||
currentSession = null;
|
||||
|
||||
const response = await fetch(`http://${hostname}:${port}/ws`);
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(body).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
test("should accept websocket requests for authenticated sessions", async () => {
|
||||
if (!serverInstance) {
|
||||
serverInstance = await createWebServer({ port, hostname });
|
||||
}
|
||||
|
||||
const ws = new WebSocket(`ws://${hostname}:${port}/ws`);
|
||||
const opened = await new Promise<boolean>((resolve) => {
|
||||
const timeout = setTimeout(() => resolve(false), 1000);
|
||||
ws.addEventListener("open", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(true);
|
||||
});
|
||||
ws.addEventListener("error", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
|
||||
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||
ws.close();
|
||||
}
|
||||
|
||||
expect(opened).toBe(true);
|
||||
});
|
||||
|
||||
test("should return 200 for health check", async () => {
|
||||
if (!serverInstance) {
|
||||
serverInstance = await createWebServer({ port, hostname });
|
||||
}
|
||||
const response = await fetch(`http://${hostname}:${port}/api/health`);
|
||||
expect(response.status).toBe(200);
|
||||
const data = (await response.json()) as { status: string };
|
||||
expect(data.status).toBe("ok");
|
||||
});
|
||||
|
||||
describe("Administrative Actions", () => {
|
||||
test("should allow administrative actions for admin sessions", async () => {
|
||||
const response = await fetch(`http://${hostname}:${port}/api/actions/reload-commands`, {
|
||||
method: "POST"
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("should reject administrative actions for player sessions", async () => {
|
||||
currentSession = {
|
||||
discordId: "456",
|
||||
username: "player-user",
|
||||
role: "player",
|
||||
expiresAt: Date.now() + 3600000,
|
||||
};
|
||||
|
||||
const response = await fetch(`http://${hostname}:${port}/api/actions/reload-commands`, {
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const data = await response.json() as { error: string };
|
||||
expect(data.error).toBe("Admin access required");
|
||||
});
|
||||
|
||||
test("should reject maintenance mode with invalid payload", async () => {
|
||||
const response = await fetch(`http://${hostname}:${port}/api/actions/maintenance-mode`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ not_enabled: true }) // Wrong field
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json() as { error: string };
|
||||
expect(data.error).toBe("Invalid payload");
|
||||
});
|
||||
});
|
||||
});
|
||||
242
api/src/server.ts
Normal file
242
api/src/server.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* @fileoverview API server factory module.
|
||||
* Exports a function to create and start the API server.
|
||||
* This allows the server to be started in-process from the main application.
|
||||
*
|
||||
* Routes are organized into modular files in the ./routes directory.
|
||||
* Each route module handles its own validation, business logic, and responses.
|
||||
*/
|
||||
|
||||
import { serve, file } from "bun";
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
import { handleRequest } from "./routes";
|
||||
import { getFullDashboardStats } from "./routes/stats.helper";
|
||||
import { join } from "path";
|
||||
import { gameServer } from "./games/GameServer";
|
||||
import type { WsConnectionData } from "./games/GameServer";
|
||||
import { getSession } from "./routes/auth.routes";
|
||||
import { GameWsClientSchema } from "./games/types";
|
||||
|
||||
// Register game plugins
|
||||
import { gameRegistry } from "@shared/games/registry";
|
||||
import { chessPlugin } from "@shared/games/chess/chess.plugin";
|
||||
import { blackjackPlugin } from "@shared/games/blackjack/blackjack.plugin";
|
||||
gameRegistry.register(chessPlugin);
|
||||
gameRegistry.register(blackjackPlugin);
|
||||
|
||||
const WS_CONFIG = {
|
||||
MAX_CONNECTIONS: 200,
|
||||
MAX_PAYLOAD_BYTES: 16384,
|
||||
IDLE_TIMEOUT_SECONDS: 60,
|
||||
STATS_BROADCAST_INTERVAL_MS: 5000,
|
||||
} as const;
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
".html": "text/html",
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
};
|
||||
|
||||
export interface WebServerConfig {
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
export interface WebServerInstance {
|
||||
server: ReturnType<typeof serve>;
|
||||
stop: () => Promise<void>;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve static files from the panel dist directory.
|
||||
* Falls back to index.html for SPA routing.
|
||||
*/
|
||||
async function servePanelStatic(pathname: string, distDir: string): Promise<Response | null> {
|
||||
// Don't serve panel for API/auth/ws/assets routes
|
||||
if (pathname.startsWith("/api/") || pathname.startsWith("/auth/") || pathname === "/ws" || pathname.startsWith("/assets/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to serve the exact file
|
||||
const filePath = join(distDir, pathname);
|
||||
const bunFile = file(filePath);
|
||||
if (await bunFile.exists()) {
|
||||
const ext = pathname.substring(pathname.lastIndexOf("."));
|
||||
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
||||
return new Response(bunFile, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// SPA fallback: serve index.html for all non-file routes
|
||||
const indexFile = file(join(distDir, "index.html"));
|
||||
if (await indexFile.exists()) {
|
||||
return new Response(indexFile, {
|
||||
headers: { "Content-Type": "text/html", "Cache-Control": "no-cache" },
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function createWebServer(config: WebServerConfig = {}): Promise<WebServerInstance> {
|
||||
const { port = 3000, hostname = "localhost" } = config;
|
||||
|
||||
let activeConnections = 0;
|
||||
let statsBroadcastInterval: Timer | undefined;
|
||||
|
||||
const server = serve<WsConnectionData>({
|
||||
port,
|
||||
hostname,
|
||||
async fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (url.pathname === "/ws") {
|
||||
if (activeConnections >= WS_CONFIG.MAX_CONNECTIONS) {
|
||||
logger.warn("web", `Connection rejected: limit reached (${activeConnections}/${WS_CONFIG.MAX_CONNECTIONS})`);
|
||||
return new Response("Connection limit reached", { status: 429 });
|
||||
}
|
||||
|
||||
const session = getSession(req);
|
||||
if (!session) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const success = server.upgrade(req, {
|
||||
data: {
|
||||
session: {
|
||||
discordId: session.discordId,
|
||||
username: session.username,
|
||||
role: session.role,
|
||||
},
|
||||
rooms: new Set<string>(),
|
||||
},
|
||||
});
|
||||
if (success) return undefined;
|
||||
return new Response("WebSocket upgrade failed", { status: 400 });
|
||||
}
|
||||
|
||||
const response = await handleRequest(req, url);
|
||||
if (response) return response;
|
||||
|
||||
const panelDistDir = join(import.meta.dir, "../../panel/dist");
|
||||
const staticResponse = await servePanelStatic(url.pathname, panelDistDir);
|
||||
if (staticResponse) return staticResponse;
|
||||
|
||||
return new Response("Not Found", { status: 404 });
|
||||
},
|
||||
|
||||
websocket: {
|
||||
open(ws: ServerWebSocket<WsConnectionData>) {
|
||||
activeConnections++;
|
||||
ws.subscribe("dashboard");
|
||||
ws.subscribe("lobby");
|
||||
logger.debug("web", `Client connected: ${ws.data.session.discordId}. Total: ${activeConnections}`);
|
||||
|
||||
getFullDashboardStats().then(stats => {
|
||||
ws.send(JSON.stringify({ type: "STATS_UPDATE", data: stats }));
|
||||
});
|
||||
|
||||
gameServer.handleOpen(ws);
|
||||
|
||||
if (!statsBroadcastInterval) {
|
||||
statsBroadcastInterval = setInterval(async () => {
|
||||
try {
|
||||
const stats = await getFullDashboardStats();
|
||||
server.publish("dashboard", JSON.stringify({ type: "STATS_UPDATE", data: stats }));
|
||||
} catch (error) {
|
||||
logger.error("web", "Error in stats broadcast", error);
|
||||
}
|
||||
}, WS_CONFIG.STATS_BROADCAST_INTERVAL_MS);
|
||||
}
|
||||
},
|
||||
|
||||
async message(ws: ServerWebSocket<WsConnectionData>, message) {
|
||||
try {
|
||||
const messageStr = message.toString();
|
||||
|
||||
if (messageStr.length > WS_CONFIG.MAX_PAYLOAD_BYTES) {
|
||||
logger.error("web", "Payload exceeded maximum limit");
|
||||
return;
|
||||
}
|
||||
|
||||
const rawData = JSON.parse(messageStr);
|
||||
|
||||
// Handle dashboard-level messages (PING, etc.)
|
||||
if (rawData && typeof rawData === "object" && rawData.type === "PING") {
|
||||
ws.send(JSON.stringify({ type: "PONG" }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Route game messages — try to parse as a game client message
|
||||
const gameCheck = GameWsClientSchema.safeParse(rawData);
|
||||
if (gameCheck.success) {
|
||||
gameServer.handleMessage(ws, rawData).catch(err =>
|
||||
logger.error("web", `Game message handler error: ${err}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to full WsMessageSchema for dashboard messages that aren't PING
|
||||
const { WsMessageSchema } = await import("@shared/modules/dashboard/dashboard.types");
|
||||
const parsed = WsMessageSchema.safeParse(rawData);
|
||||
if (!parsed?.success) {
|
||||
logger.error("web", "Invalid message format", parsed?.error.issues);
|
||||
}
|
||||
// Nothing else to do for PONG/STATS_UPDATE/NEW_EVENT from clients
|
||||
} catch (e) {
|
||||
logger.error("web", "Failed to handle message", e);
|
||||
}
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<WsConnectionData>) {
|
||||
activeConnections--;
|
||||
ws.unsubscribe("dashboard");
|
||||
ws.unsubscribe("lobby");
|
||||
logger.debug("web", `Client disconnected: ${ws.data.session.discordId}. Total remaining: ${activeConnections}`);
|
||||
|
||||
gameServer.handleClose(ws);
|
||||
|
||||
if (activeConnections === 0 && statsBroadcastInterval) {
|
||||
clearInterval(statsBroadcastInterval);
|
||||
statsBroadcastInterval = undefined;
|
||||
}
|
||||
},
|
||||
maxPayloadLength: WS_CONFIG.MAX_PAYLOAD_BYTES,
|
||||
idleTimeout: WS_CONFIG.IDLE_TIMEOUT_SECONDS,
|
||||
},
|
||||
});
|
||||
|
||||
// Wire gameServer to Bun server for pub/sub publishing
|
||||
gameServer.setServer(server);
|
||||
|
||||
const { systemEvents, EVENTS } = await import("@shared/lib/events");
|
||||
systemEvents.on(EVENTS.DASHBOARD.NEW_EVENT, (event) => {
|
||||
server.publish("dashboard", JSON.stringify({ type: "NEW_EVENT", data: event }));
|
||||
});
|
||||
|
||||
const url = `http://${hostname}:${port}`;
|
||||
|
||||
return {
|
||||
server,
|
||||
url,
|
||||
stop: async () => {
|
||||
if (statsBroadcastInterval) {
|
||||
clearInterval(statsBroadcastInterval);
|
||||
}
|
||||
server.stop(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const moderationCase = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -16,39 +17,35 @@ export const moderationCase = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||
|
||||
try {
|
||||
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;
|
||||
}
|
||||
|
||||
// Validate case ID format
|
||||
if (!caseId.match(/^CASE-\d+$/)) {
|
||||
// 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: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
||||
embeds: [getCaseEmbed(moderationCase)]
|
||||
});
|
||||
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)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Case command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching the case.")]
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCasesListEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const cases = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -22,33 +23,29 @@ export const cases = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
const activeOnly = interaction.options.getBoolean("active_only") || false;
|
||||
|
||||
try {
|
||||
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);
|
||||
|
||||
// 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 title = activeOnly
|
||||
? `⚠️ Active Cases for ${targetUser.username}`
|
||||
: `📋 All Cases for ${targetUser.username}`;
|
||||
const description = userCases.length === 0
|
||||
? undefined
|
||||
: `Total cases: **${userCases.length}**`;
|
||||
|
||||
const description = userCases.length === 0
|
||||
? undefined
|
||||
: `Total cases: **${userCases.length}**`;
|
||||
|
||||
// Display the cases
|
||||
await interaction.editReply({
|
||||
embeds: [getCasesListEmbed(userCases, title, description)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Cases command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching cases.")]
|
||||
});
|
||||
}
|
||||
// Display the cases
|
||||
await interaction.editReply({
|
||||
embeds: [getCasesListEmbed(userCases, title, description)]
|
||||
});
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const clearwarning = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -23,62 +24,58 @@ export const clearwarning = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||
const reason = interaction.options.getString("reason") || "Cleared by moderator";
|
||||
|
||||
try {
|
||||
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;
|
||||
}
|
||||
|
||||
// Validate case ID format
|
||||
if (!caseId.match(/^CASE-\d+$/)) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
||||
// 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
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if case exists and is active
|
||||
const existingCase = await ModerationService.getCaseById(caseId);
|
||||
|
||||
if (!existingCase) {
|
||||
// Send success message
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
||||
embeds: [getClearSuccessEmbed(caseId)]
|
||||
});
|
||||
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)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Clear warning command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while clearing the warning.")]
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js";
|
||||
import { config, saveConfig } from "@shared/lib/config";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
|
||||
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { items } from "@db/schema";
|
||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const createColor = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -31,64 +33,60 @@ export const createColor = createCommand({
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const name = interaction.options.getString("name", true);
|
||||
const colorInput = interaction.options.getString("color", true);
|
||||
const price = interaction.options.getNumber("price") || 500;
|
||||
const imageUrl = interaction.options.getString("image") || "https://cdn.discordapp.com/attachments/1450061247365124199/1453122950822760559/Main_Chip_1.png";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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}`
|
||||
});
|
||||
|
||||
try {
|
||||
// 2. Create Role
|
||||
const role = await interaction.guild?.roles.create({
|
||||
name: name,
|
||||
color: colorInput as any, // Discord.js types are a bit strict on ColorResolvable, but string generally works or needs parsing
|
||||
reason: `Created via /createcolor by ${interaction.user.tag}`
|
||||
});
|
||||
if (!role) {
|
||||
throw new Error("Failed to create role.");
|
||||
}
|
||||
|
||||
if (!role) {
|
||||
throw new Error("Failed to create role.");
|
||||
}
|
||||
// 3. Add to guild settings
|
||||
await guildSettingsService.addColorRole(interaction.guildId!, role.id);
|
||||
invalidateGuildConfigCache(interaction.guildId!);
|
||||
|
||||
// 3. Update Config
|
||||
if (!config.colorRoles.includes(role.id)) {
|
||||
config.colorRoles.push(role.id);
|
||||
saveConfig(config);
|
||||
}
|
||||
// 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
|
||||
});
|
||||
|
||||
// 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"
|
||||
)]
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error in createcolor:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(`Failed to create color role: ${error.message}`)] });
|
||||
}
|
||||
// 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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
293
bot/commands/admin/featureflags.ts
Normal file
293
bot/commands/admin/featureflags.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, Colors, userMention, roleMention, ChatInputCommandInteraction } from "discord.js";
|
||||
import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service";
|
||||
import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const featureflags = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("featureflags")
|
||||
.setDescription("Manage feature flags for beta testing")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("list")
|
||||
.setDescription("List all feature flags")
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("create")
|
||||
.setDescription("Create a new feature flag")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("name")
|
||||
.setDescription("Name of the feature flag")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addStringOption(opt =>
|
||||
opt.setName("description")
|
||||
.setDescription("Description of the feature flag")
|
||||
.setRequired(false)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("delete")
|
||||
.setDescription("Delete a feature flag")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("name")
|
||||
.setDescription("Name of the feature flag")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("enable")
|
||||
.setDescription("Enable a feature flag")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("name")
|
||||
.setDescription("Name of the feature flag")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("disable")
|
||||
.setDescription("Disable a feature flag")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("name")
|
||||
.setDescription("Name of the feature flag")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("grant")
|
||||
.setDescription("Grant access to a feature flag")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("name")
|
||||
.setDescription("Name of the feature flag")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
)
|
||||
.addUserOption(opt =>
|
||||
opt.setName("user")
|
||||
.setDescription("User to grant access to")
|
||||
.setRequired(false)
|
||||
)
|
||||
.addRoleOption(opt =>
|
||||
opt.setName("role")
|
||||
.setDescription("Role to grant access to")
|
||||
.setRequired(false)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("revoke")
|
||||
.setDescription("Revoke access from a feature flag")
|
||||
.addIntegerOption(opt =>
|
||||
opt.setName("id")
|
||||
.setDescription("Access record ID to revoke")
|
||||
.setRequired(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("access")
|
||||
.setDescription("List access records for a feature flag")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("name")
|
||||
.setDescription("Name of the feature flag")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
)
|
||||
),
|
||||
autocomplete: async (interaction) => {
|
||||
const focused = interaction.options.getFocused(true);
|
||||
|
||||
if (focused.name === "name") {
|
||||
const flags = await featureFlagsService.listFlags();
|
||||
const filtered = flags
|
||||
.filter(f => f.name.toLowerCase().includes(focused.value.toLowerCase()))
|
||||
.slice(0, 25);
|
||||
|
||||
await interaction.respond(
|
||||
filtered.map(f => ({ name: `${f.name} (${f.enabled ? "enabled" : "disabled"})`, value: f.name }))
|
||||
);
|
||||
}
|
||||
},
|
||||
execute: async (interaction) => {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
switch (subcommand) {
|
||||
case "list":
|
||||
await handleList(interaction);
|
||||
break;
|
||||
case "create":
|
||||
await handleCreate(interaction);
|
||||
break;
|
||||
case "delete":
|
||||
await handleDelete(interaction);
|
||||
break;
|
||||
case "enable":
|
||||
await handleEnable(interaction);
|
||||
break;
|
||||
case "disable":
|
||||
await handleDisable(interaction);
|
||||
break;
|
||||
case "grant":
|
||||
await handleGrant(interaction);
|
||||
break;
|
||||
case "revoke":
|
||||
await handleRevoke(interaction);
|
||||
break;
|
||||
case "access":
|
||||
await handleAccess(interaction);
|
||||
break;
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
async function handleList(interaction: ChatInputCommandInteraction) {
|
||||
const flags = await featureFlagsService.listFlags();
|
||||
|
||||
if (flags.length === 0) {
|
||||
await interaction.editReply({ embeds: [createBaseEmbed("Feature Flags", "No feature flags have been created yet.", Colors.Blue)] });
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = createBaseEmbed("Feature Flags", undefined, Colors.Blue)
|
||||
.addFields(
|
||||
flags.map(f => ({
|
||||
name: f.name,
|
||||
value: `${f.enabled ? "✅ Enabled" : "❌ Disabled"}\n${f.description || "*No description*"}`,
|
||||
inline: false,
|
||||
}))
|
||||
);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
|
||||
async function handleCreate(interaction: ChatInputCommandInteraction) {
|
||||
const name = interaction.options.getString("name", true);
|
||||
const description = interaction.options.getString("description");
|
||||
|
||||
const flag = await featureFlagsService.createFlag(name, description ?? undefined);
|
||||
|
||||
if (!flag) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to create feature flag.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" created successfully. Use \`/featureflags enable\` to enable it.`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDelete(interaction: ChatInputCommandInteraction) {
|
||||
const name = interaction.options.getString("name", true);
|
||||
|
||||
const flag = await featureFlagsService.deleteFlag(name);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been deleted.`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleEnable(interaction: ChatInputCommandInteraction) {
|
||||
const name = interaction.options.getString("name", true);
|
||||
|
||||
const flag = await featureFlagsService.setFlagEnabled(name, true);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been enabled.`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDisable(interaction: ChatInputCommandInteraction) {
|
||||
const name = interaction.options.getString("name", true);
|
||||
|
||||
const flag = await featureFlagsService.setFlagEnabled(name, false);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been disabled.`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleGrant(interaction: ChatInputCommandInteraction) {
|
||||
const name = interaction.options.getString("name", true);
|
||||
const user = interaction.options.getUser("user");
|
||||
const role = interaction.options.getRole("role");
|
||||
|
||||
if (!user && !role) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("You must specify either a user or a role to grant access to.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const access = await featureFlagsService.grantAccess(name, {
|
||||
userId: user?.id,
|
||||
roleId: role?.id,
|
||||
guildId: interaction.guildId!,
|
||||
});
|
||||
|
||||
if (!access) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to grant access.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
let target: string;
|
||||
if (user) {
|
||||
target = userMention(user.id);
|
||||
} else if (role) {
|
||||
target = roleMention(role.id);
|
||||
} else {
|
||||
target = "Unknown";
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Access to "**${name}**" granted to ${target} (ID: ${access.id})`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRevoke(interaction: ChatInputCommandInteraction) {
|
||||
const id = interaction.options.getInteger("id", true);
|
||||
|
||||
const access = await featureFlagsService.revokeAccess(id);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Access record #${access.id} has been revoked.`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAccess(interaction: ChatInputCommandInteraction) {
|
||||
const name = interaction.options.getString("name", true);
|
||||
|
||||
const accessRecords = await featureFlagsService.listAccess(name);
|
||||
|
||||
if (accessRecords.length === 0) {
|
||||
await interaction.editReply({
|
||||
embeds: [createBaseEmbed("Feature Flag Access", `No access records for "**${name}**".`, Colors.Blue)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = accessRecords.map(a => {
|
||||
let target = "Unknown";
|
||||
if (a.userId) target = `User: ${userMention(a.userId.toString())}`;
|
||||
else if (a.roleId) target = `Role: ${roleMention(a.roleId.toString())}`;
|
||||
else if (a.guildId) target = `Guild: ${a.guildId.toString()}`;
|
||||
|
||||
return {
|
||||
name: `ID: ${a.id}`,
|
||||
value: target,
|
||||
inline: true,
|
||||
};
|
||||
});
|
||||
|
||||
const embed = createBaseEmbed(`Feature Flag Access: ${name}`, undefined, Colors.Blue)
|
||||
.addFields(fields);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
} from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
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()
|
||||
@@ -31,72 +31,67 @@ export const listing = createCommand({
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const itemId = interaction.options.getNumber("item", true);
|
||||
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
|
||||
|
||||
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' });
|
||||
if (!targetChannel || !targetChannel.isSendable()) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listingMessage = getShopListingMessage({
|
||||
...item,
|
||||
rarity: item.rarity || undefined,
|
||||
formattedPrice: `${item.price} 🪙`,
|
||||
price: item.price
|
||||
}, context);
|
||||
const item = await inventoryService.getItem(itemId);
|
||||
if (!item) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await targetChannel.send(listingMessage as any);
|
||||
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error creating listing:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||
}
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { CaseType } from "@shared/lib/constants";
|
||||
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const note = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -24,39 +25,35 @@ export const note = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
const noteText = interaction.options.getString("note", true);
|
||||
|
||||
try {
|
||||
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.")]
|
||||
// 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,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Send success message
|
||||
await interaction.editReply({
|
||||
embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)]
|
||||
});
|
||||
if (!moderationCase) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("Failed to create note.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Note command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while adding the note.")]
|
||||
});
|
||||
}
|
||||
// Send success message
|
||||
await interaction.editReply({
|
||||
embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)]
|
||||
});
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCasesListEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const notes = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -16,28 +17,24 @@ export const notes = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
|
||||
try {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
// Get all notes for the user
|
||||
const userNotes = await moderationService.getUserNotes(targetUser.id);
|
||||
|
||||
// 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}**`
|
||||
)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Notes command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching notes.")]
|
||||
});
|
||||
}
|
||||
// Display the notes
|
||||
await interaction.editReply({
|
||||
embeds: [getCasesListEmbed(
|
||||
userNotes,
|
||||
`📝 Staff Notes for ${targetUser.username}`,
|
||||
userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**`
|
||||
)]
|
||||
});
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { PruneService } from "@shared/modules/moderation/prune.service";
|
||||
import { pruneService } from "@modules/moderation/prune.service";
|
||||
import {
|
||||
getConfirmationMessage,
|
||||
getProgressEmbed,
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
getPruneWarningEmbed,
|
||||
getCancelledEmbed
|
||||
} from "@/modules/moderation/prune.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
import { PRUNE_CUSTOM_IDS } from "@modules/moderation/prune.types";
|
||||
|
||||
export const prune = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -38,142 +40,126 @@ export const prune = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const amount = interaction.options.getInteger("amount");
|
||||
const user = interaction.options.getUser("user");
|
||||
const all = interaction.options.getBoolean("all") || false;
|
||||
|
||||
try {
|
||||
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 === "cancel_prune") {
|
||||
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
|
||||
// Validate inputs
|
||||
if (!amount && !all) {
|
||||
// Default to 10 messages
|
||||
} else if (amount && all) {
|
||||
await interaction.editReply({
|
||||
embeds: [getSuccessEmbed(result)],
|
||||
components: []
|
||||
embeds: [getPruneErrorEmbed("Cannot specify both `amount` and `all`. Please use one or the other.")]
|
||||
});
|
||||
|
||||
} 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)]
|
||||
});
|
||||
}
|
||||
const finalAmount = all ? 'all' : (amount || 10);
|
||||
const confirmThreshold = config.moderation.prune.confirmThreshold;
|
||||
|
||||
} catch (error) {
|
||||
console.error("Prune command error:", error);
|
||||
// Check if confirmation is needed
|
||||
const needsConfirmation = all || (typeof finalAmount === 'number' && finalAmount > confirmThreshold);
|
||||
|
||||
let errorMessage = "An unexpected error occurred while trying to delete messages.";
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("permission")) {
|
||||
errorMessage = "I don't have permission to delete messages in this channel.";
|
||||
} else if (error.message.includes("channel type")) {
|
||||
errorMessage = "This command cannot be used in this type of channel.";
|
||||
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 {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
// No confirmation needed, proceed directly
|
||||
const result = await pruneService.deleteMessages(
|
||||
interaction.channel!,
|
||||
{
|
||||
amount: finalAmount as number,
|
||||
userId: user?.id,
|
||||
all: false
|
||||
}
|
||||
);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneErrorEmbed(errorMessage)]
|
||||
});
|
||||
}
|
||||
// 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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { createSuccessEmbed } from "@lib/embeds";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const refresh = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -9,25 +10,24 @@ export const refresh = createCommand({
|
||||
.setDescription("Reloads all commands and config without restarting")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const start = Date.now();
|
||||
await AuroraClient.loadCommands(true);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
await AuroraClient.loadCommands(true);
|
||||
const duration = Date.now() - start;
|
||||
// Deploy commands
|
||||
await AuroraClient.deployCommands();
|
||||
|
||||
// Deploy commands
|
||||
await AuroraClient.deployCommands();
|
||||
const embed = createSuccessEmbed(
|
||||
`Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`,
|
||||
"System Refreshed"
|
||||
);
|
||||
|
||||
const embed = createSuccessEmbed(
|
||||
`Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`,
|
||||
"System Refreshed"
|
||||
);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An error occurred while refreshing commands. Check console for details.", "Refresh Failed")] });
|
||||
}
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
243
bot/commands/admin/settings.ts
Normal file
243
bot/commands/admin/settings.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, Colors, ChatInputCommandInteraction } from "discord.js";
|
||||
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
|
||||
import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const settings = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("settings")
|
||||
.setDescription("Manage guild settings")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("show")
|
||||
.setDescription("Show current guild settings"))
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("set")
|
||||
.setDescription("Set a guild setting")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("key")
|
||||
.setDescription("Setting to change")
|
||||
.setRequired(true)
|
||||
.addChoices(
|
||||
{ name: "Student Role", value: "studentRole" },
|
||||
{ name: "Visitor Role", value: "visitorRole" },
|
||||
{ name: "Welcome Channel", value: "welcomeChannel" },
|
||||
{ name: "Welcome Message", value: "welcomeMessage" },
|
||||
{ name: "Feedback Channel", value: "feedbackChannel" },
|
||||
{ name: "Terminal Channel", value: "terminalChannel" },
|
||||
{ name: "Terminal Message", value: "terminalMessage" },
|
||||
{ name: "Moderation Log Channel", value: "moderationLogChannel" },
|
||||
{ name: "DM on Warn", value: "moderationDmOnWarn" },
|
||||
{ name: "Auto Timeout Threshold", value: "moderationAutoTimeoutThreshold" },
|
||||
))
|
||||
.addRoleOption(opt =>
|
||||
opt.setName("role")
|
||||
.setDescription("Role value"))
|
||||
.addChannelOption(opt =>
|
||||
opt.setName("channel")
|
||||
.setDescription("Channel value"))
|
||||
.addStringOption(opt =>
|
||||
opt.setName("text")
|
||||
.setDescription("Text value"))
|
||||
.addIntegerOption(opt =>
|
||||
opt.setName("number")
|
||||
.setDescription("Number value"))
|
||||
.addBooleanOption(opt =>
|
||||
opt.setName("boolean")
|
||||
.setDescription("Boolean value (true/false)")))
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("reset")
|
||||
.setDescription("Reset a setting to default")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("key")
|
||||
.setDescription("Setting to reset")
|
||||
.setRequired(true)
|
||||
.addChoices(
|
||||
{ name: "Student Role", value: "studentRole" },
|
||||
{ name: "Visitor Role", value: "visitorRole" },
|
||||
{ name: "Welcome Channel", value: "welcomeChannel" },
|
||||
{ name: "Welcome Message", value: "welcomeMessage" },
|
||||
{ name: "Feedback Channel", value: "feedbackChannel" },
|
||||
{ name: "Terminal Channel", value: "terminalChannel" },
|
||||
{ name: "Terminal Message", value: "terminalMessage" },
|
||||
{ name: "Moderation Log Channel", value: "moderationLogChannel" },
|
||||
{ name: "DM on Warn", value: "moderationDmOnWarn" },
|
||||
{ name: "Auto Timeout Threshold", value: "moderationAutoTimeoutThreshold" },
|
||||
)))
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("colors")
|
||||
.setDescription("Manage color roles")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("action")
|
||||
.setDescription("Action to perform")
|
||||
.setRequired(true)
|
||||
.addChoices(
|
||||
{ name: "List", value: "list" },
|
||||
{ name: "Add", value: "add" },
|
||||
{ name: "Remove", value: "remove" },
|
||||
))
|
||||
.addRoleOption(opt =>
|
||||
opt.setName("role")
|
||||
.setDescription("Role to add/remove")
|
||||
.setRequired(false))),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
const guildId = interaction.guildId!;
|
||||
|
||||
switch (subcommand) {
|
||||
case "show":
|
||||
await handleShow(interaction, guildId);
|
||||
break;
|
||||
case "set":
|
||||
await handleSet(interaction, guildId);
|
||||
break;
|
||||
case "reset":
|
||||
await handleReset(interaction, guildId);
|
||||
break;
|
||||
case "colors":
|
||||
await handleColors(interaction, guildId);
|
||||
break;
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
async function handleShow(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||
const settings = await getGuildConfig(guildId);
|
||||
|
||||
const colorRolesDisplay = settings.colorRoles?.length
|
||||
? settings.colorRoles.map(id => `<@&${id}>`).join(", ")
|
||||
: "None";
|
||||
|
||||
const embed = createBaseEmbed("Guild Settings", undefined, Colors.Blue)
|
||||
.addFields(
|
||||
{ name: "Student Role", value: settings.studentRole ? `<@&${settings.studentRole}>` : "Not set", inline: true },
|
||||
{ name: "Visitor Role", value: settings.visitorRole ? `<@&${settings.visitorRole}>` : "Not set", inline: true },
|
||||
{ name: "\u200b", value: "\u200b", inline: true },
|
||||
{ name: "Welcome Channel", value: settings.welcomeChannelId ? `<#${settings.welcomeChannelId}>` : "Not set", inline: true },
|
||||
{ name: "Feedback Channel", value: settings.feedbackChannelId ? `<#${settings.feedbackChannelId}>` : "Not set", inline: true },
|
||||
{ name: "Moderation Log", value: settings.moderation?.cases?.logChannelId ? `<#${settings.moderation.cases.logChannelId}>` : "Not set", inline: true },
|
||||
{ name: "Terminal Channel", value: settings.terminal?.channelId ? `<#${settings.terminal.channelId}>` : "Not set", inline: true },
|
||||
{ name: "DM on Warn", value: settings.moderation?.cases?.dmOnWarn !== false ? "Enabled" : "Disabled", inline: true },
|
||||
{ name: "Auto Timeout", value: settings.moderation?.cases?.autoTimeoutThreshold ? `${settings.moderation.cases.autoTimeoutThreshold} warnings` : "Disabled", inline: true },
|
||||
{ name: "Color Roles", value: colorRolesDisplay, inline: false },
|
||||
);
|
||||
|
||||
if (settings.welcomeMessage) {
|
||||
embed.addFields({ name: "Welcome Message", value: settings.welcomeMessage.substring(0, 1024), inline: false });
|
||||
}
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
|
||||
async function handleSet(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||
const key = interaction.options.getString("key", true);
|
||||
const role = interaction.options.getRole("role");
|
||||
const channel = interaction.options.getChannel("channel");
|
||||
const text = interaction.options.getString("text");
|
||||
const number = interaction.options.getInteger("number");
|
||||
const boolean = interaction.options.getBoolean("boolean");
|
||||
|
||||
let value: string | number | boolean | null = null;
|
||||
|
||||
if (role) value = role.id;
|
||||
else if (channel) value = channel.id;
|
||||
else if (text) value = text;
|
||||
else if (number !== null) value = number;
|
||||
else if (boolean !== null) value = boolean;
|
||||
|
||||
if (value === null) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("Please provide a role, channel, text, number, or boolean value")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await guildSettingsService.updateSetting(guildId, key, value);
|
||||
invalidateGuildConfigCache(guildId);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Setting "${key}" updated`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleReset(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||
const key = interaction.options.getString("key", true);
|
||||
|
||||
await guildSettingsService.updateSetting(guildId, key, null);
|
||||
invalidateGuildConfigCache(guildId);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Setting "${key}" reset to default`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleColors(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||
const action = interaction.options.getString("action", true);
|
||||
const role = interaction.options.getRole("role");
|
||||
|
||||
switch (action) {
|
||||
case "list": {
|
||||
const settings = await getGuildConfig(guildId);
|
||||
const colorRoles = settings.colorRoles ?? [];
|
||||
|
||||
if (colorRoles.length === 0) {
|
||||
await interaction.editReply({
|
||||
embeds: [createBaseEmbed("Color Roles", "No color roles configured.", Colors.Blue)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = createBaseEmbed("Color Roles", undefined, Colors.Blue)
|
||||
.addFields({
|
||||
name: `Configured Roles (${colorRoles.length})`,
|
||||
value: colorRoles.map(id => `<@&${id}>`).join("\n"),
|
||||
});
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
break;
|
||||
}
|
||||
|
||||
case "add": {
|
||||
if (!role) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("Please specify a role to add.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await guildSettingsService.addColorRole(guildId, role.id);
|
||||
invalidateGuildConfigCache(guildId);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Added <@&${role.id}> to color roles.`)]
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "remove": {
|
||||
if (!role) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("Please specify a role to remove.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await guildSettingsService.removeColorRole(guildId, role.id);
|
||||
invalidateGuildConfigCache(guildId);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Removed <@&${role.id}> from color roles.`)]
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
|
||||
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
||||
import { createBaseEmbed, createErrorEmbed } from "@/lib/embeds";
|
||||
import { terminalService } from "@modules/system/terminal.service";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const terminal = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -23,15 +24,14 @@ export const terminal = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({ ephemeral: true, content: "Initializing terminal..." });
|
||||
|
||||
try {
|
||||
await terminalService.init(channel as TextChannel);
|
||||
await interaction.editReply({ content: "✅ Terminal initialized!" });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await interaction.editReply({ content: "❌ Failed to initialize terminal." });
|
||||
}
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
await terminalService.init(channel as TextChannel);
|
||||
await interaction.editReply({ content: "✅ Terminal initialized!" });
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import {
|
||||
getWarnSuccessEmbed,
|
||||
getModerationErrorEmbed,
|
||||
getUserWarningEmbed
|
||||
} from "@/modules/moderation/moderation.view";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const warn = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -28,60 +28,63 @@ export const warn = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
const reason = interaction.options.getString("reason", true);
|
||||
|
||||
try {
|
||||
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 warning bots
|
||||
if (targetUser.bot) {
|
||||
// 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: [getModerationErrorEmbed("You cannot warn bots.")]
|
||||
embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't allow self-warnings
|
||||
if (targetUser.id === interaction.user.id) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("You cannot warn yourself.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 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)
|
||||
});
|
||||
|
||||
// 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
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Warn command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while issuing the warning.")]
|
||||
});
|
||||
}
|
||||
// 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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getWarningsEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const warnings = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -16,24 +17,20 @@ export const warnings = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
|
||||
try {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
// Get active warnings for the user
|
||||
const activeWarnings = await moderationService.getUserWarnings(targetUser.id);
|
||||
|
||||
// Get active warnings for the user
|
||||
const activeWarnings = await ModerationService.getUserWarnings(targetUser.id);
|
||||
|
||||
// Display the warnings
|
||||
await interaction.editReply({
|
||||
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Warnings command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching warnings.")]
|
||||
});
|
||||
}
|
||||
// Display the warnings
|
||||
await interaction.editReply({
|
||||
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)]
|
||||
});
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const webhook = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -14,43 +15,40 @@ export const webhook = createCommand({
|
||||
.setRequired(true)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const payloadString = interaction.options.getString("payload", true);
|
||||
let payload;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const channel = interaction.channel;
|
||||
if (!channel || !('createWebhook' in channel)) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("This channel does not support webhooks.", "Unsupported Channel")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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}`
|
||||
);
|
||||
|
||||
try {
|
||||
await sendWebhookMessage(
|
||||
channel,
|
||||
payload,
|
||||
interaction.client.user,
|
||||
`Proxy message requested by ${interaction.user.tag}`
|
||||
);
|
||||
|
||||
await interaction.editReply({ content: "Message sent successfully!" });
|
||||
} catch (error) {
|
||||
console.error("Webhook error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("Failed to send message via webhook. Ensure the bot has 'Manage Webhooks' permission and the payload is valid.", "Delivery Failed")]
|
||||
});
|
||||
}
|
||||
await interaction.editReply({ content: "Message sent successfully!" });
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,35 +2,29 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { createSuccessEmbed } from "@lib/embeds";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const daily = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("daily")
|
||||
.setDescription("Claim your daily reward"),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
try {
|
||||
const result = await economyService.claimDaily(interaction.user.id);
|
||||
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");
|
||||
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] });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error claiming daily:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
@@ -10,66 +11,62 @@ export const exam = createCommand({
|
||||
.setName("exam")
|
||||
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
// First, try to take the exam or check status
|
||||
const result = await examService.takeExam(interaction.user.id);
|
||||
|
||||
try {
|
||||
// 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);
|
||||
|
||||
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(
|
||||
`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"
|
||||
`**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!"
|
||||
)]
|
||||
});
|
||||
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!"
|
||||
)]
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error in exam command:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An unexpected error occurred.")] });
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const pay = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -50,20 +50,14 @@ export const pay = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await interaction.deferReply();
|
||||
await economyService.transfer(senderId, receiverId.toString(), amount);
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
await economyService.transfer(senderId, receiverId.toString(), amount);
|
||||
|
||||
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
|
||||
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error sending payment:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
|
||||
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createErrorEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { TriviaCategory } from "@shared/lib/constants";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const trivia = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -53,64 +54,54 @@ export const trivia = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
// User can play - defer publicly for trivia question
|
||||
await interaction.deferReply();
|
||||
// User can play - use standardized error handling for the main operation
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
// Start trivia session (deducts entry fee)
|
||||
const session = await triviaService.startTrivia(
|
||||
interaction.user.id,
|
||||
interaction.user.username,
|
||||
categoryId ? parseInt(categoryId) : undefined
|
||||
);
|
||||
|
||||
// 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
|
||||
}
|
||||
);
|
||||
|
||||
// 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) {
|
||||
// Check if we've already deferred
|
||||
if (interaction.deferred) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(error.message)]
|
||||
});
|
||||
} else {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed(error.message)],
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed(error.message)],
|
||||
ephemeral: true
|
||||
});
|
||||
} else {
|
||||
console.error("Error in trivia command:", error);
|
||||
// Check if we've already deferred
|
||||
if (interaction.deferred) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")]
|
||||
});
|
||||
} else {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { getFeedbackTypeMenu } from "@/modules/feedback/feedback.view";
|
||||
|
||||
@@ -9,8 +9,10 @@ export const feedback = createCommand({
|
||||
.setName("feedback")
|
||||
.setDescription("Submit feedback, feature requests, or bug reports"),
|
||||
execute: async (interaction) => {
|
||||
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||
|
||||
// Check if feedback channel is configured
|
||||
if (!config.feedbackChannelId) {
|
||||
if (!guildConfig.feedbackChannelId) {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("Feedback system is not configured. Please contact an administrator.")],
|
||||
ephemeral: true
|
||||
|
||||
@@ -1,22 +1,83 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { SlashCommandBuilder, MessageFlags, ComponentType } from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { createWarningEmbed } from "@lib/embeds";
|
||||
import { getInventoryEmbed } from "@/modules/inventory/inventory.view";
|
||||
import { createWarningEmbed, createErrorEmbed } from "@lib/embeds";
|
||||
import {
|
||||
getInventoryListMessage,
|
||||
getEmptyInventoryMessage,
|
||||
getItemDetailMessage,
|
||||
getDiscardConfirmMessage,
|
||||
appendUseBackButton,
|
||||
sortInventoryItems,
|
||||
ITEMS_PER_PAGE,
|
||||
type InventoryEntry,
|
||||
} from "@/modules/inventory/inventory.view";
|
||||
import { getLootboxResultMessage } from "@/modules/inventory/inventory.view";
|
||||
import {
|
||||
parseInventoryCustomId,
|
||||
executeItemUse,
|
||||
} from "@/modules/inventory/inventory.interaction";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
export const inventory = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("inventory")
|
||||
.setDescription("View your or another user's inventory")
|
||||
.addUserOption(option =>
|
||||
option.setName("user")
|
||||
.setDescription("User to view")
|
||||
.setRequired(false)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("list")
|
||||
.setDescription("View your or another user's inventory")
|
||||
.addUserOption(option =>
|
||||
option.setName("user")
|
||||
.setDescription("User to view")
|
||||
.setRequired(false)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("view")
|
||||
.setDescription("View details of a specific item")
|
||||
.addNumberOption(option =>
|
||||
option.setName("item")
|
||||
.setDescription("The item to view")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
|
||||
const viewerId = interaction.user.id;
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
if (subcommand === "view") {
|
||||
// Direct item detail view
|
||||
const itemId = interaction.options.getNumber("item", true);
|
||||
const user = await userService.getOrCreateUser(viewerId, interaction.user.username);
|
||||
if (!user) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("Failed to load user data.", "Error")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = await inventoryService.getInventory(user.id.toString());
|
||||
const entry = entries.find((e: any) => e.item.id === itemId);
|
||||
if (!entry) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("Item not found in your inventory.", "Not Found")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const ownerId = user.id.toString();
|
||||
let currentPage = 0;
|
||||
let selectedItemId: number | null = itemId;
|
||||
|
||||
const response = await interaction.editReply(
|
||||
getItemDetailMessage(entry as InventoryEntry, viewerId, ownerId) as any
|
||||
);
|
||||
|
||||
await setupCollector(interaction, response, viewerId, ownerId, user.username, currentPage, selectedItemId);
|
||||
return;
|
||||
}
|
||||
|
||||
// "list" subcommand
|
||||
const targetUser = interaction.options.getUser("user") || interaction.user;
|
||||
|
||||
if (targetUser.bot) {
|
||||
@@ -30,15 +91,232 @@ export const inventory = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
const items = await inventoryService.getInventory(user.id.toString());
|
||||
const ownerId = user.id.toString();
|
||||
const entries = await inventoryService.getInventory(ownerId);
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("Inventory is empty.", `${user.username}'s Inventory`)] });
|
||||
if (!entries || entries.length === 0) {
|
||||
await interaction.editReply(getEmptyInventoryMessage(user.username) as any);
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = getInventoryEmbed(items, user.username);
|
||||
let currentPage = 0;
|
||||
let selectedItemId: number | null = null;
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
const response = await interaction.editReply(
|
||||
getInventoryListMessage(entries as InventoryEntry[], user.username, currentPage, viewerId, ownerId) as any
|
||||
);
|
||||
|
||||
await setupCollector(interaction, response, viewerId, ownerId, user.username, currentPage, selectedItemId);
|
||||
},
|
||||
autocomplete: async (interaction) => {
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
const userId = interaction.user.id;
|
||||
const results = await inventoryService.getAutocompleteItems(userId, focusedValue);
|
||||
await interaction.respond(results);
|
||||
},
|
||||
});
|
||||
|
||||
async function setupCollector(
|
||||
interaction: any,
|
||||
response: any,
|
||||
viewerId: string,
|
||||
ownerId: string,
|
||||
username: string,
|
||||
initialPage: number,
|
||||
initialItemId: number | null,
|
||||
) {
|
||||
let currentPage = initialPage;
|
||||
let selectedItemId = initialItemId;
|
||||
|
||||
const collector = response.createMessageComponentCollector({
|
||||
time: 120_000,
|
||||
});
|
||||
|
||||
collector.on("collect", async (i: any) => {
|
||||
if (i.user.id !== viewerId) return;
|
||||
|
||||
const parsed = parseInventoryCustomId(i.customId);
|
||||
if (!parsed) return;
|
||||
|
||||
try {
|
||||
await i.deferUpdate();
|
||||
|
||||
// Re-fetch inventory for fresh data
|
||||
const entries = await inventoryService.getInventory(ownerId);
|
||||
const sorted = sortInventoryItems(entries as InventoryEntry[]);
|
||||
|
||||
switch (parsed.action) {
|
||||
case "select": {
|
||||
const itemId = parseInt(i.values[0]);
|
||||
const entry = sorted.find(e => e.item.id === itemId);
|
||||
if (!entry) break;
|
||||
selectedItemId = itemId;
|
||||
await interaction.editReply(
|
||||
getItemDetailMessage(entry, viewerId, ownerId)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "prev": {
|
||||
currentPage = Math.max(0, currentPage - 1);
|
||||
selectedItemId = null;
|
||||
await interaction.editReply(
|
||||
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "next": {
|
||||
currentPage = currentPage + 1;
|
||||
selectedItemId = null;
|
||||
await interaction.editReply(
|
||||
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "back": {
|
||||
selectedItemId = null;
|
||||
if (sorted.length === 0) {
|
||||
await interaction.editReply(getEmptyInventoryMessage(username));
|
||||
} else {
|
||||
await interaction.editReply(
|
||||
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "use": {
|
||||
if (viewerId !== ownerId || !selectedItemId) break;
|
||||
try {
|
||||
const result = await executeItemUse(i, viewerId, selectedItemId);
|
||||
const message = getLootboxResultMessage(result.results, result.item);
|
||||
await interaction.editReply(appendUseBackButton(message, viewerId) as any);
|
||||
} catch (error) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(error.message)],
|
||||
components: [],
|
||||
flags: undefined,
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "use_back": {
|
||||
// Return from use result to detail or list
|
||||
if (!selectedItemId) break;
|
||||
const entry = sorted.find(e => e.item.id === selectedItemId);
|
||||
if (entry) {
|
||||
await interaction.editReply(
|
||||
getItemDetailMessage(entry, viewerId, ownerId)
|
||||
);
|
||||
} else {
|
||||
selectedItemId = null;
|
||||
if (sorted.length === 0) {
|
||||
await interaction.editReply(getEmptyInventoryMessage(username));
|
||||
} else {
|
||||
await interaction.editReply(
|
||||
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "discard": {
|
||||
if (viewerId !== ownerId || !selectedItemId) break;
|
||||
const entry = sorted.find(e => e.item.id === selectedItemId);
|
||||
if (!entry) break;
|
||||
await interaction.editReply(
|
||||
getDiscardConfirmMessage(entry, viewerId)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "discard_confirm": {
|
||||
if (viewerId !== ownerId || !selectedItemId) break;
|
||||
try {
|
||||
await inventoryService.removeItem(ownerId, selectedItemId, 1n);
|
||||
|
||||
const freshEntries = await inventoryService.getInventory(ownerId);
|
||||
const freshSorted = sortInventoryItems(freshEntries as InventoryEntry[]);
|
||||
const freshEntry = freshSorted.find(e => e.item.id === selectedItemId);
|
||||
|
||||
if (freshEntry) {
|
||||
await interaction.editReply(
|
||||
getItemDetailMessage(freshEntry, viewerId, ownerId)
|
||||
);
|
||||
} else {
|
||||
selectedItemId = null;
|
||||
if (freshSorted.length === 0) {
|
||||
await interaction.editReply(getEmptyInventoryMessage(username));
|
||||
} else {
|
||||
await interaction.editReply(
|
||||
getInventoryListMessage(freshSorted, username, currentPage, viewerId, ownerId)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(error.message)],
|
||||
components: [],
|
||||
flags: undefined,
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "discard_cancel": {
|
||||
if (!selectedItemId) break;
|
||||
const entry = sorted.find(e => e.item.id === selectedItemId);
|
||||
if (!entry) break;
|
||||
await interaction.editReply(
|
||||
getItemDetailMessage(entry, viewerId, ownerId)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Inventory interaction error:", error);
|
||||
}
|
||||
});
|
||||
|
||||
collector.on("end", async () => {
|
||||
try {
|
||||
// Re-render current view as static (no interactive components)
|
||||
const entries = await inventoryService.getInventory(ownerId);
|
||||
const sorted = sortInventoryItems(entries as InventoryEntry[]);
|
||||
|
||||
if (selectedItemId) {
|
||||
const entry = sorted.find(e => e.item.id === selectedItemId);
|
||||
if (entry) {
|
||||
// Show detail view without action buttons
|
||||
const msg = getItemDetailMessage(entry, viewerId, ownerId);
|
||||
// Replace components with empty to remove buttons but keep container content
|
||||
await interaction.editReply(msg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (sorted.length === 0) {
|
||||
await interaction.editReply(getEmptyInventoryMessage(username));
|
||||
} else {
|
||||
await interaction.editReply(
|
||||
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// If re-rendering fails, at least try to clear gracefully
|
||||
interaction.editReply({ components: [] }).catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,10 +3,9 @@ import { SlashCommandBuilder } from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
||||
import type { ItemUsageData } from "@shared/lib/types";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { getLootboxResultMessage } from "@/modules/inventory/inventory.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
|
||||
export const use = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -19,54 +18,49 @@ export const use = createCommand({
|
||||
.setAutocomplete(true)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||
const colorRoles = guildConfig.colorRoles ?? [];
|
||||
|
||||
const itemId = interaction.options.getNumber("item", true);
|
||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
if (!user) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] });
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await inventoryService.useItem(user.id.toString(), itemId);
|
||||
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 = config.colorRoles.filter(r => member.roles.cache.has(r));
|
||||
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
|
||||
await member.roles.add(effect.roleId);
|
||||
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)");
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
|
||||
const { embed, files } = getItemUseResultEmbed(result.results, result.item);
|
||||
|
||||
await interaction.editReply({ embeds: [embed], files });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error using item:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred while using the item.")] });
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
autocomplete: async (interaction) => {
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
|
||||
@@ -2,11 +2,12 @@ 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
|
||||
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()
|
||||
@@ -16,16 +17,24 @@ export const quests = createCommand({
|
||||
const response = await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const userId = interaction.user.id;
|
||||
let currentView: 'active' | 'available' = 'active';
|
||||
let currentPage = 0;
|
||||
|
||||
const updateView = async (viewType: 'active' | 'available', page: number = 0) => {
|
||||
currentView = viewType;
|
||||
currentPage = page;
|
||||
|
||||
const updateView = async (viewType: 'active' | 'available') => {
|
||||
const userQuests = await questService.getUserQuests(userId);
|
||||
const availableQuests = await questService.getAvailableQuests(userId);
|
||||
|
||||
const containers = viewType === 'active'
|
||||
? getQuestListComponents(userQuests)
|
||||
: getAvailableQuestsComponents(availableQuests);
|
||||
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
|
||||
const totalItems = viewType === 'active' ? activeQuests.length : availableQuests.length;
|
||||
|
||||
const actionRows = getQuestActionRows(viewType);
|
||||
const containers = viewType === 'active'
|
||||
? getQuestListComponents(userQuests, page)
|
||||
: getAvailableQuestsComponents(availableQuests, page);
|
||||
|
||||
const actionRows = getQuestActionRows(viewType, totalItems, page);
|
||||
|
||||
await interaction.editReply({
|
||||
content: null,
|
||||
@@ -48,13 +57,19 @@ export const quests = createCommand({
|
||||
if (i.user.id !== interaction.user.id) return;
|
||||
|
||||
try {
|
||||
if (i.customId === "quest_view_active") {
|
||||
if (i.customId === QUEST_CUSTOM_IDS.VIEW_ACTIVE) {
|
||||
await i.deferUpdate();
|
||||
await updateView('active');
|
||||
} else if (i.customId === "quest_view_available") {
|
||||
await updateView('active', 0);
|
||||
} else if (i.customId === QUEST_CUSTOM_IDS.VIEW_AVAILABLE) {
|
||||
await i.deferUpdate();
|
||||
await updateView('available');
|
||||
} else if (i.customId.startsWith("quest_accept:")) {
|
||||
await updateView('available', 0);
|
||||
} else if (i.customId === QUEST_CUSTOM_IDS.PAGE_PREV) {
|
||||
await i.deferUpdate();
|
||||
await updateView(currentView, Math.max(0, currentPage - 1));
|
||||
} else if (i.customId === QUEST_CUSTOM_IDS.PAGE_NEXT) {
|
||||
await i.deferUpdate();
|
||||
await updateView(currentView, currentPage + 1);
|
||||
} else if (i.customId.startsWith(QUEST_CUSTOM_IDS.ACCEPT_PREFIX)) {
|
||||
const questIdStr = i.customId.split(":")[1];
|
||||
if (!questIdStr) return;
|
||||
const questId = parseInt(questIdStr);
|
||||
@@ -65,7 +80,8 @@ export const quests = createCommand({
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
|
||||
await updateView('active');
|
||||
// Stay on current view/page but refresh (accepted quest disappears from available)
|
||||
await updateView(currentView, currentPage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Quest interaction error:", error);
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { Events } from "discord.js";
|
||||
import type { Event } from "@shared/lib/types";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
|
||||
// Visitor role
|
||||
const event: Event<Events.GuildMemberAdd> = {
|
||||
name: Events.GuildMemberAdd,
|
||||
execute: async (member) => {
|
||||
console.log(`👤 New member joined: ${member.user.tag} (${member.id})`);
|
||||
|
||||
const guildConfig = await getGuildConfig(member.guild.id);
|
||||
|
||||
try {
|
||||
const user = await userService.getUserById(member.id);
|
||||
|
||||
if (user && user.class) {
|
||||
console.log(`🔄 Returning student detected: ${member.user.tag}`);
|
||||
await member.roles.remove(config.visitorRole);
|
||||
await member.roles.add(config.studentRole);
|
||||
if (guildConfig.visitorRole) {
|
||||
await member.roles.remove(guildConfig.visitorRole);
|
||||
}
|
||||
if (guildConfig.studentRole) {
|
||||
await member.roles.add(guildConfig.studentRole);
|
||||
}
|
||||
|
||||
if (user.class.roleId) {
|
||||
await member.roles.add(user.class.roleId);
|
||||
@@ -22,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) {
|
||||
|
||||
@@ -15,7 +15,7 @@ const event: Event<Events.MessageCreate> = {
|
||||
levelingService.processChatXp(message.author.id);
|
||||
|
||||
// Activity Tracking for Lootdrops
|
||||
import("@shared/modules/economy/lootdrop.service").then(m => m.lootdropService.processMessage(message));
|
||||
import("@modules/economy/lootdrop.handler").then(m => m.processLootdropMessage(message));
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
15
bot/index.ts
15
bot/index.ts
@@ -1,8 +1,14 @@
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { env } from "@shared/lib/env";
|
||||
import { join } from "node:path";
|
||||
import { initializeConfig } from "@shared/lib/config";
|
||||
import { registerDomainEventListeners } from "@shared/lib/eventWiring";
|
||||
import { createWebServer } from "../api/src/server";
|
||||
|
||||
import { startWebServerFromRoot } from "../web/src/server";
|
||||
// Initialize config from database
|
||||
await initializeConfig();
|
||||
|
||||
// Register domain event listeners before loading commands/events
|
||||
registerDomainEventListeners();
|
||||
|
||||
// Load commands & events
|
||||
await AuroraClient.loadCommands();
|
||||
@@ -14,12 +20,11 @@ console.log("🌐 Starting web server...");
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
const webProjectPath = join(import.meta.dir, "../web");
|
||||
const webPort = Number(process.env.WEB_PORT) || 3000;
|
||||
const webHost = process.env.HOST || "0.0.0.0";
|
||||
|
||||
// Start web server in the same process
|
||||
const webServer = await startWebServerFromRoot(webProjectPath, {
|
||||
const webServer = await createWebServer({
|
||||
port: webPort,
|
||||
hostname: webHost,
|
||||
});
|
||||
@@ -46,4 +51,4 @@ const shutdownHandler = async () => {
|
||||
};
|
||||
|
||||
process.on("SIGINT", shutdownHandler);
|
||||
process.on("SIGTERM", shutdownHandler);
|
||||
process.on("SIGTERM", shutdownHandler);
|
||||
|
||||
147
bot/lib/commandUtils.test.ts
Normal file
147
bot/lib/commandUtils.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, mock, beforeEach, spyOn } from "bun:test";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
const mockDeferReply = mock(() => Promise.resolve());
|
||||
const mockEditReply = mock(() => Promise.resolve());
|
||||
|
||||
const mockInteraction = {
|
||||
deferReply: mockDeferReply,
|
||||
editReply: mockEditReply,
|
||||
} as any;
|
||||
|
||||
const mockCreateErrorEmbed = mock((msg: string) => ({ description: msg, type: "error" }));
|
||||
|
||||
mock.module("./embeds", () => ({
|
||||
createErrorEmbed: mockCreateErrorEmbed,
|
||||
}));
|
||||
|
||||
// Import AFTER mocking
|
||||
const { withCommandErrorHandling } = await import("./commandUtils");
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe("withCommandErrorHandling", () => {
|
||||
let consoleErrorSpy: ReturnType<typeof spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockDeferReply.mockClear();
|
||||
mockEditReply.mockClear();
|
||||
mockCreateErrorEmbed.mockClear();
|
||||
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => { });
|
||||
});
|
||||
|
||||
it("should always call deferReply", async () => {
|
||||
await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => "result"
|
||||
);
|
||||
|
||||
expect(mockDeferReply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should pass ephemeral option to deferReply", async () => {
|
||||
await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => "result",
|
||||
{ ephemeral: true }
|
||||
);
|
||||
|
||||
expect(mockDeferReply).toHaveBeenCalledWith({ ephemeral: true });
|
||||
});
|
||||
|
||||
it("should return the operation result on success", async () => {
|
||||
const result = await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => ({ data: "test" })
|
||||
);
|
||||
|
||||
expect(result).toEqual({ data: "test" });
|
||||
});
|
||||
|
||||
it("should call onSuccess with the result", async () => {
|
||||
const onSuccess = mock(async (_result: string) => { });
|
||||
|
||||
await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => "hello",
|
||||
{ onSuccess }
|
||||
);
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("should send successMessage when no onSuccess is provided", async () => {
|
||||
await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => "result",
|
||||
{ successMessage: "It worked!" }
|
||||
);
|
||||
|
||||
expect(mockEditReply).toHaveBeenCalledWith({
|
||||
content: "It worked!",
|
||||
});
|
||||
});
|
||||
|
||||
it("should prefer onSuccess over successMessage", async () => {
|
||||
const onSuccess = mock(async (_result: string) => { });
|
||||
|
||||
await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => "result",
|
||||
{ successMessage: "This should not be sent", onSuccess }
|
||||
);
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1);
|
||||
// editReply should NOT have been called with the successMessage
|
||||
expect(mockEditReply).not.toHaveBeenCalledWith({
|
||||
content: "This should not be sent",
|
||||
});
|
||||
});
|
||||
|
||||
it("should show error embed for UserError", async () => {
|
||||
const result = await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => {
|
||||
throw new UserError("You can't do that!");
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockCreateErrorEmbed).toHaveBeenCalledWith("You can't do that!");
|
||||
expect(mockEditReply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should show generic error and log for unexpected errors", async () => {
|
||||
const unexpectedError = new Error("Database exploded");
|
||||
|
||||
const result = await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => {
|
||||
throw unexpectedError;
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Unexpected error in command:",
|
||||
unexpectedError
|
||||
);
|
||||
expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
|
||||
"An unexpected error occurred."
|
||||
);
|
||||
expect(mockEditReply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should return undefined on error", async () => {
|
||||
const result = await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => {
|
||||
throw new Error("fail");
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
79
bot/lib/commandUtils.ts
Normal file
79
bot/lib/commandUtils.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { ChatInputCommandInteraction } from "discord.js";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { createErrorEmbed } from "./embeds";
|
||||
|
||||
/**
|
||||
* Wraps a command's core logic with standardized error handling.
|
||||
*
|
||||
* - Calls `interaction.deferReply()` automatically
|
||||
* - On success, invokes `onSuccess` callback or sends `successMessage`
|
||||
* - On `UserError`, shows the error message in an error embed
|
||||
* - On unexpected errors, logs to console and shows a generic error embed
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* export const myCommand = createCommand({
|
||||
* execute: async (interaction) => {
|
||||
* await withCommandErrorHandling(
|
||||
* interaction,
|
||||
* async () => {
|
||||
* const result = await doSomething();
|
||||
* await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
||||
* }
|
||||
* );
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // With deferReply options (e.g. ephemeral)
|
||||
* await withCommandErrorHandling(
|
||||
* interaction,
|
||||
* async () => doSomething(),
|
||||
* {
|
||||
* ephemeral: true,
|
||||
* successMessage: "Done!",
|
||||
* }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export async function withCommandErrorHandling<T>(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
operation: () => Promise<T>,
|
||||
options?: {
|
||||
/** Message to send on success (if no onSuccess callback is provided) */
|
||||
successMessage?: string;
|
||||
/** Callback invoked with the operation result on success */
|
||||
onSuccess?: (result: T) => Promise<void>;
|
||||
/** Whether the deferred reply should be ephemeral */
|
||||
ephemeral?: boolean;
|
||||
}
|
||||
): Promise<T | undefined> {
|
||||
try {
|
||||
await interaction.deferReply({ ephemeral: options?.ephemeral });
|
||||
const result = await operation();
|
||||
|
||||
if (options?.onSuccess) {
|
||||
await options.onSuccess(result);
|
||||
} else if (options?.successMessage) {
|
||||
await interaction.editReply({
|
||||
content: options.successMessage,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(error.message)],
|
||||
});
|
||||
} else {
|
||||
console.error("Unexpected error in command:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("An unexpected error occurred.")],
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||
|
||||
// Mock DrizzleClient
|
||||
mock.module("./DrizzleClient", () => ({
|
||||
// Mock DrizzleClient — must match the import path used in db.ts
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: {
|
||||
transaction: async (cb: any) => cb("MOCK_TX")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
|
||||
@@ -25,6 +26,37 @@ export class CommandHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check beta feature access
|
||||
if (command.beta) {
|
||||
const flagName = command.featureFlag || interaction.commandName;
|
||||
let memberRoles: string[] = [];
|
||||
|
||||
if (interaction.member && 'roles' in interaction.member) {
|
||||
const roles = interaction.member.roles;
|
||||
if (typeof roles === 'object' && 'cache' in roles) {
|
||||
memberRoles = [...roles.cache.keys()];
|
||||
} else if (Array.isArray(roles)) {
|
||||
memberRoles = roles;
|
||||
}
|
||||
}
|
||||
|
||||
const hasAccess = await featureFlagsService.hasAccess(flagName, {
|
||||
guildId: interaction.guildId!,
|
||||
userId: interaction.user.id,
|
||||
memberRoles,
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
const errorEmbed = createErrorEmbed(
|
||||
"This feature is currently in beta testing and not available to all users. " +
|
||||
"Stay tuned for the official release!",
|
||||
"Beta Feature"
|
||||
);
|
||||
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure user exists in database
|
||||
try {
|
||||
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
|
||||
import { TRADE_CUSTOM_IDS } from "@modules/trade/trade.types";
|
||||
import { SHOP_CUSTOM_IDS, LOOTDROP_CUSTOM_IDS } from "@modules/economy/economy.types";
|
||||
import { ITEM_WIZARD_CUSTOM_IDS } from "@modules/admin/item_wizard.types";
|
||||
import { TRIVIA_CUSTOM_IDS } from "@modules/trivia/trivia.types";
|
||||
import { ENROLLMENT_CUSTOM_IDS } from "@modules/user/user.types";
|
||||
import { FEEDBACK_CUSTOM_IDS } from "@modules/feedback/feedback.types";
|
||||
|
||||
// Union type for all component interactions
|
||||
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
||||
|
||||
// Type for the handler function that modules export
|
||||
type InteractionHandler = (interaction: ComponentInteraction) => Promise<void>;
|
||||
|
||||
// Type for the dynamically imported module containing the handler
|
||||
interface InteractionModule {
|
||||
[key: string]: (...args: any[]) => Promise<void> | any;
|
||||
@@ -21,45 +24,45 @@ interface InteractionRoute {
|
||||
export const interactionRoutes: InteractionRoute[] = [
|
||||
// --- TRADE MODULE ---
|
||||
{
|
||||
predicate: (i) => i.customId.startsWith("trade_") || i.customId === "amount",
|
||||
predicate: (i) => i.customId.startsWith(TRADE_CUSTOM_IDS.PREFIX) || i.customId === TRADE_CUSTOM_IDS.MONEY_AMOUNT_FIELD,
|
||||
handler: () => import("@/modules/trade/trade.interaction"),
|
||||
method: 'handleTradeInteraction'
|
||||
},
|
||||
|
||||
// --- ECONOMY MODULE ---
|
||||
{
|
||||
predicate: (i) => i.isButton() && i.customId.startsWith("shop_buy_"),
|
||||
predicate: (i) => i.isButton() && i.customId.startsWith(SHOP_CUSTOM_IDS.BUY_PREFIX),
|
||||
handler: () => import("@/modules/economy/shop.interaction"),
|
||||
method: 'handleShopInteraction'
|
||||
},
|
||||
{
|
||||
predicate: (i) => i.isButton() && i.customId.startsWith("lootdrop_"),
|
||||
predicate: (i) => i.isButton() && i.customId.startsWith(LOOTDROP_CUSTOM_IDS.PREFIX),
|
||||
handler: () => import("@/modules/economy/lootdrop.interaction"),
|
||||
method: 'handleLootdropInteraction'
|
||||
},
|
||||
{
|
||||
predicate: (i) => i.isButton() && i.customId.startsWith("trivia_"),
|
||||
predicate: (i) => i.isButton() && i.customId.startsWith(TRIVIA_CUSTOM_IDS.PREFIX),
|
||||
handler: () => import("@/modules/trivia/trivia.interaction"),
|
||||
method: 'handleTriviaInteraction'
|
||||
},
|
||||
|
||||
// --- ADMIN MODULE ---
|
||||
{
|
||||
predicate: (i) => i.customId.startsWith("createitem_"),
|
||||
predicate: (i) => i.customId.startsWith(ITEM_WIZARD_CUSTOM_IDS.PREFIX),
|
||||
handler: () => import("@/modules/admin/item_wizard"),
|
||||
method: 'handleItemWizardInteraction'
|
||||
},
|
||||
|
||||
// --- USER MODULE ---
|
||||
{
|
||||
predicate: (i) => i.isButton() && i.customId === "enrollment",
|
||||
predicate: (i) => i.isButton() && i.customId === ENROLLMENT_CUSTOM_IDS.ENROLL,
|
||||
handler: () => import("@/modules/user/enrollment.interaction"),
|
||||
method: 'handleEnrollmentInteraction'
|
||||
},
|
||||
|
||||
// --- FEEDBACK MODULE ---
|
||||
{
|
||||
predicate: (i) => i.customId.startsWith("feedback_"),
|
||||
predicate: (i) => i.customId.startsWith(FEEDBACK_CUSTOM_IDS.PREFIX),
|
||||
handler: () => import("@/modules/feedback/feedback.interaction"),
|
||||
method: 'handleFeedbackInteraction'
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { items } from "@db/schema";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import type { ItemUsageData, ItemEffect } from "@shared/lib/types";
|
||||
import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
|
||||
import type { DraftItem } from "./item_wizard.types";
|
||||
import { ITEM_WIZARD_CUSTOM_IDS, type DraftItem } from "./item_wizard.types";
|
||||
import { ItemType, EffectType } from "@shared/lib/constants";
|
||||
|
||||
// --- Types ---
|
||||
@@ -41,13 +41,13 @@ export const renderWizard = (userId: string, isDraft = true) => {
|
||||
export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
// Only handle createitem interactions
|
||||
if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return;
|
||||
if (!interaction.customId.startsWith("createitem_")) return;
|
||||
if (!interaction.customId.startsWith(ITEM_WIZARD_CUSTOM_IDS.PREFIX)) return;
|
||||
|
||||
const userId = interaction.user.id;
|
||||
let draft = draftSession.get(userId);
|
||||
|
||||
// Special case for Cancel - doesn't need draft checks usually, but we want to clear it
|
||||
if (interaction.customId === "createitem_cancel") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.CANCEL) {
|
||||
draftSession.delete(userId);
|
||||
if (interaction.isMessageComponent()) {
|
||||
await interaction.update({ content: "❌ Item creation cancelled.", embeds: [], components: [] });
|
||||
@@ -59,7 +59,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
if (!draft) {
|
||||
if (interaction.isMessageComponent()) {
|
||||
// Create one implicitly to prevent crashes, or warn user
|
||||
if (interaction.customId === "createitem_start") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.START) {
|
||||
// Allow start
|
||||
} else {
|
||||
await interaction.reply({ content: "⚠️ Session expired. Please run `/createitem` again.", ephemeral: true });
|
||||
@@ -81,7 +81,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
// --- Routing ---
|
||||
|
||||
// 1. Details Modal
|
||||
if (interaction.customId === "createitem_details") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.DETAILS) {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = getDetailsModal(draft);
|
||||
await interaction.showModal(modal);
|
||||
@@ -89,7 +89,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
}
|
||||
|
||||
// 2. Economy Modal
|
||||
if (interaction.customId === "createitem_economy") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.ECONOMY) {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = getEconomyModal(draft);
|
||||
await interaction.showModal(modal);
|
||||
@@ -97,7 +97,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
}
|
||||
|
||||
// 3. Visuals Modal
|
||||
if (interaction.customId === "createitem_visuals") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.VISUALS) {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = getVisualsModal(draft);
|
||||
await interaction.showModal(modal);
|
||||
@@ -105,14 +105,14 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
}
|
||||
|
||||
// 4. Type Toggle (Start Select Menu)
|
||||
if (interaction.customId === "createitem_type_toggle") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.TYPE_TOGGLE) {
|
||||
if (!interaction.isButton()) return;
|
||||
const { components } = getItemTypeSelection();
|
||||
await interaction.update({ components }); // Temporary view
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.customId === "createitem_select_type") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.SELECT_TYPE) {
|
||||
if (!interaction.isStringSelectMenu()) return;
|
||||
const selected = interaction.values[0];
|
||||
if (selected) {
|
||||
@@ -125,14 +125,14 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
}
|
||||
|
||||
// 5. Add Effect Flow
|
||||
if (interaction.customId === "createitem_addeffect_start") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.ADD_EFFECT_START) {
|
||||
if (!interaction.isButton()) return;
|
||||
const { components } = getEffectTypeSelection();
|
||||
await interaction.update({ components });
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.customId === "createitem_select_effect_type") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.SELECT_EFFECT_TYPE) {
|
||||
if (!interaction.isStringSelectMenu()) return;
|
||||
const effectType = interaction.values[0];
|
||||
if (!effectType) return;
|
||||
@@ -149,7 +149,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
}
|
||||
|
||||
// Toggle Consume
|
||||
if (interaction.customId === "createitem_toggle_consume") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.TOGGLE_CONSUME) {
|
||||
if (!interaction.isButton()) return;
|
||||
draft.usageData.consume = !draft.usageData.consume;
|
||||
const payload = renderWizard(userId);
|
||||
@@ -159,43 +159,43 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
|
||||
// 6. Handle Modal Submits
|
||||
if (interaction.isModalSubmit()) {
|
||||
if (interaction.customId === "createitem_modal_details") {
|
||||
draft.name = interaction.fields.getTextInputValue("name");
|
||||
draft.description = interaction.fields.getTextInputValue("desc");
|
||||
draft.rarity = interaction.fields.getTextInputValue("rarity");
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_DETAILS) {
|
||||
draft.name = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_NAME);
|
||||
draft.description = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_DESC);
|
||||
draft.rarity = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_RARITY);
|
||||
}
|
||||
else if (interaction.customId === "createitem_modal_economy") {
|
||||
const price = parseInt(interaction.fields.getTextInputValue("price"));
|
||||
else if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_ECONOMY) {
|
||||
const price = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_PRICE));
|
||||
draft.price = isNaN(price) || price === 0 ? null : price;
|
||||
}
|
||||
else if (interaction.customId === "createitem_modal_visuals") {
|
||||
draft.iconUrl = interaction.fields.getTextInputValue("icon");
|
||||
draft.imageUrl = interaction.fields.getTextInputValue("image");
|
||||
else if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_VISUALS) {
|
||||
draft.iconUrl = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_ICON);
|
||||
draft.imageUrl = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_IMAGE);
|
||||
}
|
||||
else if (interaction.customId === "createitem_modal_effect") {
|
||||
else if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_EFFECT) {
|
||||
const type = draft.pendingEffectType;
|
||||
if (type) {
|
||||
let effect: ItemEffect | null = null;
|
||||
|
||||
if (type === EffectType.ADD_XP || type === EffectType.ADD_BALANCE) {
|
||||
const amount = parseInt(interaction.fields.getTextInputValue("amount"));
|
||||
const amount = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_AMOUNT));
|
||||
if (!isNaN(amount)) effect = { type: type as any, amount };
|
||||
}
|
||||
else if (type === EffectType.REPLY_MESSAGE) {
|
||||
effect = { type: EffectType.REPLY_MESSAGE, message: interaction.fields.getTextInputValue("message") };
|
||||
effect = { type: EffectType.REPLY_MESSAGE, message: interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_MESSAGE) };
|
||||
}
|
||||
else if (type === EffectType.XP_BOOST) {
|
||||
const multiplier = parseFloat(interaction.fields.getTextInputValue("multiplier"));
|
||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
||||
const multiplier = parseFloat(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_MULTIPLIER));
|
||||
const duration = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_DURATION));
|
||||
if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: EffectType.XP_BOOST, multiplier, durationSeconds: duration };
|
||||
}
|
||||
else if (type === EffectType.TEMP_ROLE) {
|
||||
const roleId = interaction.fields.getTextInputValue("role_id");
|
||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
||||
const roleId = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID);
|
||||
const duration = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_DURATION));
|
||||
if (roleId && !isNaN(duration)) effect = { type: EffectType.TEMP_ROLE, roleId: roleId, durationSeconds: duration };
|
||||
}
|
||||
else if (type === EffectType.COLOR_ROLE) {
|
||||
const roleId = interaction.fields.getTextInputValue("role_id");
|
||||
const roleId = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID);
|
||||
if (roleId) effect = { type: EffectType.COLOR_ROLE, roleId: roleId };
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
}
|
||||
|
||||
// 7. Save
|
||||
if (interaction.customId === "createitem_save") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.SAVE) {
|
||||
if (!interaction.isButton()) return;
|
||||
|
||||
await interaction.deferUpdate(); // Prepare to save
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
import type { ItemUsageData } from "@shared/lib/types";
|
||||
|
||||
export const ITEM_WIZARD_CUSTOM_IDS = {
|
||||
PREFIX: "createitem_",
|
||||
START: "createitem_start",
|
||||
DETAILS: "createitem_details",
|
||||
ECONOMY: "createitem_economy",
|
||||
VISUALS: "createitem_visuals",
|
||||
TYPE_TOGGLE: "createitem_type_toggle",
|
||||
SELECT_TYPE: "createitem_select_type",
|
||||
ADD_EFFECT_START: "createitem_addeffect_start",
|
||||
SELECT_EFFECT_TYPE: "createitem_select_effect_type",
|
||||
TOGGLE_CONSUME: "createitem_toggle_consume",
|
||||
SAVE: "createitem_save",
|
||||
CANCEL: "createitem_cancel",
|
||||
MODAL_DETAILS: "createitem_modal_details",
|
||||
MODAL_ECONOMY: "createitem_modal_economy",
|
||||
MODAL_VISUALS: "createitem_modal_visuals",
|
||||
MODAL_EFFECT: "createitem_modal_effect",
|
||||
// Modal field IDs
|
||||
FIELD_NAME: "name",
|
||||
FIELD_DESC: "desc",
|
||||
FIELD_RARITY: "rarity",
|
||||
FIELD_PRICE: "price",
|
||||
FIELD_ICON: "icon",
|
||||
FIELD_IMAGE: "image",
|
||||
FIELD_AMOUNT: "amount",
|
||||
FIELD_MESSAGE: "message",
|
||||
FIELD_MULTIPLIER: "multiplier",
|
||||
FIELD_DURATION: "duration",
|
||||
FIELD_ROLE_ID: "role_id",
|
||||
} as const;
|
||||
|
||||
export interface DraftItem {
|
||||
name: string;
|
||||
description: string;
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type MessageActionRowComponentBuilder
|
||||
} from "discord.js";
|
||||
import { createBaseEmbed } from "@lib/embeds";
|
||||
import type { DraftItem } from "./item_wizard.types";
|
||||
import { ITEM_WIZARD_CUSTOM_IDS, type DraftItem } from "./item_wizard.types";
|
||||
import { ItemType } from "@shared/lib/constants";
|
||||
|
||||
const getItemTypeOptions = () => [
|
||||
@@ -51,18 +51,18 @@ export const getItemWizardEmbed = (draft: DraftItem) => {
|
||||
// Components
|
||||
const row1 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder().setCustomId("createitem_details").setLabel("Edit Details").setStyle(ButtonStyle.Secondary).setEmoji("📝"),
|
||||
new ButtonBuilder().setCustomId("createitem_economy").setLabel("Edit Economy").setStyle(ButtonStyle.Secondary).setEmoji("💰"),
|
||||
new ButtonBuilder().setCustomId("createitem_visuals").setLabel("Edit Visuals").setStyle(ButtonStyle.Secondary).setEmoji("🖼️"),
|
||||
new ButtonBuilder().setCustomId("createitem_type_toggle").setLabel("Change Type").setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
|
||||
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.DETAILS).setLabel("Edit Details").setStyle(ButtonStyle.Secondary).setEmoji("📝"),
|
||||
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.ECONOMY).setLabel("Edit Economy").setStyle(ButtonStyle.Secondary).setEmoji("💰"),
|
||||
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.VISUALS).setLabel("Edit Visuals").setStyle(ButtonStyle.Secondary).setEmoji("🖼️"),
|
||||
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.TYPE_TOGGLE).setLabel("Change Type").setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
|
||||
);
|
||||
|
||||
const row2 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder().setCustomId("createitem_addeffect_start").setLabel("Add Effect").setStyle(ButtonStyle.Primary).setEmoji("✨"),
|
||||
new ButtonBuilder().setCustomId("createitem_toggle_consume").setLabel(`Consume: ${draft.usageData.consume ? "ON" : "OFF"}`).setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
|
||||
new ButtonBuilder().setCustomId("createitem_save").setLabel("Save Item").setStyle(ButtonStyle.Success).setEmoji("💾"),
|
||||
new ButtonBuilder().setCustomId("createitem_cancel").setLabel("Cancel").setStyle(ButtonStyle.Danger).setEmoji("✖️")
|
||||
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.ADD_EFFECT_START).setLabel("Add Effect").setStyle(ButtonStyle.Primary).setEmoji("✨"),
|
||||
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.TOGGLE_CONSUME).setLabel(`Consume: ${draft.usageData.consume ? "ON" : "OFF"}`).setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
|
||||
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.SAVE).setLabel("Save Item").setStyle(ButtonStyle.Success).setEmoji("💾"),
|
||||
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.CANCEL).setLabel("Cancel").setStyle(ButtonStyle.Danger).setEmoji("✖️")
|
||||
);
|
||||
|
||||
return { embeds: [embed], components: [row1, row2] };
|
||||
@@ -70,65 +70,65 @@ export const getItemWizardEmbed = (draft: DraftItem) => {
|
||||
|
||||
export const getItemTypeSelection = () => {
|
||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||
new StringSelectMenuBuilder().setCustomId("createitem_select_type").setPlaceholder("Select Item Type").addOptions(getItemTypeOptions())
|
||||
new StringSelectMenuBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.SELECT_TYPE).setPlaceholder("Select Item Type").addOptions(getItemTypeOptions())
|
||||
);
|
||||
return { components: [row] };
|
||||
};
|
||||
|
||||
export const getEffectTypeSelection = () => {
|
||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||
new StringSelectMenuBuilder().setCustomId("createitem_select_effect_type").setPlaceholder("Select Effect Type").addOptions(getEffectTypeOptions())
|
||||
new StringSelectMenuBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.SELECT_EFFECT_TYPE).setPlaceholder("Select Effect Type").addOptions(getEffectTypeOptions())
|
||||
);
|
||||
return { components: [row] };
|
||||
};
|
||||
|
||||
export const getDetailsModal = (current: DraftItem) => {
|
||||
const modal = new ModalBuilder().setCustomId("createitem_modal_details").setTitle("Edit Details");
|
||||
const modal = new ModalBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.MODAL_DETAILS).setTitle("Edit Details");
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("name").setLabel("Name").setValue(current.name).setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("desc").setLabel("Description").setValue(current.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("C, R, SR, SSR").setRequired(true))
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_NAME).setLabel("Name").setValue(current.name).setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_DESC).setLabel("Description").setValue(current.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_RARITY).setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("C, R, SR, SSR").setRequired(true))
|
||||
);
|
||||
return modal;
|
||||
};
|
||||
|
||||
export const getEconomyModal = (current: DraftItem) => {
|
||||
const modal = new ModalBuilder().setCustomId("createitem_modal_economy").setTitle("Edit Economy");
|
||||
const modal = new ModalBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.MODAL_ECONOMY).setTitle("Edit Economy");
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("price").setLabel("Price (0 for not for sale)").setValue(current.price?.toString() || "0").setStyle(TextInputStyle.Short).setRequired(true))
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_PRICE).setLabel("Price (0 for not for sale)").setValue(current.price?.toString() || "0").setStyle(TextInputStyle.Short).setRequired(true))
|
||||
);
|
||||
return modal;
|
||||
};
|
||||
|
||||
export const getVisualsModal = (current: DraftItem) => {
|
||||
const modal = new ModalBuilder().setCustomId("createitem_modal_visuals").setTitle("Edit Visuals");
|
||||
const modal = new ModalBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.MODAL_VISUALS).setTitle("Edit Visuals");
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("icon").setLabel("Icon URL (Emoji or Link)").setValue(current.iconUrl).setStyle(TextInputStyle.Short).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("image").setLabel("Image URL").setValue(current.imageUrl).setStyle(TextInputStyle.Short).setRequired(false))
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_ICON).setLabel("Icon URL (Emoji or Link)").setValue(current.iconUrl).setStyle(TextInputStyle.Short).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_IMAGE).setLabel("Image URL").setValue(current.imageUrl).setStyle(TextInputStyle.Short).setRequired(false))
|
||||
);
|
||||
return modal;
|
||||
};
|
||||
|
||||
export const getEffectConfigModal = (effectType: string) => {
|
||||
let modal = new ModalBuilder().setCustomId("createitem_modal_effect").setTitle(`Config ${effectType}`);
|
||||
let modal = new ModalBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.MODAL_EFFECT).setTitle(`Config ${effectType}`);
|
||||
|
||||
if (effectType === "ADD_XP" || effectType === "ADD_BALANCE") {
|
||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("amount").setLabel("Amount").setStyle(TextInputStyle.Short).setRequired(true).setPlaceholder("100")));
|
||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_AMOUNT).setLabel("Amount").setStyle(TextInputStyle.Short).setRequired(true).setPlaceholder("100")));
|
||||
} else if (effectType === "REPLY_MESSAGE") {
|
||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("message").setLabel("Message").setStyle(TextInputStyle.Paragraph).setRequired(true)));
|
||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_MESSAGE).setLabel("Message").setStyle(TextInputStyle.Paragraph).setRequired(true)));
|
||||
} else if (effectType === "XP_BOOST") {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("multiplier").setLabel("Multiplier (e.g. 1.5)").setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_MULTIPLIER).setLabel("Multiplier (e.g. 1.5)").setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_DURATION).setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||
);
|
||||
} else if (effectType === "TEMP_ROLE") {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID).setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_DURATION).setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||
);
|
||||
} else if (effectType === "COLOR_ROLE") {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true))
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID).setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true))
|
||||
);
|
||||
}
|
||||
return modal;
|
||||
|
||||
10
bot/modules/economy/economy.types.ts
Normal file
10
bot/modules/economy/economy.types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const LOOTDROP_CUSTOM_IDS = {
|
||||
PREFIX: "lootdrop_",
|
||||
CLAIM: "lootdrop_claim",
|
||||
CLAIM_DISABLED: "lootdrop_claim_disabled",
|
||||
} as const;
|
||||
|
||||
export const SHOP_CUSTOM_IDS = {
|
||||
BUY_PREFIX: "shop_buy_",
|
||||
BUY: (itemId: number) => `shop_buy_${itemId}`,
|
||||
} as const;
|
||||
60
bot/modules/economy/lootdrop.handler.ts
Normal file
60
bot/modules/economy/lootdrop.handler.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Message, TextChannel } from "discord.js";
|
||||
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
|
||||
import { getLootdropMessage } from "./lootdrop.view";
|
||||
import { terminalService } from "@modules/system/terminal.service";
|
||||
|
||||
/**
|
||||
* Process a Discord message for lootdrop activity tracking.
|
||||
* Called from messageCreate event handler.
|
||||
*/
|
||||
export async function processLootdropMessage(message: Message): Promise<void> {
|
||||
if (message.author.bot || !message.guild) return;
|
||||
|
||||
const { shouldSpawn } = lootdropService.trackActivity(message.channel.id);
|
||||
|
||||
if (shouldSpawn) {
|
||||
await spawnLootdrop(message.channel as TextChannel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a lootdrop in a Discord channel.
|
||||
* Used by both bot events and API routes.
|
||||
*/
|
||||
export async function spawnLootdrop(
|
||||
channel: TextChannel,
|
||||
overrideReward?: number,
|
||||
overrideCurrency?: string
|
||||
): Promise<void> {
|
||||
const { reward, currency } = lootdropService.calculateReward(overrideReward, overrideCurrency);
|
||||
const { content, files, components } = await getLootdropMessage(reward, currency);
|
||||
|
||||
try {
|
||||
const sentMessage = await channel.send({ content, files, components });
|
||||
await lootdropService.persistLootdrop(sentMessage.id, channel.id, reward, currency);
|
||||
terminalService.update(channel.guildId);
|
||||
} catch (error) {
|
||||
console.error("Failed to spawn lootdrop:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a lootdrop from DB and Discord.
|
||||
*/
|
||||
export async function deleteLootdrop(messageId: string): Promise<boolean> {
|
||||
const result = await lootdropService.removeLootdrop(messageId);
|
||||
if (!result) return false;
|
||||
|
||||
try {
|
||||
const { AuroraClient } = await import("@/lib/BotClient");
|
||||
const channel = await AuroraClient.channels.fetch(result.channelId) as TextChannel;
|
||||
if (channel) {
|
||||
const message = await channel.messages.fetch(messageId);
|
||||
if (message) await message.delete();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Could not delete lootdrop message from Discord:", e);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import { ButtonInteraction } from "discord.js";
|
||||
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { getLootdropClaimedMessage } from "./lootdrop.view";
|
||||
import { terminalService } from "@modules/system/terminal.service";
|
||||
import { LOOTDROP_CUSTOM_IDS } from "./economy.types";
|
||||
|
||||
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
|
||||
if (interaction.customId === "lootdrop_claim") {
|
||||
if (interaction.customId === LOOTDROP_CUSTOM_IDS.CLAIM) {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
const result = await lootdropService.tryClaim(interaction.message.id, interaction.user.id, interaction.user.username);
|
||||
@@ -13,6 +15,9 @@ export async function handleLootdropInteraction(interaction: ButtonInteraction)
|
||||
throw new UserError(result.error || "Failed to claim.");
|
||||
}
|
||||
|
||||
// Update terminal display after successful claim
|
||||
terminalService.update();
|
||||
|
||||
await interaction.editReply({
|
||||
content: `🎉 You successfully claimed **${result.amount} ${result.currency}**!`
|
||||
});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
|
||||
import { generateLootdropCard, generateClaimedLootdropCard } from "@/graphics/lootdrop";
|
||||
import { LOOTDROP_CUSTOM_IDS } from "./economy.types";
|
||||
|
||||
export async function getLootdropMessage(reward: number, currency: string) {
|
||||
const cardBuffer = await generateLootdropCard(reward, currency);
|
||||
const attachment = new AttachmentBuilder(cardBuffer, { name: "lootdrop.png" });
|
||||
|
||||
const claimButton = new ButtonBuilder()
|
||||
.setCustomId("lootdrop_claim")
|
||||
.setCustomId(LOOTDROP_CUSTOM_IDS.CLAIM)
|
||||
.setLabel("CLAIM REWARD")
|
||||
.setStyle(ButtonStyle.Secondary) // Changed to Secondary to fit the darker theme better? Or keep Success? Let's try Secondary with custom emoji
|
||||
.setEmoji("🌠");
|
||||
@@ -28,7 +29,7 @@ export async function getLootdropClaimedMessage(userId: string, username: string
|
||||
const newRow = new ActionRowBuilder<ButtonBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId("lootdrop_claim_disabled")
|
||||
.setCustomId(LOOTDROP_CUSTOM_IDS.CLAIM_DISABLED)
|
||||
.setLabel("CLAIMED")
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji("✅")
|
||||
|
||||
@@ -2,13 +2,14 @@ import { ButtonInteraction, MessageFlags } from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { SHOP_CUSTOM_IDS } from "./economy.types";
|
||||
|
||||
export async function handleShopInteraction(interaction: ButtonInteraction) {
|
||||
if (!interaction.customId.startsWith("shop_buy_")) return;
|
||||
if (!interaction.customId.startsWith(SHOP_CUSTOM_IDS.BUY_PREFIX)) return;
|
||||
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const itemId = parseInt(interaction.customId.replace("shop_buy_", ""));
|
||||
const itemId = parseInt(interaction.customId.replace(SHOP_CUSTOM_IDS.BUY_PREFIX, ""));
|
||||
if (isNaN(itemId)) {
|
||||
throw new UserError("Invalid Item ID.");
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
AttachmentBuilder,
|
||||
Colors,
|
||||
ContainerBuilder,
|
||||
SectionBuilder,
|
||||
TextDisplayBuilder,
|
||||
@@ -19,27 +18,8 @@ import { join } from "path";
|
||||
import { existsSync } from "fs";
|
||||
import { LootType, EffectType } from "@shared/lib/constants";
|
||||
import type { LootTableItem } from "@shared/lib/types";
|
||||
|
||||
// Rarity Color Map
|
||||
const RarityColors: Record<string, number> = {
|
||||
"C": Colors.LightGrey,
|
||||
"R": Colors.Blue,
|
||||
"SR": Colors.Purple,
|
||||
"SSR": Colors.Gold,
|
||||
"CURRENCY": Colors.Green,
|
||||
"XP": Colors.Aqua,
|
||||
"NOTHING": Colors.DarkButNotBlack
|
||||
};
|
||||
|
||||
const TitleMap: Record<string, string> = {
|
||||
"C": "📦 Common Items",
|
||||
"R": "📦 Rare Items",
|
||||
"SR": "✨ Super Rare Items",
|
||||
"SSR": "🌟 SSR Items",
|
||||
"CURRENCY": "💰 Currency",
|
||||
"XP": "🔮 Experience",
|
||||
"NOTHING": "💨 Empty"
|
||||
};
|
||||
import { getRarityConfig, defaultName, stripQuery } from "@shared/lib/rarity";
|
||||
import { SHOP_CUSTOM_IDS } from "./economy.types";
|
||||
|
||||
export function getShopListingMessage(
|
||||
item: {
|
||||
@@ -61,7 +41,7 @@ export function getShopListingMessage(
|
||||
|
||||
// Handle local icon
|
||||
if (item.iconUrl && isLocalAssetUrl(item.iconUrl)) {
|
||||
const iconPath = join(process.cwd(), "bot/assets/graphics", item.iconUrl.replace(/^\/?assets\//, ""));
|
||||
const iconPath = join(process.cwd(), "bot/assets/graphics", stripQuery(item.iconUrl).replace(/^\/?assets\//, ""));
|
||||
if (existsSync(iconPath)) {
|
||||
const iconName = defaultName(item.iconUrl);
|
||||
files.push(new AttachmentBuilder(iconPath, { name: iconName }));
|
||||
@@ -74,7 +54,7 @@ export function getShopListingMessage(
|
||||
if (item.imageUrl === item.iconUrl && thumbnailUrl?.startsWith("attachment://")) {
|
||||
displayImageUrl = thumbnailUrl;
|
||||
} else {
|
||||
const imagePath = join(process.cwd(), "bot/assets/graphics", item.imageUrl.replace(/^\/?assets\//, ""));
|
||||
const imagePath = join(process.cwd(), "bot/assets/graphics", stripQuery(item.imageUrl).replace(/^\/?assets\//, ""));
|
||||
if (existsSync(imagePath)) {
|
||||
const imageName = defaultName(item.imageUrl);
|
||||
if (!files.find(f => f.name === imageName)) {
|
||||
@@ -89,7 +69,7 @@ export function getShopListingMessage(
|
||||
|
||||
// 1. Main Container
|
||||
const mainContainer = new ContainerBuilder()
|
||||
.setAccentColor(RarityColors[item.rarity || "C"] || Colors.Green);
|
||||
.setAccentColor(getRarityConfig(item.rarity || "C").color);
|
||||
|
||||
// Header Section
|
||||
const infoSection = new SectionBuilder()
|
||||
@@ -119,82 +99,114 @@ export function getShopListingMessage(
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Loot Table (if applicable)
|
||||
if (item.usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX)) {
|
||||
const lootboxEffect = item.usageData.effects.find((e: any) => e.type === EffectType.LOOTBOX);
|
||||
const pool = lootboxEffect.pool as LootTableItem[];
|
||||
const totalWeight = pool.reduce((sum, i) => sum + i.weight, 0);
|
||||
|
||||
mainContainer.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||
mainContainer.addTextDisplayComponents(new TextDisplayBuilder().setContent("## 🎁 Potential Rewards"));
|
||||
|
||||
const groups: Record<string, string[]> = {};
|
||||
for (const drop of pool) {
|
||||
const chance = ((drop.weight / totalWeight) * 100).toFixed(1);
|
||||
let line = "";
|
||||
let rarity = "C";
|
||||
|
||||
switch (drop.type as any) {
|
||||
case LootType.CURRENCY:
|
||||
const currAmount = (drop.minAmount != null && drop.maxAmount != null)
|
||||
? `${drop.minAmount} - ${drop.maxAmount}`
|
||||
: (Array.isArray(drop.amount) ? `${drop.amount[0]} - ${drop.amount[1]}` : drop.amount || 0);
|
||||
line = `**${currAmount} 🪙** (${chance}%)`;
|
||||
rarity = "CURRENCY";
|
||||
break;
|
||||
case LootType.XP:
|
||||
const xpAmount = (drop.minAmount != null && drop.maxAmount != null)
|
||||
? `${drop.minAmount} - ${drop.maxAmount}`
|
||||
: (Array.isArray(drop.amount) ? `${drop.amount[0]} - ${drop.amount[1]}` : drop.amount || 0);
|
||||
line = `**${xpAmount} XP** (${chance}%)`;
|
||||
rarity = "XP";
|
||||
break;
|
||||
case LootType.ITEM:
|
||||
const referencedItems = context?.referencedItems;
|
||||
if (drop.itemId && referencedItems?.has(drop.itemId)) {
|
||||
const i = referencedItems.get(drop.itemId)!;
|
||||
line = `**${i.name}** x${drop.amount || 1} (${chance}%)`;
|
||||
rarity = i.rarity;
|
||||
} else {
|
||||
line = `**Unknown Item** (${chance}%)`;
|
||||
rarity = "C";
|
||||
}
|
||||
break;
|
||||
case LootType.NOTHING:
|
||||
line = `**Nothing** (${chance}%)`;
|
||||
rarity = "NOTHING";
|
||||
break;
|
||||
}
|
||||
|
||||
if (line) {
|
||||
if (!groups[rarity]) groups[rarity] = [];
|
||||
groups[rarity]!.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
const order = ["SSR", "SR", "R", "C", "CURRENCY", "XP", "NOTHING"];
|
||||
for (const rarity of order) {
|
||||
if (groups[rarity] && groups[rarity]!.length > 0) {
|
||||
mainContainer.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`### ${TitleMap[rarity] || rarity}`),
|
||||
new TextDisplayBuilder().setContent(groups[rarity]!.join("\n"))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Purchase Row
|
||||
// Create buy button (used in either main or loot container)
|
||||
const buyButton = new ButtonBuilder()
|
||||
.setCustomId(`shop_buy_${item.id}`)
|
||||
.setCustomId(SHOP_CUSTOM_IDS.BUY(item.id))
|
||||
.setLabel(`Purchase for ${item.price} 🪙`)
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setEmoji("🛒");
|
||||
|
||||
mainContainer.addActionRowComponents(
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton)
|
||||
);
|
||||
// 2. Loot Table (if applicable) — separate Container with blurple accent
|
||||
const lootboxEffect = item.usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX);
|
||||
if (lootboxEffect) {
|
||||
const pool = lootboxEffect.pool as LootTableItem[];
|
||||
const totalWeight = pool.reduce((sum: number, i: LootTableItem) => sum + i.weight, 0);
|
||||
|
||||
containers.push(mainContainer);
|
||||
const lootContainer = new ContainerBuilder().setAccentColor(0x5865F2);
|
||||
lootContainer.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("## 🎁 Loot Table")
|
||||
);
|
||||
|
||||
// Group drops by rarity tier with aggregated percentages
|
||||
const tiers: Record<string, { items: string[]; totalChance: number }> = {};
|
||||
|
||||
for (const drop of pool) {
|
||||
const chance = (drop.weight / totalWeight) * 100;
|
||||
let line = "";
|
||||
let rarity = "C";
|
||||
|
||||
switch (drop.type as any) {
|
||||
case LootType.CURRENCY: {
|
||||
const amt = (drop.minAmount != null && drop.maxAmount != null)
|
||||
? `${drop.minAmount} – ${drop.maxAmount}`
|
||||
: (Array.isArray(drop.amount) ? `${drop.amount[0]} – ${drop.amount[1]}` : `${drop.amount || 0}`);
|
||||
line = `${amt} 🪙`;
|
||||
rarity = "CURRENCY";
|
||||
break;
|
||||
}
|
||||
case LootType.XP: {
|
||||
const amt = (drop.minAmount != null && drop.maxAmount != null)
|
||||
? `${drop.minAmount} – ${drop.maxAmount}`
|
||||
: (Array.isArray(drop.amount) ? `${drop.amount[0]} – ${drop.amount[1]}` : `${drop.amount || 0}`);
|
||||
line = `${amt} XP`;
|
||||
rarity = "XP";
|
||||
break;
|
||||
}
|
||||
case LootType.ITEM: {
|
||||
const referencedItems = context?.referencedItems;
|
||||
if (drop.itemId && referencedItems?.has(drop.itemId)) {
|
||||
const i = referencedItems.get(drop.itemId)!;
|
||||
line = `${i.name} ×${drop.amount || 1}`;
|
||||
rarity = i.rarity;
|
||||
} else {
|
||||
line = `Unknown Item`;
|
||||
rarity = "C";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case LootType.NOTHING: {
|
||||
line = "Nothing";
|
||||
rarity = "NOTHING";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (line) {
|
||||
if (!tiers[rarity]) tiers[rarity] = { items: [], totalChance: 0 };
|
||||
const tier = tiers[rarity]!;
|
||||
tier.items.push(line);
|
||||
tier.totalChance += chance;
|
||||
}
|
||||
}
|
||||
|
||||
const order = ["SSR", "SR", "R", "C", "CURRENCY", "XP", "NOTHING"];
|
||||
let isFirst = true;
|
||||
for (const rarity of order) {
|
||||
const tier = tiers[rarity];
|
||||
if (!tier || tier.items.length === 0) continue;
|
||||
|
||||
if (!isFirst) {
|
||||
lootContainer.addSeparatorComponents(
|
||||
new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)
|
||||
);
|
||||
}
|
||||
isFirst = false;
|
||||
|
||||
const config = getRarityConfig(rarity);
|
||||
const chanceStr = tier.totalChance < 0.1 ? "<0.1" : tier.totalChance.toFixed(1);
|
||||
lootContainer.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(
|
||||
`${config.emoji} **${config.label}** — ${chanceStr}%`
|
||||
),
|
||||
new TextDisplayBuilder().setContent(tier.items.join(", "))
|
||||
);
|
||||
}
|
||||
|
||||
// Purchase button inside loot table container
|
||||
lootContainer.addActionRowComponents(
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton)
|
||||
);
|
||||
|
||||
containers.push(mainContainer);
|
||||
containers.push(lootContainer);
|
||||
} else {
|
||||
// Non-lootbox items: purchase button stays in main container
|
||||
mainContainer.addActionRowComponents(
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton)
|
||||
);
|
||||
|
||||
containers.push(mainContainer);
|
||||
}
|
||||
|
||||
return {
|
||||
components: containers as any,
|
||||
@@ -202,7 +214,3 @@ export function getShopListingMessage(
|
||||
flags: MessageFlags.IsComponentsV2
|
||||
};
|
||||
}
|
||||
|
||||
function defaultName(path: string): string {
|
||||
return path.split("/").pop() || "image.png";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Interaction } from "discord.js";
|
||||
import { TextChannel, MessageFlags } from "discord.js";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
|
||||
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
|
||||
@@ -8,7 +8,7 @@ import { UserError } from "@shared/lib/errors";
|
||||
|
||||
export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
||||
// Handle select menu for choosing feedback type
|
||||
if (interaction.isStringSelectMenu() && interaction.customId === "feedback_select_type") {
|
||||
if (interaction.isStringSelectMenu() && interaction.customId === FEEDBACK_CUSTOM_IDS.SELECT_TYPE) {
|
||||
const feedbackType = interaction.values[0] as FeedbackType;
|
||||
|
||||
if (!feedbackType) {
|
||||
@@ -33,7 +33,13 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
||||
throw new UserError("An error occurred processing your feedback. Please try again.");
|
||||
}
|
||||
|
||||
if (!config.feedbackChannelId) {
|
||||
if (!interaction.guildId) {
|
||||
throw new UserError("This action can only be performed in a server.");
|
||||
}
|
||||
|
||||
const guildConfig = await getGuildConfig(interaction.guildId);
|
||||
|
||||
if (!guildConfig.feedbackChannelId) {
|
||||
throw new UserError("Feedback channel is not configured. Please contact an administrator.");
|
||||
}
|
||||
|
||||
@@ -52,7 +58,7 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
||||
};
|
||||
|
||||
// Get feedback channel
|
||||
const channel = await AuroraClient.channels.fetch(config.feedbackChannelId).catch(() => null) as TextChannel | null;
|
||||
const channel = await AuroraClient.channels.fetch(guildConfig.feedbackChannelId).catch(() => null) as TextChannel | null;
|
||||
|
||||
if (!channel) {
|
||||
throw new UserError("Feedback channel not found. Please contact an administrator.");
|
||||
|
||||
@@ -16,8 +16,10 @@ export const FEEDBACK_TYPE_LABELS: Record<FeedbackType, string> = {
|
||||
};
|
||||
|
||||
export const FEEDBACK_CUSTOM_IDS = {
|
||||
PREFIX: "feedback_",
|
||||
SELECT_TYPE: "feedback_select_type",
|
||||
MODAL: "feedback_modal",
|
||||
TYPE_FIELD: "feedback_type",
|
||||
TITLE_FIELD: "feedback_title",
|
||||
DESCRIPTION_FIELD: "feedback_description"
|
||||
DESCRIPTION_FIELD: "feedback_description",
|
||||
} as const;
|
||||
|
||||
@@ -14,7 +14,7 @@ import { FEEDBACK_TYPE_LABELS, FEEDBACK_CUSTOM_IDS, type FeedbackData, type Feed
|
||||
|
||||
export function getFeedbackTypeMenu() {
|
||||
const select = new StringSelectMenuBuilder()
|
||||
.setCustomId("feedback_select_type")
|
||||
.setCustomId(FEEDBACK_CUSTOM_IDS.SELECT_TYPE)
|
||||
.setPlaceholder("Choose feedback type")
|
||||
.addOptions([
|
||||
{
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import {
|
||||
handleAddXp,
|
||||
handleAddBalance,
|
||||
handleReplyMessage,
|
||||
handleXpBoost,
|
||||
handleTempRole,
|
||||
handleColorRole,
|
||||
handleLootbox
|
||||
} from "./effect.handlers";
|
||||
import type { EffectHandler } from "./effect.types";
|
||||
|
||||
export const effectHandlers: Record<string, EffectHandler> = {
|
||||
'ADD_XP': handleAddXp,
|
||||
'ADD_BALANCE': handleAddBalance,
|
||||
'REPLY_MESSAGE': handleReplyMessage,
|
||||
'XP_BOOST': handleXpBoost,
|
||||
'TEMP_ROLE': handleTempRole,
|
||||
'COLOR_ROLE': handleColorRole,
|
||||
'LOOTBOX': handleLootbox
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
import type { Transaction } from "@shared/lib/types";
|
||||
|
||||
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<any>;
|
||||
72
bot/modules/inventory/inventory.interaction.ts
Normal file
72
bot/modules/inventory/inventory.interaction.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { StringSelectMenuInteraction, ButtonInteraction, MessageFlags } from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { getLootboxResultMessage } from "./inventory.view";
|
||||
import type { ItemUsageData } from "@shared/lib/types";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
import { INVENTORY_CUSTOM_IDS } from "./inventory.types";
|
||||
|
||||
export interface InventoryState {
|
||||
ownerId: string;
|
||||
viewerId: string;
|
||||
page: number;
|
||||
selectedItemId: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the viewer user ID from an inventory custom ID.
|
||||
* Custom IDs follow the format: inv_{action}_{viewerId}
|
||||
*/
|
||||
export function parseInventoryCustomId(customId: string): { action: string; viewerId: string } | null {
|
||||
const match = customId.match(/^inv_(\w+?)_(\d+)$/);
|
||||
if (!match) return null;
|
||||
return { action: match[1]!, viewerId: match[2]! };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a custom ID belongs to the inventory system.
|
||||
*/
|
||||
export function isInventoryInteraction(customId: string): boolean {
|
||||
return customId.startsWith(INVENTORY_CUSTOM_IDS.PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the "Use" button — executes item effects.
|
||||
* Returns the result messages array from inventoryService.useItem,
|
||||
* plus handles role-based effects that require the guild member.
|
||||
*/
|
||||
export async function executeItemUse(
|
||||
interaction: ButtonInteraction,
|
||||
userId: string,
|
||||
itemId: number,
|
||||
): Promise<{ results: any[]; usageData: ItemUsageData | null; item: any }> {
|
||||
const result = await inventoryService.useItem(userId, itemId);
|
||||
|
||||
// Handle role effects (same logic as /use command)
|
||||
const usageData = result.usageData;
|
||||
if (usageData) {
|
||||
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||
const colorRoles = guildConfig.colorRoles ?? [];
|
||||
|
||||
for (const effect of usageData.effects) {
|
||||
if (effect.type === "TEMP_ROLE" || effect.type === "COLOR_ROLE") {
|
||||
try {
|
||||
const member = await interaction.guild?.members.fetch(userId);
|
||||
if (member) {
|
||||
if (effect.type === "TEMP_ROLE") {
|
||||
await member.roles.add(effect.roleId);
|
||||
} else if (effect.type === "COLOR_ROLE") {
|
||||
const rolesToRemove = colorRoles.filter((r: string) => 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 inventory use:", e);
|
||||
result.results.push("⚠️ Failed to assign role (Check bot permissions)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
13
bot/modules/inventory/inventory.types.ts
Normal file
13
bot/modules/inventory/inventory.types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const INVENTORY_CUSTOM_IDS = {
|
||||
PREFIX: "inv_",
|
||||
SELECT: (viewerId: string) => `inv_select_${viewerId}`,
|
||||
PREV: (viewerId: string) => `inv_prev_${viewerId}`,
|
||||
PAGE: (viewerId: string) => `inv_page_${viewerId}`,
|
||||
NEXT: (viewerId: string) => `inv_next_${viewerId}`,
|
||||
BACK: (viewerId: string) => `inv_back_${viewerId}`,
|
||||
USE: (viewerId: string) => `inv_use_${viewerId}`,
|
||||
DISCARD: (viewerId: string) => `inv_discard_${viewerId}`,
|
||||
DISCARD_CONFIRM: (viewerId: string) => `inv_discard_confirm_${viewerId}`,
|
||||
DISCARD_CANCEL: (viewerId: string) => `inv_discard_cancel_${viewerId}`,
|
||||
USE_BACK: (viewerId: string) => `inv_use_back_${viewerId}`,
|
||||
} as const;
|
||||
@@ -1,140 +1,488 @@
|
||||
import { EmbedBuilder, AttachmentBuilder } from "discord.js";
|
||||
import type { ItemUsageData } from "@shared/lib/types";
|
||||
import { EffectType } from "@shared/lib/constants";
|
||||
import {
|
||||
EmbedBuilder,
|
||||
AttachmentBuilder,
|
||||
ContainerBuilder,
|
||||
SectionBuilder,
|
||||
TextDisplayBuilder,
|
||||
MediaGalleryBuilder,
|
||||
MediaGalleryItemBuilder,
|
||||
ThumbnailBuilder,
|
||||
SeparatorBuilder,
|
||||
SeparatorSpacingSize,
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
StringSelectMenuBuilder,
|
||||
StringSelectMenuOptionBuilder,
|
||||
MessageFlags,
|
||||
} from "discord.js";
|
||||
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
|
||||
import { getRarityConfig, defaultName, stripQuery } from "@shared/lib/rarity";
|
||||
import { ItemType } from "@shared/lib/constants";
|
||||
import type { ItemUsageData } from "@shared/lib/types";
|
||||
import { join } from "path";
|
||||
import { existsSync } from "fs";
|
||||
import { INVENTORY_CUSTOM_IDS } from "./inventory.types";
|
||||
|
||||
/**
|
||||
* Inventory entry with item details
|
||||
*/
|
||||
interface InventoryEntry {
|
||||
export const ITEMS_PER_PAGE = 5;
|
||||
|
||||
const RARITY_SORT_ORDER: Record<string, number> = {
|
||||
SSR: 0,
|
||||
SR: 1,
|
||||
R: 2,
|
||||
C: 3,
|
||||
};
|
||||
|
||||
export interface InventoryItem {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
rarity: string | null;
|
||||
type: string;
|
||||
price: bigint | null;
|
||||
iconUrl: string;
|
||||
imageUrl: string;
|
||||
usageData: unknown;
|
||||
}
|
||||
|
||||
export interface InventoryEntry {
|
||||
quantity: bigint | null;
|
||||
item: {
|
||||
id: number;
|
||||
name: string;
|
||||
[key: string]: any;
|
||||
item: InventoryItem;
|
||||
}
|
||||
|
||||
export function sortInventoryItems(entries: InventoryEntry[]): InventoryEntry[] {
|
||||
return [...entries].sort((a, b) => {
|
||||
const rarityA = RARITY_SORT_ORDER[a.item.rarity ?? "C"] ?? 3;
|
||||
const rarityB = RARITY_SORT_ORDER[b.item.rarity ?? "C"] ?? 3;
|
||||
if (rarityA !== rarityB) return rarityA - rarityB;
|
||||
return a.item.name.localeCompare(b.item.name);
|
||||
});
|
||||
}
|
||||
|
||||
export function getInventoryListMessage(
|
||||
entries: InventoryEntry[],
|
||||
username: string,
|
||||
page: number,
|
||||
viewerId: string,
|
||||
ownerId: string,
|
||||
) {
|
||||
const sorted = sortInventoryItems(entries);
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / ITEMS_PER_PAGE));
|
||||
const safePage = Math.min(page, totalPages - 1);
|
||||
const pageItems = sorted.slice(safePage * ITEMS_PER_PAGE, (safePage + 1) * ITEMS_PER_PAGE);
|
||||
|
||||
// Accent color from highest-rarity item on page
|
||||
const highestRarity = pageItems[0]?.item.rarity ?? "C";
|
||||
const accentColor = getRarityConfig(highestRarity).color;
|
||||
|
||||
const container = new ContainerBuilder()
|
||||
.setAccentColor(accentColor)
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`# 📦 ${username}'s Inventory`),
|
||||
new TextDisplayBuilder().setContent(`-# ${sorted.length} item${sorted.length !== 1 ? "s" : ""} total`)
|
||||
);
|
||||
|
||||
container.addSeparatorComponents(
|
||||
new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)
|
||||
);
|
||||
|
||||
// Item rows
|
||||
const lines = pageItems.map((entry) => {
|
||||
const rc = getRarityConfig(entry.item.rarity ?? "C");
|
||||
return `${rc.squareEmoji} **${entry.item.name}** — ${rc.label} · ${entry.item.type} · ×${entry.quantity}`;
|
||||
});
|
||||
|
||||
container.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(lines.join("\n"))
|
||||
);
|
||||
|
||||
container.addSeparatorComponents(
|
||||
new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)
|
||||
);
|
||||
|
||||
// Select menu with current page items
|
||||
const selectMenu = new StringSelectMenuBuilder()
|
||||
.setCustomId(INVENTORY_CUSTOM_IDS.SELECT(viewerId))
|
||||
.setPlaceholder("Select an item for details");
|
||||
|
||||
for (const entry of pageItems) {
|
||||
const rc = getRarityConfig(entry.item.rarity ?? "C");
|
||||
selectMenu.addOptions(
|
||||
new StringSelectMenuOptionBuilder()
|
||||
.setLabel(entry.item.name)
|
||||
.setDescription(`${rc.label} · ${entry.item.type}`)
|
||||
.setValue(entry.item.id.toString())
|
||||
);
|
||||
}
|
||||
|
||||
container.addActionRowComponents(
|
||||
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
|
||||
);
|
||||
|
||||
// Pagination buttons
|
||||
const navRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(INVENTORY_CUSTOM_IDS.PREV(viewerId))
|
||||
.setLabel("◀ Previous")
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setDisabled(safePage <= 0),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(INVENTORY_CUSTOM_IDS.PAGE(viewerId))
|
||||
.setLabel(`Page ${safePage + 1}/${totalPages}`)
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setDisabled(true),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(INVENTORY_CUSTOM_IDS.NEXT(viewerId))
|
||||
.setLabel("Next ▶")
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setDisabled(safePage >= totalPages - 1),
|
||||
);
|
||||
|
||||
container.addActionRowComponents(navRow);
|
||||
|
||||
return {
|
||||
components: [container] as any,
|
||||
files: [] as AttachmentBuilder[],
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
embeds: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function getEmptyInventoryMessage(username: string) {
|
||||
const container = new ContainerBuilder()
|
||||
.setAccentColor(0x95A5A6)
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`# 📦 ${username}'s Inventory`),
|
||||
new TextDisplayBuilder().setContent("*No items yet. Visit the shop or complete quests to earn items!*")
|
||||
);
|
||||
|
||||
return {
|
||||
components: [container] as any,
|
||||
files: [],
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
embeds: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function getItemDetailMessage(
|
||||
entry: InventoryEntry,
|
||||
viewerId: string,
|
||||
ownerId: string,
|
||||
) {
|
||||
const { item } = entry;
|
||||
const rc = getRarityConfig(item.rarity ?? "C");
|
||||
const files: AttachmentBuilder[] = [];
|
||||
|
||||
const container = new ContainerBuilder().setAccentColor(rc.color);
|
||||
|
||||
// Header section with thumbnail
|
||||
const section = new SectionBuilder().addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`${rc.squareEmoji} **${item.name}**`),
|
||||
new TextDisplayBuilder().setContent(`-# ${rc.label} · ${item.type}`)
|
||||
);
|
||||
|
||||
// Resolve icon thumbnail
|
||||
const iconUrl = resolveItemUrl(item.iconUrl, files);
|
||||
if (iconUrl) {
|
||||
section.setThumbnailAccessory(new ThumbnailBuilder().setURL(iconUrl));
|
||||
}
|
||||
|
||||
container.addSectionComponents(section);
|
||||
|
||||
// Artwork via MediaGallery
|
||||
const imageUrl = resolveItemUrl(item.imageUrl, files);
|
||||
if (imageUrl && item.imageUrl !== item.iconUrl) {
|
||||
container.addMediaGalleryComponents(
|
||||
new MediaGalleryBuilder().addItems(
|
||||
new MediaGalleryItemBuilder().setURL(imageUrl)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Description
|
||||
if (item.description) {
|
||||
container.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(item.description)
|
||||
);
|
||||
}
|
||||
|
||||
container.addSeparatorComponents(
|
||||
new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)
|
||||
);
|
||||
|
||||
// Stats row
|
||||
const priceText = item.price ? `${item.price} 🪙` : "Not purchasable";
|
||||
container.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(
|
||||
`Owned: **×${entry.quantity}** · Value: **${priceText}**`
|
||||
)
|
||||
);
|
||||
|
||||
// Action buttons
|
||||
const isOwner = viewerId === ownerId;
|
||||
const usageData = item.usageData as ItemUsageData | null;
|
||||
const isUsable = isOwner && item.type === ItemType.CONSUMABLE &&
|
||||
usageData?.effects && usageData.effects.length > 0;
|
||||
|
||||
const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(INVENTORY_CUSTOM_IDS.BACK(viewerId))
|
||||
.setLabel("◀ Back")
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
);
|
||||
|
||||
if (isUsable) {
|
||||
actionRow.addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(INVENTORY_CUSTOM_IDS.USE(viewerId))
|
||||
.setLabel("🧪 Use")
|
||||
.setStyle(ButtonStyle.Success)
|
||||
);
|
||||
}
|
||||
|
||||
if (isOwner) {
|
||||
actionRow.addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(INVENTORY_CUSTOM_IDS.DISCARD(viewerId))
|
||||
.setLabel("🗑 Discard")
|
||||
.setStyle(ButtonStyle.Danger)
|
||||
);
|
||||
}
|
||||
|
||||
container.addActionRowComponents(actionRow);
|
||||
|
||||
return {
|
||||
components: [container] as any,
|
||||
files,
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
embeds: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function getDiscardConfirmMessage(entry: InventoryEntry, viewerId: string) {
|
||||
const rc = getRarityConfig(entry.item.rarity ?? "C");
|
||||
|
||||
const container = new ContainerBuilder()
|
||||
.setAccentColor(0xED4245)
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(
|
||||
`Are you sure you want to discard 1× **${entry.item.name}**?`
|
||||
)
|
||||
)
|
||||
.addActionRowComponents(
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(INVENTORY_CUSTOM_IDS.DISCARD_CONFIRM(viewerId))
|
||||
.setLabel("Confirm")
|
||||
.setStyle(ButtonStyle.Danger),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(INVENTORY_CUSTOM_IDS.DISCARD_CANCEL(viewerId))
|
||||
.setLabel("Cancel")
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
components: [container] as any,
|
||||
files: [],
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
embeds: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an embed displaying a user's inventory
|
||||
* Wraps a use-item result message with a Back button so the user
|
||||
* can return to the inventory after seeing the effect result.
|
||||
*/
|
||||
export function getInventoryEmbed(items: InventoryEntry[], username: string): EmbedBuilder {
|
||||
const description = items.map(entry => {
|
||||
return `**${entry.item.name}** x${entry.quantity}`;
|
||||
}).join("\n");
|
||||
export function appendUseBackButton(message: any, viewerId: string): any {
|
||||
const backRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(INVENTORY_CUSTOM_IDS.USE_BACK(viewerId))
|
||||
.setLabel("◀ Back to Inventory")
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
);
|
||||
|
||||
return new EmbedBuilder()
|
||||
.setTitle(`📦 ${username}'s Inventory`)
|
||||
.setDescription(description)
|
||||
.setColor(0x3498db); // Blue
|
||||
// If CV2 message with components array, append to the first container
|
||||
if (message.components && message.flags === MessageFlags.IsComponentsV2) {
|
||||
const container = message.components[0];
|
||||
if (container?.addActionRowComponents) {
|
||||
container.addActionRowComponents(backRow);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
// Embed-based fallback — add as a regular component row
|
||||
return {
|
||||
...message,
|
||||
components: [...(message.components || []), backRow],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an embed showing the results of using an item
|
||||
* Resolves an item URL (icon or image) for use in CV2 components.
|
||||
* Handles both local assets and remote URLs.
|
||||
* Pushes AttachmentBuilders to `files` array for local assets.
|
||||
*/
|
||||
export function getItemUseResultEmbed(results: any[], item?: { name: string, iconUrl: string | null, usageData: any }): { embed: EmbedBuilder, files: AttachmentBuilder[] } {
|
||||
const embed = new EmbedBuilder();
|
||||
function resolveItemUrl(url: string | null | undefined, files: AttachmentBuilder[]): string | null {
|
||||
if (!url) return null;
|
||||
|
||||
if (isLocalAssetUrl(url)) {
|
||||
const filePath = join(process.cwd(), "bot/assets/graphics", stripQuery(url).replace(/^\/?assets\//, ""));
|
||||
if (existsSync(filePath)) {
|
||||
const fileName = defaultName(url);
|
||||
if (!files.find(f => f.name === fileName)) {
|
||||
files.push(new AttachmentBuilder(filePath, { name: fileName }));
|
||||
}
|
||||
return `attachment://${fileName}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolveAssetUrl(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Components V2 message showing the result of opening a lootbox.
|
||||
* Falls back to a simple embed for non-lootbox item usage.
|
||||
*/
|
||||
export function getLootboxResultMessage(
|
||||
results: any[],
|
||||
item?: { name: string; iconUrl: string | null; imageUrl: string | null; usageData: any }
|
||||
) {
|
||||
const files: AttachmentBuilder[] = [];
|
||||
const otherMessages: string[] = [];
|
||||
let lootResult: any = null;
|
||||
|
||||
for (const res of results) {
|
||||
if (typeof res === 'object' && res.type === 'LOOTBOX_RESULT') {
|
||||
if (typeof res === "object" && res.type === "LOOTBOX_RESULT") {
|
||||
lootResult = res;
|
||||
} else {
|
||||
otherMessages.push(typeof res === 'string' ? `• ${res}` : `• ${JSON.stringify(res)}`);
|
||||
otherMessages.push(typeof res === "string" ? `• ${res}` : `• ${JSON.stringify(res)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Default Configuration
|
||||
const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === EffectType.LOOTBOX);
|
||||
embed.setColor(isLootbox ? 0xFFD700 : 0x2ecc71); // Gold for lootbox, Green otherwise by default
|
||||
embed.setTimestamp();
|
||||
|
||||
if (lootResult) {
|
||||
embed.setTitle(`🎁 ${item?.name || "Lootbox"} Opened!`);
|
||||
|
||||
if (lootResult.rewardType === 'ITEM' && lootResult.item) {
|
||||
const i = lootResult.item;
|
||||
const amountStr = lootResult.amount > 1 ? `x${lootResult.amount}` : '';
|
||||
|
||||
// Rarity Colors
|
||||
const rarityColors: Record<string, number> = {
|
||||
'C': 0x95A5A6, // Gray
|
||||
'R': 0x3498DB, // Blue
|
||||
'SR': 0x9B59B6, // Purple
|
||||
'SSR': 0xF1C40F // Gold
|
||||
};
|
||||
|
||||
const rarityKey = i.rarity || 'C';
|
||||
if (rarityKey in rarityColors) {
|
||||
embed.setColor(rarityColors[rarityKey] ?? 0x95A5A6);
|
||||
} else {
|
||||
embed.setColor(0x95A5A6);
|
||||
}
|
||||
|
||||
if (i.image) {
|
||||
if (isLocalAssetUrl(i.image)) {
|
||||
const imagePath = join(process.cwd(), "bot/assets/graphics", i.image.replace(/^\/?assets\//, ""));
|
||||
if (existsSync(imagePath)) {
|
||||
const imageName = defaultName(i.image);
|
||||
if (!files.find(f => f.name === imageName)) {
|
||||
files.push(new AttachmentBuilder(imagePath, { name: imageName }));
|
||||
}
|
||||
embed.setImage(`attachment://${imageName}`);
|
||||
}
|
||||
} else {
|
||||
const imgUrl = resolveAssetUrl(i.image);
|
||||
if (imgUrl) embed.setImage(imgUrl);
|
||||
}
|
||||
}
|
||||
|
||||
embed.setDescription(`**You found ${i.name} ${amountStr}!**\n${i.description || '_'}`);
|
||||
embed.addFields({ name: 'Rarity', value: rarityKey, inline: true });
|
||||
|
||||
} else if (lootResult.rewardType === 'CURRENCY') {
|
||||
embed.setColor(0xF1C40F);
|
||||
embed.setDescription(`**You found ${lootResult.amount.toLocaleString()} 🪙 AU!**`);
|
||||
} else if (lootResult.rewardType === 'XP') {
|
||||
embed.setColor(0x2ECC71); // Green
|
||||
embed.setDescription(`**You gained ${lootResult.amount.toLocaleString()} XP!**`);
|
||||
} else {
|
||||
// Nothing or Message
|
||||
embed.setDescription(lootResult.message);
|
||||
embed.setColor(0x95A5A6); // Gray
|
||||
}
|
||||
// If no loot result, fall back to a simple embed (non-lootbox item usage)
|
||||
if (!lootResult) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!")
|
||||
.setDescription(otherMessages.join("\n") || "Effect applied.")
|
||||
.setColor(0x2ecc71)
|
||||
.setTimestamp();
|
||||
return { embeds: [embed], files, components: undefined, flags: undefined };
|
||||
}
|
||||
|
||||
// Determine rarity key for theming
|
||||
let rarityKey = "C";
|
||||
if (lootResult.rewardType === "ITEM" && lootResult.item) {
|
||||
rarityKey = lootResult.item.rarity || "C";
|
||||
} else if (lootResult.rewardType === "CURRENCY") {
|
||||
rarityKey = "CURRENCY";
|
||||
} else if (lootResult.rewardType === "XP") {
|
||||
rarityKey = "XP";
|
||||
} else {
|
||||
// Standard item usage
|
||||
embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!");
|
||||
embed.setDescription(otherMessages.join("\n") || "Effect applied.");
|
||||
rarityKey = "NOTHING";
|
||||
}
|
||||
|
||||
if (isLootbox && item && item.iconUrl) {
|
||||
if (isLocalAssetUrl(item.iconUrl)) {
|
||||
const iconPath = join(process.cwd(), "bot/assets/graphics", item.iconUrl.replace(/^\/?assets\//, ""));
|
||||
if (existsSync(iconPath)) {
|
||||
const iconName = defaultName(item.iconUrl);
|
||||
if (!files.find(f => f.name === iconName)) {
|
||||
files.push(new AttachmentBuilder(iconPath, { name: iconName }));
|
||||
const config = getRarityConfig(rarityKey);
|
||||
const container = new ContainerBuilder().setAccentColor(config.color);
|
||||
|
||||
// Header: lootbox name
|
||||
if (item?.name) {
|
||||
container.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`-# Opened: ${item.name}`)
|
||||
);
|
||||
}
|
||||
|
||||
// Build title and description based on reward type
|
||||
let title = "";
|
||||
let description = "";
|
||||
|
||||
if (lootResult.rewardType === "ITEM" && lootResult.item) {
|
||||
const i = lootResult.item;
|
||||
const amountStr = lootResult.amount > 1 ? ` ×${lootResult.amount}` : "";
|
||||
title = `${config.emoji} ${config.label} — ${i.name}${amountStr}`;
|
||||
description = i.description || "";
|
||||
description += (description ? "\n\n" : "") + `**${config.label}** · ×${lootResult.amount || 1} added to inventory`;
|
||||
} else if (lootResult.rewardType === "CURRENCY") {
|
||||
title = `${config.emoji} You found ${lootResult.amount.toLocaleString()} AU!`;
|
||||
description = "Coins have been added to your balance.";
|
||||
} else if (lootResult.rewardType === "XP") {
|
||||
title = `${config.emoji} You gained ${lootResult.amount.toLocaleString()} XP!`;
|
||||
description = "Experience has been added to your profile.";
|
||||
} else {
|
||||
title = `${config.emoji} Empty...`;
|
||||
description = lootResult.message || "You found nothing inside.";
|
||||
}
|
||||
|
||||
// Main section with optional thumbnail
|
||||
const section = new SectionBuilder().addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`# ${title}`),
|
||||
new TextDisplayBuilder().setContent(description)
|
||||
);
|
||||
|
||||
// Thumbnail from iconUrl (use reward item's icon for ITEM, lootbox icon otherwise)
|
||||
let thumbnailUrl: string | null = null;
|
||||
const iconSource = lootResult.rewardType === "ITEM" ? lootResult.item?.iconUrl : item?.iconUrl;
|
||||
if (iconSource) {
|
||||
if (isLocalAssetUrl(iconSource)) {
|
||||
const iconPath = join(process.cwd(), "bot/assets/graphics", stripQuery(iconSource).replace(/^\/?assets\//, ""));
|
||||
if (existsSync(iconPath)) {
|
||||
const iconName = defaultName(iconSource);
|
||||
files.push(new AttachmentBuilder(iconPath, { name: iconName }));
|
||||
thumbnailUrl = `attachment://${iconName}`;
|
||||
}
|
||||
} else {
|
||||
thumbnailUrl = resolveAssetUrl(iconSource);
|
||||
}
|
||||
}
|
||||
|
||||
if (thumbnailUrl) {
|
||||
section.setThumbnailAccessory(new ThumbnailBuilder().setURL(thumbnailUrl));
|
||||
}
|
||||
|
||||
container.addSectionComponents(section);
|
||||
|
||||
// Media gallery for full item art (if imageUrl differs from iconUrl)
|
||||
if (lootResult.rewardType === "ITEM" && lootResult.item) {
|
||||
const imgSource = lootResult.item.imageUrl;
|
||||
const iconSrc = lootResult.item.iconUrl;
|
||||
if (imgSource && imgSource !== iconSrc) {
|
||||
let displayImageUrl: string | null = null;
|
||||
if (isLocalAssetUrl(imgSource)) {
|
||||
const imagePath = join(process.cwd(), "bot/assets/graphics", stripQuery(imgSource).replace(/^\/?assets\//, ""));
|
||||
if (existsSync(imagePath)) {
|
||||
const imageName = defaultName(imgSource);
|
||||
if (!files.find(f => f.name === imageName)) {
|
||||
files.push(new AttachmentBuilder(imagePath, { name: imageName }));
|
||||
}
|
||||
embed.setThumbnail(`attachment://${iconName}`);
|
||||
displayImageUrl = `attachment://${imageName}`;
|
||||
}
|
||||
} else {
|
||||
const resolvedIconUrl = resolveAssetUrl(item.iconUrl);
|
||||
if (resolvedIconUrl) embed.setThumbnail(resolvedIconUrl);
|
||||
displayImageUrl = resolveAssetUrl(imgSource);
|
||||
}
|
||||
if (displayImageUrl) {
|
||||
container.addMediaGalleryComponents(
|
||||
new MediaGalleryBuilder().addItems(
|
||||
new MediaGalleryItemBuilder().setURL(displayImageUrl)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (otherMessages.length > 0 && lootResult) {
|
||||
embed.addFields({ name: "Other Effects", value: otherMessages.join("\n") });
|
||||
// Other effects (non-lootbox results like temp roles, XP boosts)
|
||||
if (otherMessages.length > 0) {
|
||||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||
container.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`**Other Effects**\n${otherMessages.join("\n")}`)
|
||||
);
|
||||
}
|
||||
|
||||
return { embed, files };
|
||||
}
|
||||
|
||||
function defaultName(path: string): string {
|
||||
return path.split("/").pop() || "image.png";
|
||||
return {
|
||||
// TODO: remove cast once discord.js types include ContainerBuilder in MessageEditOptions
|
||||
components: [container] as any,
|
||||
files,
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
embeds: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,24 +2,87 @@ import { Collection, Message, PermissionFlagsBits } from "discord.js";
|
||||
import type { TextBasedChannel } from "discord.js";
|
||||
import type { PruneOptions, PruneResult, PruneProgress } from "@/modules/moderation/prune.types";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { UserError, SystemError } from "@shared/lib/errors";
|
||||
|
||||
export class PruneService {
|
||||
/**
|
||||
* Fetch messages from a channel
|
||||
*/
|
||||
async function fetchMessages(
|
||||
channel: TextBasedChannel,
|
||||
limit: number,
|
||||
before?: string
|
||||
): Promise<Collection<string, Message>> {
|
||||
if (!('messages' in channel)) {
|
||||
return new Collection();
|
||||
}
|
||||
|
||||
return await channel.messages.fetch({
|
||||
limit,
|
||||
before
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batch of messages for deletion
|
||||
*/
|
||||
async function processBatch(
|
||||
channel: TextBasedChannel,
|
||||
messages: Collection<string, Message>,
|
||||
userId?: string
|
||||
): Promise<{ deleted: number; skipped: number }> {
|
||||
if (!('bulkDelete' in channel)) {
|
||||
throw new UserError("This channel type does not support bulk deletion");
|
||||
}
|
||||
|
||||
// Filter by user if specified
|
||||
let messagesToDelete = messages;
|
||||
if (userId) {
|
||||
messagesToDelete = messages.filter(msg => msg.author.id === userId);
|
||||
}
|
||||
|
||||
if (messagesToDelete.size === 0) {
|
||||
return { deleted: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
// bulkDelete with filterOld=true will automatically skip messages >14 days
|
||||
const deleted = await channel.bulkDelete(messagesToDelete, true);
|
||||
const skipped = messagesToDelete.size - deleted.size;
|
||||
|
||||
return {
|
||||
deleted: deleted.size,
|
||||
skipped
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error during bulk delete:", error);
|
||||
throw new SystemError("Failed to delete messages");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to delay execution
|
||||
*/
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export const pruneService = {
|
||||
/**
|
||||
* Delete messages from a channel based on provided options
|
||||
*/
|
||||
static async deleteMessages(
|
||||
async deleteMessages(
|
||||
channel: TextBasedChannel,
|
||||
options: PruneOptions,
|
||||
progressCallback?: (progress: PruneProgress) => Promise<void>
|
||||
): Promise<PruneResult> {
|
||||
// Validate channel permissions
|
||||
if (!('permissionsFor' in channel)) {
|
||||
throw new Error("Cannot check permissions for this channel type");
|
||||
throw new UserError("Cannot check permissions for this channel type");
|
||||
}
|
||||
|
||||
const permissions = channel.permissionsFor(channel.client.user!);
|
||||
if (!permissions?.has(PermissionFlagsBits.ManageMessages)) {
|
||||
throw new Error("Missing permission to manage messages in this channel");
|
||||
throw new UserError("Missing permission to manage messages in this channel");
|
||||
}
|
||||
|
||||
const { amount, userId, all } = options;
|
||||
@@ -38,11 +101,11 @@ export class PruneService {
|
||||
requestedCount = estimatedTotal;
|
||||
|
||||
while (true) {
|
||||
const messages = await this.fetchMessages(channel, batchSize, lastMessageId);
|
||||
const messages = await fetchMessages(channel, batchSize, lastMessageId);
|
||||
|
||||
if (messages.size === 0) break;
|
||||
|
||||
const { deleted, skipped } = await this.processBatch(
|
||||
const { deleted, skipped } = await processBatch(
|
||||
channel,
|
||||
messages,
|
||||
userId
|
||||
@@ -70,15 +133,15 @@ export class PruneService {
|
||||
|
||||
// Delay to avoid rate limits
|
||||
if (messages.size >= batchSize) {
|
||||
await this.delay(batchDelay);
|
||||
await delay(batchDelay);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Delete specific amount
|
||||
const limit = Math.min(amount || 10, config.moderation.prune.maxAmount);
|
||||
const messages = await this.fetchMessages(channel, limit, undefined);
|
||||
const messages = await fetchMessages(channel, limit, undefined);
|
||||
|
||||
const { deleted, skipped } = await this.processBatch(
|
||||
const { deleted, skipped } = await processBatch(
|
||||
channel,
|
||||
messages,
|
||||
userId
|
||||
@@ -106,67 +169,12 @@ export class PruneService {
|
||||
username,
|
||||
skippedOld: totalSkipped > 0 ? totalSkipped : undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch messages from a channel
|
||||
*/
|
||||
private static async fetchMessages(
|
||||
channel: TextBasedChannel,
|
||||
limit: number,
|
||||
before?: string
|
||||
): Promise<Collection<string, Message>> {
|
||||
if (!('messages' in channel)) {
|
||||
return new Collection();
|
||||
}
|
||||
|
||||
return await channel.messages.fetch({
|
||||
limit,
|
||||
before
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batch of messages for deletion
|
||||
*/
|
||||
private static async processBatch(
|
||||
channel: TextBasedChannel,
|
||||
messages: Collection<string, Message>,
|
||||
userId?: string
|
||||
): Promise<{ deleted: number; skipped: number }> {
|
||||
if (!('bulkDelete' in channel)) {
|
||||
throw new Error("This channel type does not support bulk deletion");
|
||||
}
|
||||
|
||||
// Filter by user if specified
|
||||
let messagesToDelete = messages;
|
||||
if (userId) {
|
||||
messagesToDelete = messages.filter(msg => msg.author.id === userId);
|
||||
}
|
||||
|
||||
if (messagesToDelete.size === 0) {
|
||||
return { deleted: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
// bulkDelete with filterOld=true will automatically skip messages >14 days
|
||||
const deleted = await channel.bulkDelete(messagesToDelete, true);
|
||||
const skipped = messagesToDelete.size - deleted.size;
|
||||
|
||||
return {
|
||||
deleted: deleted.size,
|
||||
skipped
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error during bulk delete:", error);
|
||||
throw new Error("Failed to delete messages");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Estimate the total number of messages in a channel
|
||||
*/
|
||||
static async estimateMessageCount(channel: TextBasedChannel): Promise<number> {
|
||||
async estimateMessageCount(channel: TextBasedChannel): Promise<number> {
|
||||
if (!('messages' in channel)) {
|
||||
return 0;
|
||||
}
|
||||
@@ -187,12 +195,5 @@ export class PruneService {
|
||||
} catch {
|
||||
return 100; // Default estimate
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to delay execution
|
||||
*/
|
||||
private static delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,8 @@
|
||||
export const PRUNE_CUSTOM_IDS = {
|
||||
CONFIRM: "confirm_prune",
|
||||
CANCEL: "cancel_prune",
|
||||
} as const;
|
||||
|
||||
export interface PruneOptions {
|
||||
amount?: number;
|
||||
userId?: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, Colors } from "discord.js";
|
||||
import type { PruneResult, PruneProgress } from "./prune.types";
|
||||
import { PRUNE_CUSTOM_IDS, type PruneResult, type PruneProgress } from "./prune.types";
|
||||
|
||||
/**
|
||||
* Creates a confirmation message for prune operations
|
||||
@@ -25,12 +25,12 @@ export function getConfirmationMessage(
|
||||
.setTimestamp();
|
||||
|
||||
const confirmButton = new ButtonBuilder()
|
||||
.setCustomId("confirm_prune")
|
||||
.setCustomId(PRUNE_CUSTOM_IDS.CONFIRM)
|
||||
.setLabel("Confirm")
|
||||
.setStyle(ButtonStyle.Danger);
|
||||
|
||||
const cancelButton = new ButtonBuilder()
|
||||
.setCustomId("cancel_prune")
|
||||
.setCustomId(PRUNE_CUSTOM_IDS.CANCEL)
|
||||
.setLabel("Cancel")
|
||||
.setStyle(ButtonStyle.Secondary);
|
||||
|
||||
|
||||
8
bot/modules/quest/quest.types.ts
Normal file
8
bot/modules/quest/quest.types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const QUEST_CUSTOM_IDS = {
|
||||
ACCEPT_PREFIX: "quest_accept:",
|
||||
ACCEPT: (questId: number) => `quest_accept:${questId}`,
|
||||
PAGE_PREV: "quest_page_prev",
|
||||
PAGE_NEXT: "quest_page_next",
|
||||
VIEW_ACTIVE: "quest_view_active",
|
||||
VIEW_AVAILABLE: "quest_view_available",
|
||||
} as const;
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
SeparatorSpacingSize,
|
||||
MessageFlags
|
||||
} from "discord.js";
|
||||
import { QUEST_CUSTOM_IDS } from "./quest.types";
|
||||
|
||||
/**
|
||||
* Quest entry with quest details and progress
|
||||
@@ -43,6 +44,12 @@ const COLORS = {
|
||||
COMPLETED: 0xf1c40f // Gold - completed
|
||||
};
|
||||
|
||||
// Max quests per page. Discord counts all nested components toward a 40 total limit:
|
||||
// Fixed: 1 container + 2 header + 1 nav row + 2 nav buttons + 1 pagination row + 2 pagination buttons = 9
|
||||
// Per quest (available): 1 separator + 3 text + 1 action row + 1 button = 6
|
||||
// Budget: 9 + 6×5 = 39 <= 40
|
||||
const QUESTS_PER_PAGE = 5;
|
||||
|
||||
/**
|
||||
* Formats quest rewards object into a human-readable string
|
||||
*/
|
||||
@@ -70,15 +77,22 @@ function renderProgressBar(current: number, total: number, size: number = 10): s
|
||||
/**
|
||||
* Creates Components v2 containers for the quest list (active quests only)
|
||||
*/
|
||||
export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuilder[] {
|
||||
export function getQuestListComponents(userQuests: QuestEntry[], page: number = 0): ContainerBuilder[] {
|
||||
// Filter to only show in-progress quests (not completed)
|
||||
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
|
||||
const totalPages = Math.max(1, Math.ceil(activeQuests.length / QUESTS_PER_PAGE));
|
||||
const safePage = Math.min(page, totalPages - 1);
|
||||
const pageQuests = activeQuests.slice(safePage * QUESTS_PER_PAGE, (safePage + 1) * QUESTS_PER_PAGE);
|
||||
|
||||
const container = new ContainerBuilder()
|
||||
.setAccentColor(COLORS.ACTIVE)
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("# 📜 Quest Log"),
|
||||
new TextDisplayBuilder().setContent("-# Your active quests")
|
||||
new TextDisplayBuilder().setContent(
|
||||
totalPages > 1
|
||||
? `-# Your active quests — Page ${safePage + 1}/${totalPages}`
|
||||
: "-# Your active quests"
|
||||
)
|
||||
);
|
||||
|
||||
if (activeQuests.length === 0) {
|
||||
@@ -89,7 +103,7 @@ export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuild
|
||||
return [container];
|
||||
}
|
||||
|
||||
activeQuests.forEach((entry) => {
|
||||
pageQuests.forEach((entry) => {
|
||||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||
|
||||
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
||||
@@ -113,12 +127,20 @@ export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuild
|
||||
/**
|
||||
* Creates Components v2 containers for available quests with inline accept buttons
|
||||
*/
|
||||
export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]): ContainerBuilder[] {
|
||||
export function getAvailableQuestsComponents(availableQuests: AvailableQuest[], page: number = 0): ContainerBuilder[] {
|
||||
const totalPages = Math.max(1, Math.ceil(availableQuests.length / QUESTS_PER_PAGE));
|
||||
const safePage = Math.min(page, totalPages - 1);
|
||||
const pageQuests = availableQuests.slice(safePage * QUESTS_PER_PAGE, (safePage + 1) * QUESTS_PER_PAGE);
|
||||
|
||||
const container = new ContainerBuilder()
|
||||
.setAccentColor(COLORS.AVAILABLE)
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("# 🗺️ Available Quests"),
|
||||
new TextDisplayBuilder().setContent("-# Quests you can accept")
|
||||
new TextDisplayBuilder().setContent(
|
||||
totalPages > 1
|
||||
? `-# Quests you can accept — Page ${safePage + 1}/${totalPages}`
|
||||
: "-# Quests you can accept"
|
||||
)
|
||||
);
|
||||
|
||||
if (availableQuests.length === 0) {
|
||||
@@ -129,10 +151,7 @@ export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]):
|
||||
return [container];
|
||||
}
|
||||
|
||||
// Limit to 10 quests (5 action rows max with 2 added for navigation)
|
||||
const questsToShow = availableQuests.slice(0, 10);
|
||||
|
||||
questsToShow.forEach((quest) => {
|
||||
pageQuests.forEach((quest) => {
|
||||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||
|
||||
const rewards = quest.rewards as { xp?: number, balance?: number };
|
||||
@@ -151,7 +170,7 @@ export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]):
|
||||
container.addActionRowComponents(
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(`quest_accept:${quest.id}`)
|
||||
.setCustomId(QUEST_CUSTOM_IDS.ACCEPT(quest.id))
|
||||
.setLabel("Accept Quest")
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setEmoji("✅")
|
||||
@@ -163,24 +182,43 @@ export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]):
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns action rows for navigation only
|
||||
* Returns action rows for navigation and pagination
|
||||
*/
|
||||
export function getQuestActionRows(viewType: 'active' | 'available'): ActionRowBuilder<ButtonBuilder>[] {
|
||||
// Navigation row
|
||||
const navRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
export function getQuestActionRows(viewType: 'active' | 'available', totalItems: number, page: number): ActionRowBuilder<ButtonBuilder>[] {
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / QUESTS_PER_PAGE));
|
||||
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
|
||||
|
||||
// Pagination row (only if more than one page)
|
||||
if (totalPages > 1) {
|
||||
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(QUEST_CUSTOM_IDS.PAGE_PREV)
|
||||
.setLabel("◀ Prev")
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setDisabled(page <= 0),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(QUEST_CUSTOM_IDS.PAGE_NEXT)
|
||||
.setLabel("Next ▶")
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setDisabled(page >= totalPages - 1)
|
||||
));
|
||||
}
|
||||
|
||||
// Tab navigation row
|
||||
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId("quest_view_active")
|
||||
.setCustomId(QUEST_CUSTOM_IDS.VIEW_ACTIVE)
|
||||
.setLabel("📜 Active")
|
||||
.setStyle(viewType === 'active' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
||||
.setDisabled(viewType === 'active'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId("quest_view_available")
|
||||
.setCustomId(QUEST_CUSTOM_IDS.VIEW_AVAILABLE)
|
||||
.setLabel("🗺️ Available")
|
||||
.setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
||||
.setDisabled(viewType === 'available')
|
||||
);
|
||||
));
|
||||
|
||||
return [navRow];
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { temporaryRoleService } from "@shared/modules/system/temp-role.service";
|
||||
import { terminalService } from "./terminal.service";
|
||||
|
||||
export const schedulerService = {
|
||||
start: () => {
|
||||
@@ -10,7 +11,6 @@ export const schedulerService = {
|
||||
}, 60 * 1000);
|
||||
|
||||
// 2. Terminal Update Loop (every 60s)
|
||||
const { terminalService } = require("@shared/modules/terminal/terminal.service");
|
||||
setInterval(() => {
|
||||
terminalService.update();
|
||||
}, 60 * 1000);
|
||||
|
||||
@@ -12,24 +12,36 @@ import { AuroraClient } from "@/lib/BotClient";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { users, transactions, lootdrops, inventory } from "@db/schema";
|
||||
import { desc, sql } from "drizzle-orm";
|
||||
import { config, saveConfig } from "@shared/lib/config";
|
||||
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
|
||||
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
|
||||
import { env } from "@shared/lib/env";
|
||||
|
||||
// Color palette for containers (hex as decimal)
|
||||
const COLORS = {
|
||||
HEADER: 0x9B59B6, // Purple - mystical
|
||||
LEADERS: 0xF1C40F, // Gold - achievement
|
||||
ACTIVITY: 0x3498DB, // Blue - activity
|
||||
ALERT: 0xE74C3C // Red - active events
|
||||
HEADER: 0x9B59B6,
|
||||
LEADERS: 0xF1C40F,
|
||||
ACTIVITY: 0x3498DB,
|
||||
ALERT: 0xE74C3C
|
||||
};
|
||||
|
||||
function getPrimaryGuildId(): string | null {
|
||||
return env.DISCORD_GUILD_ID ?? null;
|
||||
}
|
||||
|
||||
export const terminalService = {
|
||||
init: async (channel: TextChannel) => {
|
||||
// Limit to one terminal for now
|
||||
if (config.terminal) {
|
||||
const guildId = channel.guildId;
|
||||
if (!guildId) {
|
||||
console.error("Cannot initialize terminal: no guild ID");
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up old terminal if exists
|
||||
const currentConfig = await getGuildConfig(guildId);
|
||||
if (currentConfig.terminal?.channelId && currentConfig.terminal?.messageId) {
|
||||
try {
|
||||
const oldChannel = await AuroraClient.channels.fetch(config.terminal.channelId) as TextChannel;
|
||||
const oldChannel = await AuroraClient.channels.fetch(currentConfig.terminal.channelId).catch(() => null) as TextChannel | null;
|
||||
if (oldChannel) {
|
||||
const oldMsg = await oldChannel.messages.fetch(config.terminal.messageId);
|
||||
const oldMsg = await oldChannel.messages.fetch(currentConfig.terminal.messageId).catch(() => null);
|
||||
if (oldMsg) await oldMsg.delete();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -39,25 +51,37 @@ export const terminalService = {
|
||||
|
||||
const msg = await channel.send({ content: "🔄 Initializing Aurora Station..." });
|
||||
|
||||
config.terminal = {
|
||||
channelId: channel.id,
|
||||
messageId: msg.id
|
||||
};
|
||||
saveConfig(config);
|
||||
// Save to database
|
||||
await guildSettingsService.upsertSettings({
|
||||
guildId,
|
||||
terminalChannelId: channel.id,
|
||||
terminalMessageId: msg.id,
|
||||
});
|
||||
invalidateGuildConfigCache(guildId);
|
||||
|
||||
await terminalService.update();
|
||||
await terminalService.update(guildId);
|
||||
},
|
||||
|
||||
update: async () => {
|
||||
if (!config.terminal) return;
|
||||
update: async (guildId?: string) => {
|
||||
const effectiveGuildId = guildId ?? getPrimaryGuildId();
|
||||
if (!effectiveGuildId) {
|
||||
console.warn("No guild ID available for terminal update");
|
||||
return;
|
||||
}
|
||||
|
||||
const guildConfig = await getGuildConfig(effectiveGuildId);
|
||||
|
||||
if (!guildConfig.terminal?.channelId || !guildConfig.terminal?.messageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const channel = await AuroraClient.channels.fetch(config.terminal.channelId).catch(() => null) as TextChannel;
|
||||
const channel = await AuroraClient.channels.fetch(guildConfig.terminal.channelId).catch(() => null) as TextChannel | null;
|
||||
if (!channel) {
|
||||
console.warn("Terminal channel not found");
|
||||
return;
|
||||
}
|
||||
const message = await channel.messages.fetch(config.terminal.messageId).catch(() => null);
|
||||
const message = await channel.messages.fetch(guildConfig.terminal.messageId).catch(() => null);
|
||||
if (!message) {
|
||||
console.warn("Terminal message not found");
|
||||
return;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user