Compare commits
150 Commits
feat/dashb
...
f5fecb59cb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5fecb59cb | ||
|
|
65f5663c97 | ||
| de83307adc | |||
|
|
15e01906a3 | ||
|
|
fed27c0227 | ||
|
|
9751e62e30 | ||
|
|
87d5aa259c | ||
|
|
f0bfaecb0b | ||
|
|
9471b6fdab | ||
|
|
04e5851387 | ||
| 1a59c9e796 | |||
|
|
251616fe15 | ||
|
|
fbb2e0f010 | ||
|
|
dc10ad5c37 | ||
|
|
2381f073ba | ||
|
|
121c242168 | ||
|
|
942875e8d0 | ||
|
|
878e3306eb | ||
|
|
aca5538d57 | ||
|
|
f822d90dd3 | ||
|
|
141c3098f8 | ||
|
|
0c67a8754f | ||
|
|
bf20c61190 | ||
|
|
099601ce6d | ||
|
|
55d2376ca1 | ||
|
|
6eb4a32a12 | ||
|
|
2d35a5eabb | ||
|
|
570cdc69c1 | ||
|
|
c2b1fb6db1 | ||
|
|
d15d53e839 | ||
|
|
58374d1746 | ||
|
|
ae6a068197 | ||
|
|
43d32918ab | ||
|
|
0bc254b728 | ||
|
|
610d97bde3 | ||
|
|
babccfd08a | ||
|
|
ee7d63df3e | ||
|
|
5f107d03a7 | ||
|
|
1ff24b0f7f | ||
|
|
a5e3534260 | ||
|
|
228005322e | ||
|
|
67a3aa4b0f | ||
|
|
64804f7066 | ||
|
|
73ad889018 | ||
|
|
9c7f1e4418 | ||
|
|
efb50916b2 | ||
|
|
6abb52694e | ||
|
|
76968e31a6 | ||
|
|
29bf0e6f1c | ||
|
|
8c306fbd23 | ||
|
|
b0c3baf5b7 | ||
|
|
f575588b9a | ||
|
|
553b9b4952 | ||
|
|
073348fa55 | ||
|
|
4232674494 | ||
|
|
fbf1e52c28 | ||
|
|
20284dc57b | ||
|
|
36f9c76fa9 | ||
|
|
46e95ce7b3 | ||
|
|
9acd3f3d76 | ||
|
|
5e8683a19f | ||
|
|
ee088ad84b | ||
|
|
b18b5fab62 | ||
|
|
0b56486ab2 | ||
|
|
11c589b01c | ||
|
|
e4169d9dd5 | ||
|
|
1929f0dd1f | ||
|
|
db4e7313c3 | ||
|
|
1ffe397fbb | ||
|
|
34958aa220 | ||
|
|
109b36ffe2 | ||
|
|
cd954afe36 | ||
|
|
2b60883173 | ||
|
|
c2d67d7435 | ||
|
|
e252d6e00a | ||
|
|
95f1b4e04a | ||
|
|
62c6ca5e87 | ||
|
|
aac9be19f2 | ||
|
|
bb823c86c1 | ||
|
|
119301f1c3 | ||
|
|
9a2fc101da | ||
|
|
7049cbfd9d | ||
|
|
db859e8f12 | ||
|
|
5ff3fa9ab5 | ||
|
|
c8bf69a969 | ||
|
|
fee4969910 | ||
|
|
dabcb4cab3 | ||
|
|
1a3f5c6654 | ||
|
|
422db6479b | ||
|
|
35ecea16f7 | ||
|
|
9ff679ee5c | ||
|
|
ebefd8c0df | ||
|
|
73531f38ae | ||
|
|
5a6356d271 | ||
|
|
f9dafeac3b | ||
|
|
1a2bbb011c | ||
|
|
2ead35789d | ||
|
|
c1da71227d | ||
|
|
17e636c4e5 | ||
|
|
d7543d9f48 | ||
|
|
afe82c449b | ||
|
|
3c1334b30e | ||
|
|
58f261562a | ||
|
|
4ecbffd617 | ||
|
|
5491551544 | ||
|
|
7d658bbef9 | ||
|
|
d117bcb697 | ||
|
|
94e332ba57 | ||
|
|
3ef9773990 | ||
|
|
d243a11bd3 | ||
|
|
47ce0f12e6 | ||
|
|
f2caa1a3ee | ||
|
|
2a72beb0ef | ||
|
|
2f73f38877 | ||
|
|
9e5c6b5ac3 | ||
|
|
eb108695d3 | ||
|
|
7d541825d8 | ||
|
|
52f8ab11f0 | ||
|
|
f8436e9755 | ||
|
|
194a032c7f | ||
|
|
94a5a183d0 | ||
|
|
c7730b9355 | ||
|
|
1e20a5a7a0 | ||
|
|
54944283a3 | ||
|
|
f79ee6fbc7 | ||
|
|
915f1bc4ad | ||
|
|
4af2690bab | ||
|
|
6e57ab07e4 | ||
|
|
3a620a84c5 | ||
|
|
7d68652ea5 | ||
|
|
35bd1f58dd | ||
|
|
1cd3dbcd72 | ||
|
|
c97249f2ca | ||
|
|
0d923491b5 | ||
|
|
d870ef69d5 | ||
|
|
682e9d208e | ||
|
|
4a691ac71d | ||
|
|
1b84dbd36d | ||
|
|
a5b8d922e3 | ||
|
|
238d9a8803 | ||
|
|
713ea07040 | ||
|
|
bea6c33024 | ||
|
|
8fe300c8a2 | ||
|
|
9caa95a0d8 | ||
|
|
c6fd23b5fa | ||
|
|
d46434de18 | ||
|
|
cf4c28e1df | ||
|
|
39e405afde | ||
|
|
6763e3c543 | ||
|
|
11e07a0068 |
@@ -1,57 +0,0 @@
|
|||||||
---
|
|
||||||
description: Create a new Ticket
|
|
||||||
---
|
|
||||||
|
|
||||||
### Role
|
|
||||||
You are a Senior Technical Product Manager and Lead Engineer. Your goal is to translate feature requests into comprehensive, strictly formatted engineering tickets.
|
|
||||||
|
|
||||||
### Task
|
|
||||||
When I ask you to "scope a feature" or "create a ticket" for a specific functionality:
|
|
||||||
1. Analyze the request for technical implications, edge cases, and architectural fit.
|
|
||||||
2. Generate a new Markdown file.
|
|
||||||
3. Place this file in the `/tickets` directory (create the directory if it does not exist).
|
|
||||||
|
|
||||||
### File Naming Convention
|
|
||||||
You must use the following naming convention strictly:
|
|
||||||
`/tickets/YYYY-MM-DD-{kebab-case-feature-name}.md`
|
|
||||||
|
|
||||||
*Example:* `/tickets/2024-10-12-user-authentication-flow.md`
|
|
||||||
|
|
||||||
### File Content Structure
|
|
||||||
The markdown file must adhere to the following template exactly. Do not skip sections. If a section is not applicable, write "N/A" but explain why.
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# [Ticket ID]: [Feature Title]
|
|
||||||
|
|
||||||
**Status:** Draft
|
|
||||||
**Created:** [YYYY-MM-DD]
|
|
||||||
**Tags:** [comma, separated, tags]
|
|
||||||
|
|
||||||
## 1. Context & User Story
|
|
||||||
* **As a:** [Role]
|
|
||||||
* **I want to:** [Action]
|
|
||||||
* **So that:** [Benefit/Value]
|
|
||||||
|
|
||||||
## 2. Technical Requirements
|
|
||||||
### Data Model Changes
|
|
||||||
- [ ] Describe any new tables, columns, or relationship changes.
|
|
||||||
- [ ] SQL migration required? (Yes/No)
|
|
||||||
|
|
||||||
### API / Interface
|
|
||||||
- [ ] Define endpoints (method, path) or function signatures.
|
|
||||||
- [ ] Payload definition (JSON structure or Types).
|
|
||||||
|
|
||||||
## 3. Constraints & Validations (CRITICAL)
|
|
||||||
*This section must be exhaustive. Do not be vague.*
|
|
||||||
- **Input Validation:** (e.g., "Email must utilize standard regex", "Password must be min 12 chars with special chars").
|
|
||||||
- **System Constraints:** (e.g., "Image upload max size 5MB", "Request timeout 30s").
|
|
||||||
- **Business Logic Guardrails:** (e.g., "User cannot upgrade if balance < $0").
|
|
||||||
|
|
||||||
## 4. Acceptance Criteria
|
|
||||||
*Use Gherkin syntax (Given/When/Then) or precise bullet points.*
|
|
||||||
1. [ ] Criteria 1
|
|
||||||
2. [ ] Criteria 2
|
|
||||||
|
|
||||||
## 5. Implementation Plan
|
|
||||||
- [ ] Step 1: ...
|
|
||||||
- [ ] Step 2: ...
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
---
|
|
||||||
description: Review the most recent changes critically.
|
|
||||||
---
|
|
||||||
|
|
||||||
### Role
|
|
||||||
You are a Lead Security Engineer and Senior QA Automator. Your persona is **"The Hostile Reviewer."**
|
|
||||||
* **Mindset:** You do not trust the code. You assume it contains bugs, security flaws, and logic gaps.
|
|
||||||
* **Goal:** Your objective is to reject the most recent git changes by finding legitimate issues. If you cannot find issues, only then do you approve.
|
|
||||||
|
|
||||||
### Phase 1: The Security & Logic Audit
|
|
||||||
Analyze the code changes for specific vulnerabilities. Do not summarize what the code does; look for what it *does wrong*.
|
|
||||||
|
|
||||||
1. **TypeScript Strictness:**
|
|
||||||
* Flag any usage of `any`.
|
|
||||||
* Flag any use of non-null assertions (`!`) unless strictly guarded.
|
|
||||||
* Flag forced type casting (`as UnknownType`) without validation.
|
|
||||||
2. **Bun/Runtime Specifics:**
|
|
||||||
* Check for unhandled Promises (floating promises).
|
|
||||||
* Ensure environment variables are not hardcoded.
|
|
||||||
3. **Security Vectors:**
|
|
||||||
* **Injection:** Check SQL/NoSQL queries for concatenation.
|
|
||||||
* **Sanitization:** Are inputs from the generic request body validated against the schema defined in the Ticket?
|
|
||||||
* **Auth:** Are sensitive routes actually protected by middleware?
|
|
||||||
|
|
||||||
### Phase 2: Test Quality Verification
|
|
||||||
Do not just check if tests pass. Check if the tests are **valid**.
|
|
||||||
1. **The "Happy Path" Trap:** If the tests only check for success (status 200), **FAIL** the review.
|
|
||||||
2. **Edge Case Coverage:**
|
|
||||||
* Did the code handle the *Constraints & Validations* listed in the original ticket?
|
|
||||||
* *Example:* If the ticket says "Max 5MB upload", is there a test case for a 5.1MB file?
|
|
||||||
3. **Mocking Integrity:** Are mocks too permissive? (e.g., Mocking a function to always return `true` regardless of input).
|
|
||||||
|
|
||||||
### Phase 3: The Verdict
|
|
||||||
Output your review in the following strict format:
|
|
||||||
|
|
||||||
---
|
|
||||||
# 🛡️ Code Review Report
|
|
||||||
|
|
||||||
**Ticket ID:** [Ticket Name]
|
|
||||||
**Verdict:** [🔴 REJECT / 🟢 APPROVE]
|
|
||||||
|
|
||||||
## 🚨 Critical Issues (Must Fix)
|
|
||||||
*List logic bugs, security risks, or failing tests.*
|
|
||||||
1. ...
|
|
||||||
2. ...
|
|
||||||
|
|
||||||
## ⚠️ Suggestions (Refactoring)
|
|
||||||
*List code style improvements, variable naming, or DRY opportunities.*
|
|
||||||
1. ...
|
|
||||||
|
|
||||||
## 🧪 Test Coverage Gap Analysis
|
|
||||||
*List specific scenarios that are NOT currently tested but should be.*
|
|
||||||
- [ ] Scenario: ...
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
---
|
|
||||||
description: Pick a Ticket and work on it.
|
|
||||||
---
|
|
||||||
|
|
||||||
### Role
|
|
||||||
You are an Autonomous Senior Software Engineer specializing in TypeScript and Bun. You are responsible for the full lifecycle of feature implementation: selection, coding, testing, verification, and closure.
|
|
||||||
|
|
||||||
|
|
||||||
### Phase 1: Triage & Selection
|
|
||||||
1. **Scan:** Read all files in the `/tickets` directory.
|
|
||||||
2. **Filter:** Ignore tickets marked `Status: Done` or `Status: Archived`.
|
|
||||||
3. **Prioritize:** Select a single ticket based on the following hierarchy:
|
|
||||||
* **Tags:** `Critical` > `High Priority` > `Bug` > `Feature`.
|
|
||||||
* **Age:** Oldest created date first (FIFO).
|
|
||||||
4. **Announce:** Explicitly state: "I am picking ticket: [Ticket ID/Name] because [Reason]."
|
|
||||||
|
|
||||||
### Phase 2: Setup (Non-Destructive)
|
|
||||||
1. **Branching:** Create a new git branch based on the ticket name.
|
|
||||||
* *Format:* `feat/{ticket-kebab-name}` or `fix/{ticket-kebab-name}`.
|
|
||||||
* *Command:* `git checkout -b feat/user-auth-flow`.
|
|
||||||
2. **Context:** Read the selected ticket markdown file thoroughly, paying special attention to "Constraints & Validations."
|
|
||||||
|
|
||||||
### Phase 3: Implementation & Testing (The Loop)
|
|
||||||
*Iterate until the requirements are met.*
|
|
||||||
|
|
||||||
1. **Write Code:** Implement the feature or fix using TypeScript.
|
|
||||||
2. **Tightened Testing:**
|
|
||||||
* You must create or update test files (`*.test.ts` or `*.spec.ts`).
|
|
||||||
* **Requirement:** Tests must cover happy paths AND the edge cases defined in the ticket's "Constraints" section.
|
|
||||||
* *Mocking:* Mock external dependencies where appropriate to ensure isolation.
|
|
||||||
3. **Type Safety Check:**
|
|
||||||
* Run: `bun x tsc --noEmit`
|
|
||||||
* **CRITICAL:** If there are ANY TypeScript errors, you must fix them immediately. Do not proceed.
|
|
||||||
4. **Runtime Verification:**
|
|
||||||
* Run: `bun test`
|
|
||||||
* Ensure all tests pass. If a test fails, analyze the stack trace, fix the implementation, and rerun.
|
|
||||||
|
|
||||||
### Phase 4: Self-Review & Clean Up
|
|
||||||
Before declaring the task finished, perform a self-review:
|
|
||||||
1. **Linting:** Check for unused variables, any types, or console logs.
|
|
||||||
2. **Refactor:** Ensure code is DRY (Don't Repeat Yourself) and strictly typed.
|
|
||||||
3. **Ticket Update:**
|
|
||||||
* Modify the Markdown ticket file.
|
|
||||||
* Change `Status: Draft` to `Status: In Review` or `Status: Done`.
|
|
||||||
* Add a new section at the bottom: `## Implementation Notes` listing the specific files changed.
|
|
||||||
|
|
||||||
### Phase 5: Handover
|
|
||||||
Only when `bun x tsc` and `bun test` pass with 0 errors:
|
|
||||||
1. Commit the changes with a semantic message (e.g., `feat: implement user auth logic`).
|
|
||||||
2. Present a summary of the work done and ask for a human code review.
|
|
||||||
7
.citrine
Normal file
7
.citrine
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
### Frontend
|
||||||
|
[8bb0] [>] implement items page
|
||||||
|
[de51] [ ] implement classes page
|
||||||
|
[d108] [ ] implement quests page
|
||||||
|
[8bbe] [ ] implement lootdrops page
|
||||||
|
[094e] [ ] implement moderation page
|
||||||
|
[220d] [ ] implement transactions page
|
||||||
39
.dockerignore
Normal file
39
.dockerignore
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Dependencies - handled inside container
|
||||||
|
node_modules
|
||||||
|
web/node_modules
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Logs and data
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
shared/db/data
|
||||||
|
shared/db/log
|
||||||
|
|
||||||
|
# Development tools
|
||||||
|
.env
|
||||||
|
.env.example
|
||||||
|
.opencode
|
||||||
|
.agent
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
docs
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
dist
|
||||||
|
.cache
|
||||||
|
*.tsbuildinfo
|
||||||
25
.env.example
25
.env.example
@@ -1,12 +1,33 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Aurora Environment Configuration
|
||||||
|
# =============================================================================
|
||||||
|
# Copy this file to .env and update with your values
|
||||||
|
# For production, see .env.prod.example with security recommendations
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# For production: use a strong password (openssl rand -base64 32)
|
||||||
DB_USER=aurora
|
DB_USER=aurora
|
||||||
DB_PASSWORD=aurora
|
DB_PASSWORD=aurora
|
||||||
DB_NAME=aurora
|
DB_NAME=aurora
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
DB_HOST=db
|
DB_HOST=db
|
||||||
|
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
|
||||||
|
|
||||||
|
# Discord
|
||||||
|
# Get from: https://discord.com/developers/applications
|
||||||
DISCORD_BOT_TOKEN=your-discord-bot-token
|
DISCORD_BOT_TOKEN=your-discord-bot-token
|
||||||
DISCORD_CLIENT_ID=your-discord-client-id
|
DISCORD_CLIENT_ID=your-discord-client-id
|
||||||
DISCORD_GUILD_ID=your-discord-guild-id
|
DISCORD_GUILD_ID=your-discord-guild-id
|
||||||
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
|
|
||||||
|
|
||||||
VPS_USER=your-vps-user
|
# Admin Panel (Discord OAuth)
|
||||||
|
# Get client secret from: https://discord.com/developers/applications → OAuth2
|
||||||
|
DISCORD_CLIENT_SECRET=your-discord-client-secret
|
||||||
|
SESSION_SECRET=change-me-to-a-random-string
|
||||||
|
ADMIN_USER_IDS=123456789012345678
|
||||||
|
PANEL_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Server (for remote access scripts)
|
||||||
|
# Use a non-root user (see shared/scripts/setup-server.sh)
|
||||||
|
VPS_USER=deploy
|
||||||
VPS_HOST=your-vps-ip
|
VPS_HOST=your-vps-ip
|
||||||
|
|||||||
38
.env.prod.example
Normal file
38
.env.prod.example
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Aurora Production Environment Template
|
||||||
|
# =============================================================================
|
||||||
|
# Copy this file to .env and fill in the values
|
||||||
|
# IMPORTANT: Use strong, unique passwords in production!
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Database Configuration
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Generate strong password: openssl rand -base64 32
|
||||||
|
DB_USER=aurora_prod
|
||||||
|
DB_PASSWORD=CHANGE_ME_USE_STRONG_PASSWORD
|
||||||
|
DB_NAME=aurora_prod
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_HOST=localhost
|
||||||
|
|
||||||
|
# Constructed database URL (used by Drizzle)
|
||||||
|
DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_NAME}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Discord Configuration
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Get these from Discord Developer Portal: https://discord.com/developers
|
||||||
|
DISCORD_BOT_TOKEN=your_bot_token_here
|
||||||
|
DISCORD_CLIENT_ID=your_client_id_here
|
||||||
|
DISCORD_GUILD_ID=your_guild_id_here
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Server Configuration (for SSH deployment scripts)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Use a non-root user for security!
|
||||||
|
VPS_USER=deploy
|
||||||
|
VPS_HOST=your_server_ip_here
|
||||||
|
|
||||||
|
# Optional: Custom ports for remote access
|
||||||
|
# DASHBOARD_PORT=3000
|
||||||
|
# STUDIO_PORT=4983
|
||||||
6
.env.test
Normal file
6
.env.test
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
DATABASE_URL="postgresql://auroradev:auroradev123@localhost:5432/aurora_test"
|
||||||
|
DISCORD_BOT_TOKEN="test_token"
|
||||||
|
DISCORD_CLIENT_ID="123456789"
|
||||||
|
DISCORD_GUILD_ID="123456789"
|
||||||
|
ADMIN_TOKEN="admin_token_123"
|
||||||
|
LOG_LEVEL="error"
|
||||||
100
.github/workflows/deploy.yml
vendored
Normal file
100
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Aurora CI/CD Pipeline
|
||||||
|
# Builds, tests, and deploys to production server
|
||||||
|
|
||||||
|
name: Deploy to Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch: # Allow manual trigger
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ==========================================================================
|
||||||
|
# Test Job
|
||||||
|
# ==========================================================================
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: aurora_test
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Create Config File
|
||||||
|
run: |
|
||||||
|
mkdir -p shared/config
|
||||||
|
cat <<EOF > shared/config/config.json
|
||||||
|
{
|
||||||
|
"leveling": { "base": 100, "exponent": 2.5, "chat": { "cooldownMs": 60000, "minXp": 15, "maxXp": 25 } },
|
||||||
|
"economy": {
|
||||||
|
"daily": { "amount": "100", "streakBonus": "10", "weeklyBonus": "50", "cooldownMs": 86400000 },
|
||||||
|
"transfers": { "allowSelfTransfer": false, "minAmount": "1" },
|
||||||
|
"exam": { "multMin": 0.05, "multMax": 0.03 }
|
||||||
|
},
|
||||||
|
"inventory": { "maxStackSize": "99", "maxSlots": 50 },
|
||||||
|
"commands": {},
|
||||||
|
"lootdrop": {
|
||||||
|
"activityWindowMs": 120000, "minMessages": 1, "spawnChance": 1, "cooldownMs": 3000,
|
||||||
|
"reward": { "min": 40, "max": 150, "currency": "Astral Units" }
|
||||||
|
},
|
||||||
|
"studentRole": "123", "visitorRole": "456", "colorRoles": [],
|
||||||
|
"moderation": {
|
||||||
|
"prune": { "maxAmount": 100, "confirmThreshold": 50, "batchSize": 100, "batchDelayMs": 1000 },
|
||||||
|
"cases": { "dmOnWarn": false }
|
||||||
|
},
|
||||||
|
"trivia": {
|
||||||
|
"entryFee": "50", "rewardMultiplier": 1.5, "timeoutSeconds": 30, "cooldownMs": 60000,
|
||||||
|
"categories": [], "difficulty": "random"
|
||||||
|
},
|
||||||
|
"system": {}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Setup Test Database
|
||||||
|
run: bun run db:push:local
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/aurora_test
|
||||||
|
# Create .env.test for implicit usage by bun
|
||||||
|
DISCORD_BOT_TOKEN: test_token
|
||||||
|
DISCORD_CLIENT_ID: 123
|
||||||
|
DISCORD_GUILD_ID: 123
|
||||||
|
|
||||||
|
- name: Run Tests
|
||||||
|
run: |
|
||||||
|
# Create .env.test for test-sequential.sh / bun test
|
||||||
|
cat <<EOF > .env.test
|
||||||
|
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/aurora_test"
|
||||||
|
DISCORD_BOT_TOKEN="test_token"
|
||||||
|
DISCORD_CLIENT_ID="123456789"
|
||||||
|
DISCORD_GUILD_ID="123456789"
|
||||||
|
ADMIN_TOKEN="admin_token_123"
|
||||||
|
LOG_LEVEL="error"
|
||||||
|
EOF
|
||||||
|
bash shared/scripts/test-sequential.sh --integration
|
||||||
|
env:
|
||||||
|
NODE_ENV: test
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,7 +1,9 @@
|
|||||||
.env
|
.env
|
||||||
node_modules
|
node_modules
|
||||||
|
docker-compose.override.yml
|
||||||
shared/db-logs
|
shared/db-logs
|
||||||
shared/db/data
|
shared/db/data
|
||||||
|
shared/db/backups
|
||||||
shared/db/loga
|
shared/db/loga
|
||||||
.cursor
|
.cursor
|
||||||
# dependencies (bun install)
|
# dependencies (bun install)
|
||||||
@@ -44,4 +46,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
|
|
||||||
src/db/data
|
src/db/data
|
||||||
src/db/log
|
src/db/log
|
||||||
scratchpad/
|
scratchpad/
|
||||||
|
bot/assets/graphics/items
|
||||||
|
tickets/
|
||||||
|
.citrine.local
|
||||||
|
|||||||
257
AGENTS.md
Normal file
257
AGENTS.md
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
# AGENTS.md - AI Coding Agent Guidelines
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
AuroraBot is a Discord bot with a REST API built using Bun, Discord.js, and PostgreSQL with Drizzle ORM.
|
||||||
|
|
||||||
|
## Build/Lint/Test Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
bun --watch bot/index.ts # Run bot + API server with hot reload
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
|
||||||
|
shared/ # Shared between bot and web
|
||||||
|
├── db/ # Database schema and migrations
|
||||||
|
├── lib/ # Utils, config, errors, types
|
||||||
|
└── modules/ # Domain services (economy, user, etc.)
|
||||||
|
|
||||||
|
web/ # API server
|
||||||
|
└── src/routes/ # API route handlers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Import Conventions
|
||||||
|
|
||||||
|
Use path aliases defined in tsconfig.json:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// External packages first
|
||||||
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
// 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";
|
||||||
|
|
||||||
|
// Relative imports last
|
||||||
|
import { localHelper } from "./helper";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available Aliases:**
|
||||||
|
|
||||||
|
- `@/*` - bot/
|
||||||
|
- `@shared/*` - shared/
|
||||||
|
- `@db/*` - shared/db/
|
||||||
|
- `@lib/*` - bot/lib/
|
||||||
|
- `@modules/*` - bot/modules/
|
||||||
|
- `@commands/*` - bot/commands/
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
| 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_` |
|
||||||
|
|
||||||
|
## Code Patterns
|
||||||
|
|
||||||
|
### Command Definition
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const commandName = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("commandname")
|
||||||
|
.setDescription("Description"),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
await interaction.deferReply();
|
||||||
|
// Implementation
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Pattern (Singleton Object)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const serviceName = {
|
||||||
|
methodName: async (params: ParamType): Promise<ReturnType> => {
|
||||||
|
return await withTransaction(async (tx) => {
|
||||||
|
// Database operations
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Module File Organization
|
||||||
|
|
||||||
|
- `*.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)
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Custom Error Classes
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { UserError, SystemError } from "@shared/lib/errors";
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recommended: `withCommandErrorHandling`
|
||||||
|
|
||||||
|
Use the `withCommandErrorHandling` utility from `@lib/commandUtils` to standardize
|
||||||
|
error handling across all commands. It handles `deferReply`, `UserError` display,
|
||||||
|
and unexpected error logging automatically.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
|
export const myCommand = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("mycommand")
|
||||||
|
.setDescription("Does something"),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const result = await service.method();
|
||||||
|
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
||||||
|
},
|
||||||
|
{ ephemeral: true } // optional: makes the deferred reply ephemeral
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `ephemeral` — whether `deferReply` should be ephemeral
|
||||||
|
- `successMessage` — a simple string to send on success
|
||||||
|
- `onSuccess` — a callback invoked with the operation result
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test File Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||||
|
|
||||||
|
// 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` |
|
||||||
|
| Error handler | `bot/lib/commandUtils.ts` |
|
||||||
73
Dockerfile
73
Dockerfile
@@ -1,22 +1,77 @@
|
|||||||
|
# ============================================
|
||||||
|
# Base stage - shared configuration
|
||||||
|
# ============================================
|
||||||
FROM oven/bun:latest AS base
|
FROM oven/bun:latest AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
# Install system dependencies with cleanup in same layer
|
||||||
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends git && \
|
||||||
|
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
|
|
||||||
# Install root project dependencies
|
# ============================================
|
||||||
|
# Dependencies stage - installs all deps
|
||||||
|
# ============================================
|
||||||
|
FROM base AS deps
|
||||||
|
|
||||||
|
# Copy only package files first (better layer caching)
|
||||||
COPY package.json bun.lock ./
|
COPY package.json bun.lock ./
|
||||||
|
COPY panel/package.json panel/
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
RUN bun install --frozen-lockfile
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
# Install web project dependencies
|
# ============================================
|
||||||
COPY web/package.json web/bun.lock ./web/
|
# Development stage - for local dev with volume mounts
|
||||||
RUN cd web && bun install --frozen-lockfile
|
# ============================================
|
||||||
|
FROM base AS development
|
||||||
|
|
||||||
# Copy source code
|
# Copy dependencies from deps stage
|
||||||
COPY . .
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
|
||||||
# Expose ports (3000 for web dashboard)
|
# Expose ports
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Default command
|
# Default command
|
||||||
CMD ["bun", "run", "dev"]
|
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"]
|
||||||
|
|||||||
66
README.md
66
README.md
@@ -10,21 +10,40 @@
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
|
### Discord Bot
|
||||||
* **Class System**: Users can join different classes.
|
* **Class System**: Users can join different classes.
|
||||||
* **Economy**: Complete economy system with balance, transactions, and daily rewards.
|
* **Economy**: Complete economy system with balance, transactions, and daily rewards.
|
||||||
* **Inventory & Items**: sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management.
|
* **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.
|
* **Leveling**: XP-based leveling system to track user activity and progress.
|
||||||
* **Quests**: Quest system with requirements and rewards.
|
* **Quests**: Quest system with requirements and rewards.
|
||||||
* **Trading**: Secure trading system between users.
|
* **Trading**: Secure trading system between users.
|
||||||
* **Lootdrops**: Random loot drops in channels to engage users.
|
* **Lootdrops**: Random loot drops in channels to engage users.
|
||||||
* **Admin Tools**: Administrative commands for server management.
|
* **Admin Tools**: Administrative commands for server management.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
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
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
* **Runtime**: [Bun](https://bun.sh/)
|
* **Runtime**: [Bun](https://bun.sh/)
|
||||||
* **Framework**: [Discord.js](https://discord.js.org/)
|
* **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/)
|
* **Database**: [PostgreSQL](https://www.postgresql.org/)
|
||||||
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
||||||
* **Validation**: [Zod](https://zod.dev/)
|
* **Validation**: [Zod](https://zod.dev/)
|
||||||
@@ -74,12 +93,14 @@ Aurora is a powerful Discord bot designed to facilitate RPG-like elements within
|
|||||||
bun run db:push
|
bun run db:push
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running the Bot
|
### Running the Bot & API
|
||||||
|
|
||||||
**Development Mode** (with hot reload):
|
**Development Mode** (with hot reload):
|
||||||
```bash
|
```bash
|
||||||
bun run dev
|
bun run dev
|
||||||
```
|
```
|
||||||
|
* Bot: Online in Discord
|
||||||
|
* API: http://localhost:3000
|
||||||
|
|
||||||
**Production Mode**:
|
**Production Mode**:
|
||||||
Build and run with Docker (recommended):
|
Build and run with Docker (recommended):
|
||||||
@@ -87,27 +108,46 @@ Build and run with Docker (recommended):
|
|||||||
docker compose up -d app
|
docker compose up -d app
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 🔐 Accessing Production Services (SSH Tunnel)
|
||||||
|
|
||||||
|
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 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
|
## 📜 Scripts
|
||||||
|
|
||||||
* `bun run dev`: Start the bot in watch mode.
|
* `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 generate`: Generate Drizzle migrations.
|
||||||
* `bun run migrate`: Apply migrations (via Docker).
|
* `bun run migrate`: Apply migrations (via Docker).
|
||||||
* `bun run db:push`: Push, schema to DB (via Docker).
|
|
||||||
* `bun run db:studio`: Open Drizzle Studio to inspect the database.
|
* `bun run db:studio`: Open Drizzle Studio to inspect the database.
|
||||||
* `bun test`: Run tests.
|
* `bun test`: Run tests.
|
||||||
|
|
||||||
## 📂 Project Structure
|
## 📂 Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
├── src
|
├── bot # Discord Bot logic & entry point
|
||||||
│ ├── commands # Slash commands
|
├── web # REST API Server
|
||||||
│ ├── events # Discord event handlers
|
├── shared # Shared code (Database, Config, Types)
|
||||||
│ ├── modules # Feature modules (Economy, Inventory, etc.)
|
|
||||||
│ ├── db # Database schema and connection
|
|
||||||
│ └── lib # Shared utilities
|
|
||||||
├── drizzle # Drizzle migration files
|
├── drizzle # Drizzle migration files
|
||||||
├── config # Configuration files
|
├── scripts # Utility scripts
|
||||||
└── scripts # Utility scripts
|
├── docker-compose.yml
|
||||||
|
└── package.json
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|||||||
0
web/.gitignore → api/.gitignore
vendored
0
web/.gitignore → api/.gitignore
vendored
30
api/README.md
Normal file
30
api/README.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Aurora Web API
|
||||||
|
|
||||||
|
The web API provides a REST interface and WebSocket support for accessing Aurora bot data and configuration.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
- `GET /api/stats` - Real-time bot statistics
|
||||||
|
- `GET /api/settings` - Bot configuration
|
||||||
|
- `GET /api/users` - User data
|
||||||
|
- `GET /api/items` - Item catalog
|
||||||
|
- `GET /api/quests` - Quest information
|
||||||
|
- `GET /api/transactions` - Economy data
|
||||||
|
- `GET /api/health` - Health check
|
||||||
|
|
||||||
|
## WebSocket
|
||||||
|
|
||||||
|
Connect to `/ws` for real-time updates:
|
||||||
|
- Stats broadcasts every 5 seconds
|
||||||
|
- Event notifications via system bus
|
||||||
|
- PING/PONG heartbeat support
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
The API runs automatically when you start the bot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The API will be available at `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
106
api/src/routes/actions.routes.ts
Normal file
106
api/src/routes/actions.routes.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Administrative action endpoints for Aurora API.
|
||||||
|
* Provides endpoints for system administration tasks like cache clearing
|
||||||
|
* and maintenance mode toggling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import { jsonResponse, errorResponse, parseBody, withErrorHandling } from "./utils";
|
||||||
|
import { MaintenanceModeSchema } from "./schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin actions routes handler.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - POST /api/actions/reload-commands - Reload bot slash commands
|
||||||
|
* - POST /api/actions/clear-cache - Clear internal caches
|
||||||
|
* - POST /api/actions/maintenance-mode - Toggle maintenance mode
|
||||||
|
*/
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method, req } = ctx;
|
||||||
|
|
||||||
|
// Only handle POST requests to /api/actions/*
|
||||||
|
if (!pathname.startsWith("/api/actions/") || method !== "POST") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { actionService } = await import("@shared/modules/admin/action.service");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/actions/reload-commands
|
||||||
|
* @description Triggers a reload of all Discord slash commands.
|
||||||
|
* Useful after modifying command configurations.
|
||||||
|
* @response 200 - `{ success: true, message: string }`
|
||||||
|
* @response 500 - Error reloading commands
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* POST /api/actions/reload-commands
|
||||||
|
*
|
||||||
|
* // Response
|
||||||
|
* { "success": true, "message": "Commands reloaded" }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/actions/reload-commands") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const result = await actionService.reloadCommands();
|
||||||
|
return jsonResponse(result);
|
||||||
|
}, "reload commands");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/actions/clear-cache
|
||||||
|
* @description Clears all internal application caches.
|
||||||
|
* Useful for forcing fresh data fetches.
|
||||||
|
* @response 200 - `{ success: true, message: string }`
|
||||||
|
* @response 500 - Error clearing cache
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* POST /api/actions/clear-cache
|
||||||
|
*
|
||||||
|
* // Response
|
||||||
|
* { "success": true, "message": "Cache cleared" }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/actions/clear-cache") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const result = await actionService.clearCache();
|
||||||
|
return jsonResponse(result);
|
||||||
|
}, "clear cache");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/actions/maintenance-mode
|
||||||
|
* @description Toggles bot maintenance mode on or off.
|
||||||
|
* When enabled, the bot will respond with a maintenance message.
|
||||||
|
*
|
||||||
|
* @body { enabled: boolean, reason?: string }
|
||||||
|
* @response 200 - `{ success: true, enabled: boolean }`
|
||||||
|
* @response 400 - Invalid payload with validation errors
|
||||||
|
* @response 500 - Error toggling maintenance mode
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* POST /api/actions/maintenance-mode
|
||||||
|
* Content-Type: application/json
|
||||||
|
* { "enabled": true, "reason": "Deploying updates..." }
|
||||||
|
*
|
||||||
|
* // Response
|
||||||
|
* { "success": true, "enabled": true }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/actions/maintenance-mode") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const data = await parseBody(req, MaintenanceModeSchema);
|
||||||
|
if (data instanceof Response) return data;
|
||||||
|
|
||||||
|
const result = await actionService.toggleMaintenanceMode(data.enabled, data.reason);
|
||||||
|
return jsonResponse(result);
|
||||||
|
}, "toggle maintenance mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actionsRoutes: RouteModule = {
|
||||||
|
name: "actions",
|
||||||
|
handler
|
||||||
|
};
|
||||||
83
api/src/routes/assets.routes.ts
Normal file
83
api/src/routes/assets.routes.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Static asset serving for Aurora API.
|
||||||
|
* Serves item images and other assets from the local filesystem.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { join, resolve, dirname } from "path";
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
|
||||||
|
// Resolve assets root directory
|
||||||
|
const currentDir = dirname(new URL(import.meta.url).pathname);
|
||||||
|
const assetsRoot = resolve(currentDir, "../../../bot/assets/graphics");
|
||||||
|
|
||||||
|
/** MIME types for supported image formats */
|
||||||
|
const MIME_TYPES: Record<string, string> = {
|
||||||
|
"png": "image/png",
|
||||||
|
"jpg": "image/jpeg",
|
||||||
|
"jpeg": "image/jpeg",
|
||||||
|
"webp": "image/webp",
|
||||||
|
"gif": "image/gif",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assets routes handler.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - GET /assets/* - Serve static files from the assets directory
|
||||||
|
*/
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method } = ctx;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /assets/*
|
||||||
|
* @description Serves static asset files (images) with caching headers.
|
||||||
|
* Assets are served from the bot's graphics directory.
|
||||||
|
*
|
||||||
|
* Path security: Path traversal attacks are prevented by validating
|
||||||
|
* that the resolved path stays within the assets root.
|
||||||
|
*
|
||||||
|
* @response 200 - File content with appropriate MIME type
|
||||||
|
* @response 403 - Forbidden (path traversal attempt)
|
||||||
|
* @response 404 - File not found
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* GET /assets/items/1.png
|
||||||
|
*
|
||||||
|
* // Response Headers
|
||||||
|
* Content-Type: image/png
|
||||||
|
* Cache-Control: public, max-age=86400
|
||||||
|
*/
|
||||||
|
if (pathname.startsWith("/assets/") && method === "GET") {
|
||||||
|
const assetPath = pathname.replace("/assets/", "");
|
||||||
|
|
||||||
|
// Security: prevent path traversal attacks
|
||||||
|
const safePath = join(assetsRoot, assetPath);
|
||||||
|
if (!safePath.startsWith(assetsRoot)) {
|
||||||
|
return new Response("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = Bun.file(safePath);
|
||||||
|
if (await file.exists()) {
|
||||||
|
// Determine MIME type based on extension
|
||||||
|
const ext = safePath.split(".").pop()?.toLowerCase();
|
||||||
|
const contentType = MIME_TYPES[ext || ""] || "application/octet-stream";
|
||||||
|
|
||||||
|
return new Response(file, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const assetsRoutes: RouteModule = {
|
||||||
|
name: "assets",
|
||||||
|
handler
|
||||||
|
};
|
||||||
233
api/src/routes/auth.routes.ts
Normal file
233
api/src/routes/auth.routes.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Discord OAuth2 authentication routes for the admin panel.
|
||||||
|
* Handles login flow, callback, logout, and session management.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import { jsonResponse, errorResponse } from "./utils";
|
||||||
|
import { logger } from "@shared/lib/logger";
|
||||||
|
|
||||||
|
// In-memory session store: token → { discordId, username, avatar, expiresAt }
|
||||||
|
export interface Session {
|
||||||
|
discordId: string;
|
||||||
|
username: string;
|
||||||
|
avatar: string | null;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = new Map<string, Session>();
|
||||||
|
const redirects = new Map<string, string>(); // redirect token -> return_to URL
|
||||||
|
|
||||||
|
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
|
||||||
|
function getEnv(key: string): string {
|
||||||
|
const val = process.env[key];
|
||||||
|
if (!val) throw new Error(`Missing env: ${key}`);
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAdminIds(): string[] {
|
||||||
|
const raw = process.env.ADMIN_USER_IDS ?? "";
|
||||||
|
return raw.split(",").map(s => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateToken(): string {
|
||||||
|
const bytes = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get session from request cookie */
|
||||||
|
export function getSession(req: Request): Session | null {
|
||||||
|
const cookies = parseCookies(req.headers.get("cookie"));
|
||||||
|
const token = cookies["aurora_session"];
|
||||||
|
if (!token) return null;
|
||||||
|
const session = sessions.get(token);
|
||||||
|
if (!session) return null;
|
||||||
|
if (Date.now() > session.expiresAt) {
|
||||||
|
sessions.delete(token);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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";
|
||||||
|
|
||||||
|
// Store return_to URL if provided
|
||||||
|
const returnTo = ctx.url.searchParams.get("return_to") || "/";
|
||||||
|
const redirectToken = generateToken();
|
||||||
|
redirects.set(redirectToken, returnTo);
|
||||||
|
|
||||||
|
const url = `https://discord.com/oauth2/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&scope=${scope}`;
|
||||||
|
|
||||||
|
// Set a temporary cookie with the redirect token
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: {
|
||||||
|
Location: url,
|
||||||
|
"Set-Cookie": `aurora_redirect=${redirectToken}; Path=/; Max-Age=600; SameSite=Lax`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} 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`;
|
||||||
|
|
||||||
|
// Exchange code for token
|
||||||
|
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 allowlist
|
||||||
|
const adminIds = getAdminIds();
|
||||||
|
if (adminIds.length > 0 && !adminIds.includes(user.id)) {
|
||||||
|
logger.warn("auth", `Unauthorized login attempt by ${user.username} (${user.id})`);
|
||||||
|
return new Response(
|
||||||
|
`<html><body><h1>Access Denied</h1><p>Your Discord account is not authorized.</p></body></html>`,
|
||||||
|
{ status: 403, headers: { "Content-Type": "text/html" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const token = generateToken();
|
||||||
|
sessions.set(token, {
|
||||||
|
discordId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
avatar: user.avatar,
|
||||||
|
expiresAt: Date.now() + SESSION_MAX_AGE,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("auth", `Admin login: ${user.username} (${user.id})`);
|
||||||
|
|
||||||
|
// Get return_to URL from redirect token cookie
|
||||||
|
const cookies = parseCookies(ctx.req.headers.get("cookie"));
|
||||||
|
const redirectToken = cookies["aurora_redirect"];
|
||||||
|
let returnTo = redirectToken && redirects.get(redirectToken) ? redirects.get(redirectToken)! : "/";
|
||||||
|
if (redirectToken) redirects.delete(redirectToken);
|
||||||
|
|
||||||
|
// Only allow redirects to localhost or relative paths (prevent open redirect)
|
||||||
|
try {
|
||||||
|
const parsed = new URL(returnTo, baseUrl);
|
||||||
|
if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1") {
|
||||||
|
returnTo = "/";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
returnTo = "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to panel with session cookie
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: {
|
||||||
|
Location: returnTo,
|
||||||
|
"Set-Cookie": `aurora_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${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") {
|
||||||
|
const cookies = parseCookies(ctx.req.headers.get("cookie"));
|
||||||
|
const token = cookies["aurora_session"];
|
||||||
|
if (token) sessions.delete(token);
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Set-Cookie": "aurora_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=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 }, 401);
|
||||||
|
return jsonResponse({
|
||||||
|
authenticated: true,
|
||||||
|
user: {
|
||||||
|
discordId: session.discordId,
|
||||||
|
username: session.username,
|
||||||
|
avatar: session.avatar,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authRoutes: RouteModule = {
|
||||||
|
name: "auth",
|
||||||
|
handler,
|
||||||
|
};
|
||||||
155
api/src/routes/classes.routes.ts
Normal file
155
api/src/routes/classes.routes.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Class management endpoints for Aurora API.
|
||||||
|
* Provides CRUD operations for player classes/guilds.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import {
|
||||||
|
jsonResponse,
|
||||||
|
errorResponse,
|
||||||
|
parseBody,
|
||||||
|
parseStringIdFromPath,
|
||||||
|
withErrorHandling
|
||||||
|
} from "./utils";
|
||||||
|
import { CreateClassSchema, UpdateClassSchema } from "./schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classes routes handler.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - GET /api/classes - List all classes
|
||||||
|
* - POST /api/classes - Create a new class
|
||||||
|
* - PUT /api/classes/:id - Update a class
|
||||||
|
* - DELETE /api/classes/:id - Delete a class
|
||||||
|
*/
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method, req } = ctx;
|
||||||
|
|
||||||
|
// Only handle requests to /api/classes*
|
||||||
|
if (!pathname.startsWith("/api/classes")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { classService } = await import("@shared/modules/class/class.service");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/classes
|
||||||
|
* @description Returns all classes/guilds in the system.
|
||||||
|
*
|
||||||
|
* @response 200 - `{ classes: Class[] }`
|
||||||
|
* @response 500 - Error fetching classes
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Response
|
||||||
|
* {
|
||||||
|
* "classes": [
|
||||||
|
* { "id": "1", "name": "Warrior", "balance": "5000", "roleId": "123456789" }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/classes" && method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const classes = await classService.getAllClasses();
|
||||||
|
return jsonResponse({ classes });
|
||||||
|
}, "fetch classes");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/classes
|
||||||
|
* @description Creates a new class/guild.
|
||||||
|
*
|
||||||
|
* @body {
|
||||||
|
* id: string | number (required) - Unique class identifier,
|
||||||
|
* name: string (required) - Class display name,
|
||||||
|
* balance?: string | number - Initial class balance (default: 0),
|
||||||
|
* roleId?: string - Associated Discord role ID
|
||||||
|
* }
|
||||||
|
* @response 201 - `{ success: true, class: Class }`
|
||||||
|
* @response 400 - Missing required fields
|
||||||
|
* @response 500 - Error creating class
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* POST /api/classes
|
||||||
|
* { "id": "2", "name": "Mage", "balance": "0", "roleId": "987654321" }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/classes" && method === "POST") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const data = await req.json() as Record<string, any>;
|
||||||
|
|
||||||
|
if (!data.id || !data.name || typeof data.name !== 'string') {
|
||||||
|
return errorResponse("Missing required fields: id and name are required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newClass = await classService.createClass({
|
||||||
|
id: BigInt(data.id),
|
||||||
|
name: data.name,
|
||||||
|
balance: data.balance ? BigInt(data.balance) : 0n,
|
||||||
|
roleId: data.roleId || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse({ success: true, class: newClass }, 201);
|
||||||
|
}, "create class");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route PUT /api/classes/:id
|
||||||
|
* @description Updates an existing class.
|
||||||
|
*
|
||||||
|
* @param id - Class ID
|
||||||
|
* @body {
|
||||||
|
* name?: string - Updated class name,
|
||||||
|
* balance?: string | number - Updated balance,
|
||||||
|
* roleId?: string - Updated Discord role ID
|
||||||
|
* }
|
||||||
|
* @response 200 - `{ success: true, class: Class }`
|
||||||
|
* @response 404 - Class not found
|
||||||
|
* @response 500 - Error updating class
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/classes\/\d+$/) && method === "PUT") {
|
||||||
|
const id = parseStringIdFromPath(pathname);
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const data = await req.json() as Record<string, any>;
|
||||||
|
|
||||||
|
const updateData: any = {};
|
||||||
|
if (data.name !== undefined) updateData.name = data.name;
|
||||||
|
if (data.balance !== undefined) updateData.balance = BigInt(data.balance);
|
||||||
|
if (data.roleId !== undefined) updateData.roleId = data.roleId;
|
||||||
|
|
||||||
|
const updatedClass = await classService.updateClass(BigInt(id), updateData);
|
||||||
|
|
||||||
|
if (!updatedClass) {
|
||||||
|
return errorResponse("Class not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ success: true, class: updatedClass });
|
||||||
|
}, "update class");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route DELETE /api/classes/:id
|
||||||
|
* @description Deletes a class. Users assigned to this class will need to be reassigned.
|
||||||
|
*
|
||||||
|
* @param id - Class ID
|
||||||
|
* @response 204 - Class deleted (no content)
|
||||||
|
* @response 500 - Error deleting class
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/classes\/\d+$/) && method === "DELETE") {
|
||||||
|
const id = parseStringIdFromPath(pathname);
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
await classService.deleteClass(BigInt(id));
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}, "delete class");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const classesRoutes: RouteModule = {
|
||||||
|
name: "classes",
|
||||||
|
handler
|
||||||
|
};
|
||||||
64
api/src/routes/guild-settings.routes.ts
Normal file
64
api/src/routes/guild-settings.routes.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Guild settings endpoints for Aurora API.
|
||||||
|
* Provides endpoints for reading and updating per-guild configuration
|
||||||
|
* stored in the database.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
|
||||||
|
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
|
||||||
|
import { invalidateGuildConfigCache } from "@shared/lib/config";
|
||||||
|
|
||||||
|
const GUILD_SETTINGS_PATTERN = /^\/api\/guilds\/(\d+)\/settings$/;
|
||||||
|
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method, req } = ctx;
|
||||||
|
|
||||||
|
const match = pathname.match(GUILD_SETTINGS_PATTERN);
|
||||||
|
if (!match || !match[1]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guildId = match[1];
|
||||||
|
|
||||||
|
if (method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const settings = await guildSettingsService.getSettings(guildId);
|
||||||
|
if (!settings) {
|
||||||
|
return jsonResponse({ guildId, configured: false });
|
||||||
|
}
|
||||||
|
return jsonResponse({ ...settings, guildId, configured: true });
|
||||||
|
}, "fetch guild settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "PUT" || method === "PATCH") {
|
||||||
|
try {
|
||||||
|
const body = await req.json() as Record<string, unknown>;
|
||||||
|
const { guildId: _, ...settings } = body;
|
||||||
|
const result = await guildSettingsService.upsertSettings({
|
||||||
|
guildId,
|
||||||
|
...settings,
|
||||||
|
} as Parameters<typeof guildSettingsService.upsertSettings>[0]);
|
||||||
|
invalidateGuildConfigCache(guildId);
|
||||||
|
return jsonResponse(result);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return errorResponse("Failed to save guild settings", 400, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "DELETE") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
await guildSettingsService.deleteSettings(guildId);
|
||||||
|
invalidateGuildConfigCache(guildId);
|
||||||
|
return jsonResponse({ success: true });
|
||||||
|
}, "delete guild settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const guildSettingsRoutes: RouteModule = {
|
||||||
|
name: "guild-settings",
|
||||||
|
handler
|
||||||
|
};
|
||||||
36
api/src/routes/health.routes.ts
Normal file
36
api/src/routes/health.routes.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Health check endpoint for Aurora API.
|
||||||
|
* Provides a simple health status endpoint for monitoring and load balancers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health routes handler.
|
||||||
|
*
|
||||||
|
* @route GET /api/health
|
||||||
|
* @description Returns server health status with timestamp.
|
||||||
|
* @response 200 - `{ status: "ok", timestamp: number }`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* GET /api/health
|
||||||
|
*
|
||||||
|
* // Response
|
||||||
|
* { "status": "ok", "timestamp": 1707408000000 }
|
||||||
|
*/
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
if (ctx.pathname === "/api/health" && ctx.method === "GET") {
|
||||||
|
return Response.json({
|
||||||
|
status: "ok",
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const healthRoutes: RouteModule = {
|
||||||
|
name: "health",
|
||||||
|
handler
|
||||||
|
};
|
||||||
93
api/src/routes/index.ts
Normal file
93
api/src/routes/index.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Route registration module for Aurora API.
|
||||||
|
* Aggregates all route handlers and provides a unified request handler.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import { authRoutes, isAuthenticated } from "./auth.routes";
|
||||||
|
import { healthRoutes } from "./health.routes";
|
||||||
|
import { statsRoutes } from "./stats.routes";
|
||||||
|
import { actionsRoutes } from "./actions.routes";
|
||||||
|
import { questsRoutes } from "./quests.routes";
|
||||||
|
import { settingsRoutes } from "./settings.routes";
|
||||||
|
import { guildSettingsRoutes } from "./guild-settings.routes";
|
||||||
|
import { itemsRoutes } from "./items.routes";
|
||||||
|
import { usersRoutes } from "./users.routes";
|
||||||
|
import { classesRoutes } from "./classes.routes";
|
||||||
|
import { moderationRoutes } from "./moderation.routes";
|
||||||
|
import { transactionsRoutes } from "./transactions.routes";
|
||||||
|
import { lootdropsRoutes } from "./lootdrops.routes";
|
||||||
|
import { assetsRoutes } from "./assets.routes";
|
||||||
|
import { errorResponse } from "./utils";
|
||||||
|
|
||||||
|
/** Routes that do NOT require authentication */
|
||||||
|
const publicRoutes: RouteModule[] = [
|
||||||
|
authRoutes,
|
||||||
|
healthRoutes,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Routes that require an authenticated admin session */
|
||||||
|
const protectedRoutes: RouteModule[] = [
|
||||||
|
statsRoutes,
|
||||||
|
actionsRoutes,
|
||||||
|
questsRoutes,
|
||||||
|
settingsRoutes,
|
||||||
|
guildSettingsRoutes,
|
||||||
|
itemsRoutes,
|
||||||
|
usersRoutes,
|
||||||
|
classesRoutes,
|
||||||
|
moderationRoutes,
|
||||||
|
transactionsRoutes,
|
||||||
|
lootdropsRoutes,
|
||||||
|
assetsRoutes,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main request handler that routes requests to appropriate handlers.
|
||||||
|
*
|
||||||
|
* @param req - The incoming HTTP request
|
||||||
|
* @param url - Parsed URL object
|
||||||
|
* @returns Response from matching route handler, or null if no match
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const response = await handleRequest(req, url);
|
||||||
|
* if (response) return response;
|
||||||
|
* return new Response("Not Found", { status: 404 });
|
||||||
|
*/
|
||||||
|
export async function handleRequest(req: Request, url: URL): Promise<Response | null> {
|
||||||
|
const ctx: RouteContext = {
|
||||||
|
req,
|
||||||
|
url,
|
||||||
|
method: req.method,
|
||||||
|
pathname: url.pathname,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try public routes first (auth, health)
|
||||||
|
for (const module of publicRoutes) {
|
||||||
|
const response = await module.handler(ctx);
|
||||||
|
if (response !== null) return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For API routes, enforce authentication
|
||||||
|
if (ctx.pathname.startsWith("/api/")) {
|
||||||
|
if (!isAuthenticated(req)) {
|
||||||
|
return errorResponse("Unauthorized", 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try protected routes
|
||||||
|
for (const module of protectedRoutes) {
|
||||||
|
const response = await module.handler(ctx);
|
||||||
|
if (response !== null) return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of all registered route module names.
|
||||||
|
* Useful for debugging and documentation.
|
||||||
|
*/
|
||||||
|
export function getRegisteredRoutes(): string[] {
|
||||||
|
return [...publicRoutes, ...protectedRoutes].map(m => m.name);
|
||||||
|
}
|
||||||
371
api/src/routes/items.routes.ts
Normal file
371
api/src/routes/items.routes.ts
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Items management endpoints for Aurora API.
|
||||||
|
* Provides CRUD operations for game items with image upload support.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { join, resolve, dirname } from "path";
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import type { CreateItemDTO, UpdateItemDTO } from "@shared/modules/items/items.service";
|
||||||
|
import {
|
||||||
|
jsonResponse,
|
||||||
|
errorResponse,
|
||||||
|
parseBody,
|
||||||
|
parseIdFromPath,
|
||||||
|
parseQuery,
|
||||||
|
withErrorHandling
|
||||||
|
} from "./utils";
|
||||||
|
import { CreateItemSchema, UpdateItemSchema, ItemQuerySchema } from "./schemas";
|
||||||
|
|
||||||
|
// Resolve assets directory path
|
||||||
|
const currentDir = dirname(new URL(import.meta.url).pathname);
|
||||||
|
const assetsDir = resolve(currentDir, "../../../bot/assets/graphics/items");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates image file by checking magic bytes.
|
||||||
|
* Supports PNG, JPEG, WebP, and GIF formats.
|
||||||
|
*/
|
||||||
|
function validateImageFormat(bytes: Uint8Array): boolean {
|
||||||
|
const isPNG = bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47;
|
||||||
|
const isJPEG = bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF;
|
||||||
|
const isWebP = bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50;
|
||||||
|
const isGIF = bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46;
|
||||||
|
|
||||||
|
return isPNG || isJPEG || isWebP || isGIF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maximum image file size: 15MB */
|
||||||
|
const MAX_IMAGE_SIZE = 15 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Items routes handler.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - GET /api/items - List items with filters
|
||||||
|
* - POST /api/items - Create item (JSON or multipart with image)
|
||||||
|
* - GET /api/items/:id - Get single item
|
||||||
|
* - PUT /api/items/:id - Update item
|
||||||
|
* - DELETE /api/items/:id - Delete item and asset
|
||||||
|
* - POST /api/items/:id/icon - Upload/replace item icon
|
||||||
|
*/
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method, req, url } = ctx;
|
||||||
|
|
||||||
|
// Only handle requests to /api/items*
|
||||||
|
if (!pathname.startsWith("/api/items")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { itemsService } = await import("@shared/modules/items/items.service");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/items
|
||||||
|
* @description Returns a paginated list of items with optional filtering.
|
||||||
|
*
|
||||||
|
* @query search - Filter by name/description (partial match)
|
||||||
|
* @query type - Filter by item type (CONSUMABLE, EQUIPMENT, etc.)
|
||||||
|
* @query rarity - Filter by rarity (C, R, SR, SSR)
|
||||||
|
* @query limit - Max results per page (default: 100, max: 100)
|
||||||
|
* @query offset - Pagination offset (default: 0)
|
||||||
|
*
|
||||||
|
* @response 200 - `{ items: Item[], total: number }`
|
||||||
|
* @response 500 - Error fetching items
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* GET /api/items?type=CONSUMABLE&rarity=R&limit=10
|
||||||
|
*
|
||||||
|
* // Response
|
||||||
|
* {
|
||||||
|
* "items": [{ "id": 1, "name": "Health Potion", ... }],
|
||||||
|
* "total": 25
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/items" && method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const filters = {
|
||||||
|
search: url.searchParams.get("search") || undefined,
|
||||||
|
type: url.searchParams.get("type") || undefined,
|
||||||
|
rarity: url.searchParams.get("rarity") || undefined,
|
||||||
|
limit: url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 100,
|
||||||
|
offset: url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await itemsService.getAllItems(filters);
|
||||||
|
return jsonResponse(result);
|
||||||
|
}, "fetch items");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/items
|
||||||
|
* @description Creates a new item. Supports JSON or multipart/form-data with image.
|
||||||
|
*
|
||||||
|
* @body (JSON) {
|
||||||
|
* name: string (required),
|
||||||
|
* type: string (required),
|
||||||
|
* description?: string,
|
||||||
|
* rarity?: "C" | "R" | "SR" | "SSR",
|
||||||
|
* price?: string | number,
|
||||||
|
* usageData?: object
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @body (Multipart) {
|
||||||
|
* data: JSON string with item fields,
|
||||||
|
* image?: File (PNG, JPEG, WebP, GIF - max 15MB)
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @response 201 - `{ success: true, item: Item }`
|
||||||
|
* @response 400 - Missing required fields or invalid image
|
||||||
|
* @response 409 - Item name already exists
|
||||||
|
* @response 500 - Error creating item
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/items" && method === "POST") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const contentType = req.headers.get("content-type") || "";
|
||||||
|
|
||||||
|
let itemData: CreateItemDTO | null = null;
|
||||||
|
let imageFile: File | null = null;
|
||||||
|
|
||||||
|
if (contentType.includes("multipart/form-data")) {
|
||||||
|
const formData = await req.formData();
|
||||||
|
const jsonData = formData.get("data");
|
||||||
|
imageFile = formData.get("image") as File | null;
|
||||||
|
|
||||||
|
if (typeof jsonData === "string") {
|
||||||
|
itemData = JSON.parse(jsonData) as CreateItemDTO;
|
||||||
|
} else {
|
||||||
|
return errorResponse("Missing item data", 400);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
itemData = await req.json() as CreateItemDTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!itemData) {
|
||||||
|
return errorResponse("Missing item data", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!itemData.name || !itemData.type) {
|
||||||
|
return errorResponse("Missing required fields: name and type are required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate name
|
||||||
|
if (await itemsService.isNameTaken(itemData.name)) {
|
||||||
|
return errorResponse("An item with this name already exists", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set placeholder URLs if image will be uploaded
|
||||||
|
const placeholderUrl = "/assets/items/placeholder.png";
|
||||||
|
const createData = {
|
||||||
|
name: itemData.name,
|
||||||
|
description: itemData.description || null,
|
||||||
|
rarity: itemData.rarity || "C",
|
||||||
|
type: itemData.type,
|
||||||
|
price: itemData.price ? BigInt(itemData.price) : null,
|
||||||
|
iconUrl: itemData.iconUrl || placeholderUrl,
|
||||||
|
imageUrl: itemData.imageUrl || placeholderUrl,
|
||||||
|
usageData: itemData.usageData || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the item
|
||||||
|
const item = await itemsService.createItem(createData);
|
||||||
|
|
||||||
|
// If image was provided, save it and update the item
|
||||||
|
if (imageFile && item) {
|
||||||
|
const buffer = await imageFile.arrayBuffer();
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
|
||||||
|
if (!validateImageFormat(bytes)) {
|
||||||
|
await itemsService.deleteItem(item.id);
|
||||||
|
return errorResponse("Invalid image file. Only PNG, JPEG, WebP, and GIF are allowed.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer.byteLength > MAX_IMAGE_SIZE) {
|
||||||
|
await itemsService.deleteItem(item.id);
|
||||||
|
return errorResponse("Image file too large. Maximum size is 15MB.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = `${item.id}.png`;
|
||||||
|
const filePath = join(assetsDir, fileName);
|
||||||
|
await Bun.write(filePath, buffer);
|
||||||
|
|
||||||
|
const assetUrl = `/assets/items/${fileName}`;
|
||||||
|
await itemsService.updateItem(item.id, {
|
||||||
|
iconUrl: assetUrl,
|
||||||
|
imageUrl: assetUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedItem = await itemsService.getItemById(item.id);
|
||||||
|
return jsonResponse({ success: true, item: updatedItem }, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ success: true, item }, 201);
|
||||||
|
}, "create item");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/items/:id
|
||||||
|
* @description Returns a single item by ID.
|
||||||
|
*
|
||||||
|
* @param id - Item ID (numeric)
|
||||||
|
* @response 200 - Full item object
|
||||||
|
* @response 404 - Item not found
|
||||||
|
* @response 500 - Error fetching item
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/items\/\d+$/) && method === "GET") {
|
||||||
|
const id = parseIdFromPath(pathname);
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const item = await itemsService.getItemById(id);
|
||||||
|
if (!item) {
|
||||||
|
return errorResponse("Item not found", 404);
|
||||||
|
}
|
||||||
|
return jsonResponse(item);
|
||||||
|
}, "fetch item");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route PUT /api/items/:id
|
||||||
|
* @description Updates an existing item.
|
||||||
|
*
|
||||||
|
* @param id - Item ID (numeric)
|
||||||
|
* @body Partial item fields to update
|
||||||
|
* @response 200 - `{ success: true, item: Item }`
|
||||||
|
* @response 404 - Item not found
|
||||||
|
* @response 409 - Name already taken by another item
|
||||||
|
* @response 500 - Error updating item
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/items\/\d+$/) && method === "PUT") {
|
||||||
|
const id = parseIdFromPath(pathname);
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const data = await req.json() as Partial<UpdateItemDTO>;
|
||||||
|
|
||||||
|
const existing = await itemsService.getItemById(id);
|
||||||
|
if (!existing) {
|
||||||
|
return errorResponse("Item not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate name (if name is being changed)
|
||||||
|
if (data.name && data.name !== existing.name) {
|
||||||
|
if (await itemsService.isNameTaken(data.name, id)) {
|
||||||
|
return errorResponse("An item with this name already exists", 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update data
|
||||||
|
const updateData: Partial<UpdateItemDTO> = {};
|
||||||
|
if (data.name !== undefined) updateData.name = data.name;
|
||||||
|
if (data.description !== undefined) updateData.description = data.description;
|
||||||
|
if (data.rarity !== undefined) updateData.rarity = data.rarity;
|
||||||
|
if (data.type !== undefined) updateData.type = data.type;
|
||||||
|
if (data.price !== undefined) updateData.price = data.price ? BigInt(data.price) : null;
|
||||||
|
if (data.iconUrl !== undefined) updateData.iconUrl = data.iconUrl;
|
||||||
|
if (data.imageUrl !== undefined) updateData.imageUrl = data.imageUrl;
|
||||||
|
if (data.usageData !== undefined) updateData.usageData = data.usageData;
|
||||||
|
|
||||||
|
const updatedItem = await itemsService.updateItem(id, updateData);
|
||||||
|
return jsonResponse({ success: true, item: updatedItem });
|
||||||
|
}, "update item");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route DELETE /api/items/:id
|
||||||
|
* @description Deletes an item and its associated asset file.
|
||||||
|
*
|
||||||
|
* @param id - Item ID (numeric)
|
||||||
|
* @response 204 - Item deleted (no content)
|
||||||
|
* @response 404 - Item not found
|
||||||
|
* @response 500 - Error deleting item
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/items\/\d+$/) && method === "DELETE") {
|
||||||
|
const id = parseIdFromPath(pathname);
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const existing = await itemsService.getItemById(id);
|
||||||
|
if (!existing) {
|
||||||
|
return errorResponse("Item not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await itemsService.deleteItem(id);
|
||||||
|
|
||||||
|
// Try to delete associated asset file
|
||||||
|
const assetPath = join(assetsDir, `${id}.png`);
|
||||||
|
try {
|
||||||
|
const assetFile = Bun.file(assetPath);
|
||||||
|
if (await assetFile.exists()) {
|
||||||
|
const { unlink } = await import("node:fs/promises");
|
||||||
|
await unlink(assetPath);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Non-critical: log but don't fail
|
||||||
|
const { logger } = await import("@shared/lib/logger");
|
||||||
|
logger.warn("web", `Could not delete asset file for item ${id}`, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}, "delete item");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/items/:id/icon
|
||||||
|
* @description Uploads or replaces an item's icon image.
|
||||||
|
*
|
||||||
|
* @param id - Item ID (numeric)
|
||||||
|
* @body (Multipart) { image: File }
|
||||||
|
* @response 200 - `{ success: true, item: Item }`
|
||||||
|
* @response 400 - No image file or invalid format
|
||||||
|
* @response 404 - Item not found
|
||||||
|
* @response 500 - Error uploading icon
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/items\/\d+\/icon$/) && method === "POST") {
|
||||||
|
const id = parseInt(pathname.split("/")[3] || "0");
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const existing = await itemsService.getItemById(id);
|
||||||
|
if (!existing) {
|
||||||
|
return errorResponse("Item not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await req.formData();
|
||||||
|
const imageFile = formData.get("image") as File | null;
|
||||||
|
|
||||||
|
if (!imageFile) {
|
||||||
|
return errorResponse("No image file provided", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await imageFile.arrayBuffer();
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
|
||||||
|
if (!validateImageFormat(bytes)) {
|
||||||
|
return errorResponse("Invalid image file. Only PNG, JPEG, WebP, and GIF are allowed.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer.byteLength > MAX_IMAGE_SIZE) {
|
||||||
|
return errorResponse("Image file too large. Maximum size is 15MB.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = `${id}.png`;
|
||||||
|
const filePath = join(assetsDir, fileName);
|
||||||
|
await Bun.write(filePath, buffer);
|
||||||
|
|
||||||
|
const assetUrl = `/assets/items/${fileName}`;
|
||||||
|
const updatedItem = await itemsService.updateItem(id, {
|
||||||
|
iconUrl: assetUrl,
|
||||||
|
imageUrl: assetUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse({ success: true, item: updatedItem });
|
||||||
|
}, "upload item icon");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const itemsRoutes: RouteModule = {
|
||||||
|
name: "items",
|
||||||
|
handler
|
||||||
|
};
|
||||||
130
api/src/routes/lootdrops.routes.ts
Normal file
130
api/src/routes/lootdrops.routes.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Lootdrop management endpoints for Aurora API.
|
||||||
|
* Provides endpoints for viewing, spawning, and canceling lootdrops.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import {
|
||||||
|
jsonResponse,
|
||||||
|
errorResponse,
|
||||||
|
parseStringIdFromPath,
|
||||||
|
withErrorHandling
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lootdrops routes handler.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - GET /api/lootdrops - List lootdrops
|
||||||
|
* - POST /api/lootdrops - Spawn a lootdrop
|
||||||
|
* - DELETE /api/lootdrops/:messageId - Cancel/delete a lootdrop
|
||||||
|
*/
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method, req, url } = ctx;
|
||||||
|
|
||||||
|
// Only handle requests to /api/lootdrops*
|
||||||
|
if (!pathname.startsWith("/api/lootdrops")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/lootdrops
|
||||||
|
* @description Returns recent lootdrops, sorted by newest first.
|
||||||
|
*
|
||||||
|
* @query limit - Max results (default: 50)
|
||||||
|
* @response 200 - `{ lootdrops: Lootdrop[] }`
|
||||||
|
* @response 500 - Error fetching lootdrops
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/lootdrops" && method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const { lootdrops } = await import("@shared/db/schema");
|
||||||
|
const { DrizzleClient } = await import("@shared/db/DrizzleClient");
|
||||||
|
const { desc } = await import("drizzle-orm");
|
||||||
|
|
||||||
|
const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
|
||||||
|
|
||||||
|
const result = await DrizzleClient.select()
|
||||||
|
.from(lootdrops)
|
||||||
|
.orderBy(desc(lootdrops.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
return jsonResponse({ lootdrops: result });
|
||||||
|
}, "fetch lootdrops");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/lootdrops
|
||||||
|
* @description Spawns a new lootdrop in a Discord channel.
|
||||||
|
* Requires a valid text channel ID where the bot has permissions.
|
||||||
|
*
|
||||||
|
* @body {
|
||||||
|
* channelId: string (required) - Discord channel ID to spawn in,
|
||||||
|
* amount?: number - Reward amount (random if not specified),
|
||||||
|
* currency?: string - Currency type
|
||||||
|
* }
|
||||||
|
* @response 201 - `{ success: true }`
|
||||||
|
* @response 400 - Invalid channel or missing channelId
|
||||||
|
* @response 500 - Error spawning lootdrop
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* POST /api/lootdrops
|
||||||
|
* { "channelId": "1234567890", "amount": 100, "currency": "Gold" }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/lootdrops" && method === "POST") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||||
|
const { AuroraClient } = await import("../../../bot/lib/BotClient");
|
||||||
|
const { TextChannel } = await import("discord.js");
|
||||||
|
|
||||||
|
const data = await req.json() as Record<string, any>;
|
||||||
|
|
||||||
|
if (!data.channelId) {
|
||||||
|
return errorResponse("Missing required field: channelId", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = await AuroraClient.channels.fetch(data.channelId);
|
||||||
|
|
||||||
|
if (!channel || !(channel instanceof TextChannel)) {
|
||||||
|
return errorResponse("Invalid channel. Must be a TextChannel.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await lootdropService.spawnLootdrop(channel, data.amount, data.currency);
|
||||||
|
|
||||||
|
return jsonResponse({ success: true }, 201);
|
||||||
|
}, "spawn lootdrop");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route DELETE /api/lootdrops/:messageId
|
||||||
|
* @description Cancels and deletes an active lootdrop.
|
||||||
|
* The lootdrop is identified by its Discord message ID.
|
||||||
|
*
|
||||||
|
* @param messageId - Discord message ID of the lootdrop
|
||||||
|
* @response 204 - Lootdrop deleted (no content)
|
||||||
|
* @response 404 - Lootdrop not found
|
||||||
|
* @response 500 - Error deleting lootdrop
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/lootdrops\/[^\/]+$/) && method === "DELETE") {
|
||||||
|
const messageId = parseStringIdFromPath(pathname);
|
||||||
|
if (!messageId) return null;
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||||
|
const success = await lootdropService.deleteLootdrop(messageId);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return errorResponse("Lootdrop not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}, "delete lootdrop");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lootdropsRoutes: RouteModule = {
|
||||||
|
name: "lootdrops",
|
||||||
|
handler
|
||||||
|
};
|
||||||
217
api/src/routes/moderation.routes.ts
Normal file
217
api/src/routes/moderation.routes.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Moderation case management endpoints for Aurora API.
|
||||||
|
* Provides endpoints for viewing, creating, and resolving moderation cases.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import {
|
||||||
|
jsonResponse,
|
||||||
|
errorResponse,
|
||||||
|
parseBody,
|
||||||
|
withErrorHandling
|
||||||
|
} from "./utils";
|
||||||
|
import { CreateCaseSchema, ClearCaseSchema, CaseIdPattern } from "./schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moderation routes handler.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - GET /api/moderation - List cases with filters
|
||||||
|
* - GET /api/moderation/:caseId - Get single case
|
||||||
|
* - POST /api/moderation - Create new case
|
||||||
|
* - PUT /api/moderation/:caseId/clear - Clear/resolve case
|
||||||
|
*/
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method, req, url } = ctx;
|
||||||
|
|
||||||
|
// Only handle requests to /api/moderation*
|
||||||
|
if (!pathname.startsWith("/api/moderation")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { moderationService } = await import("@shared/modules/moderation/moderation.service");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/moderation
|
||||||
|
* @description Returns moderation cases with optional filtering.
|
||||||
|
*
|
||||||
|
* @query userId - Filter by target user ID
|
||||||
|
* @query moderatorId - Filter by moderator ID
|
||||||
|
* @query type - Filter by case type (warn, timeout, kick, ban, note, prune)
|
||||||
|
* @query active - Filter by active status (true/false)
|
||||||
|
* @query limit - Max results (default: 50)
|
||||||
|
* @query offset - Pagination offset (default: 0)
|
||||||
|
*
|
||||||
|
* @response 200 - `{ cases: ModerationCase[] }`
|
||||||
|
* @response 500 - Error fetching cases
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* GET /api/moderation?type=warn&active=true&limit=10
|
||||||
|
*
|
||||||
|
* // Response
|
||||||
|
* {
|
||||||
|
* "cases": [
|
||||||
|
* {
|
||||||
|
* "id": "1",
|
||||||
|
* "caseId": "CASE-0001",
|
||||||
|
* "type": "warn",
|
||||||
|
* "userId": "123456789",
|
||||||
|
* "username": "User1",
|
||||||
|
* "moderatorId": "987654321",
|
||||||
|
* "moderatorName": "Mod1",
|
||||||
|
* "reason": "Spam",
|
||||||
|
* "active": true,
|
||||||
|
* "createdAt": "2024-01-15T12:00:00Z"
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/moderation" && method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const filter: any = {};
|
||||||
|
if (url.searchParams.get("userId")) filter.userId = url.searchParams.get("userId");
|
||||||
|
if (url.searchParams.get("moderatorId")) filter.moderatorId = url.searchParams.get("moderatorId");
|
||||||
|
if (url.searchParams.get("type")) filter.type = url.searchParams.get("type");
|
||||||
|
const activeParam = url.searchParams.get("active");
|
||||||
|
if (activeParam !== null) filter.active = activeParam === "true";
|
||||||
|
filter.limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
|
||||||
|
filter.offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
|
||||||
|
|
||||||
|
const cases = await moderationService.searchCases(filter);
|
||||||
|
return jsonResponse({ cases });
|
||||||
|
}, "fetch moderation cases");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/moderation/:caseId
|
||||||
|
* @description Returns a single moderation case by case ID.
|
||||||
|
* Case IDs follow the format CASE-XXXX (e.g., CASE-0001).
|
||||||
|
*
|
||||||
|
* @param caseId - Case ID in CASE-XXXX format
|
||||||
|
* @response 200 - Full case object
|
||||||
|
* @response 404 - Case not found
|
||||||
|
* @response 500 - Error fetching case
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/moderation\/CASE-\d+$/i) && method === "GET") {
|
||||||
|
const caseId = pathname.split("/").pop()!.toUpperCase();
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const moderationCase = await moderationService.getCaseById(caseId);
|
||||||
|
|
||||||
|
if (!moderationCase) {
|
||||||
|
return errorResponse("Case not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(moderationCase);
|
||||||
|
}, "fetch moderation case");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/moderation
|
||||||
|
* @description Creates a new moderation case.
|
||||||
|
*
|
||||||
|
* @body {
|
||||||
|
* type: "warn" | "timeout" | "kick" | "ban" | "note" | "prune" (required),
|
||||||
|
* userId: string (required) - Target user's Discord ID,
|
||||||
|
* username: string (required) - Target user's username,
|
||||||
|
* moderatorId: string (required) - Moderator's Discord ID,
|
||||||
|
* moderatorName: string (required) - Moderator's username,
|
||||||
|
* reason: string (required) - Reason for the action,
|
||||||
|
* metadata?: object - Additional case metadata (e.g., duration)
|
||||||
|
* }
|
||||||
|
* @response 201 - `{ success: true, case: ModerationCase }`
|
||||||
|
* @response 400 - Missing required fields
|
||||||
|
* @response 500 - Error creating case
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* POST /api/moderation
|
||||||
|
* {
|
||||||
|
* "type": "warn",
|
||||||
|
* "userId": "123456789",
|
||||||
|
* "username": "User1",
|
||||||
|
* "moderatorId": "987654321",
|
||||||
|
* "moderatorName": "Mod1",
|
||||||
|
* "reason": "Rule violation",
|
||||||
|
* "metadata": { "duration": "24h" }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/moderation" && method === "POST") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const data = await req.json() as Record<string, any>;
|
||||||
|
|
||||||
|
if (!data.type || !data.userId || !data.username || !data.moderatorId || !data.moderatorName || !data.reason) {
|
||||||
|
return errorResponse(
|
||||||
|
"Missing required fields: type, userId, username, moderatorId, moderatorName, reason",
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCase = await moderationService.createCase({
|
||||||
|
type: data.type,
|
||||||
|
userId: data.userId,
|
||||||
|
username: data.username,
|
||||||
|
moderatorId: data.moderatorId,
|
||||||
|
moderatorName: data.moderatorName,
|
||||||
|
reason: data.reason,
|
||||||
|
metadata: data.metadata || {},
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse({ success: true, case: newCase }, 201);
|
||||||
|
}, "create moderation case");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route PUT /api/moderation/:caseId/clear
|
||||||
|
* @description Clears/resolves a moderation case.
|
||||||
|
* Sets the case as inactive and records who cleared it.
|
||||||
|
*
|
||||||
|
* @param caseId - Case ID in CASE-XXXX format
|
||||||
|
* @body {
|
||||||
|
* clearedBy: string (required) - Discord ID of user clearing the case,
|
||||||
|
* clearedByName: string (required) - Username of user clearing the case,
|
||||||
|
* reason?: string - Reason for clearing (default: "Cleared via API")
|
||||||
|
* }
|
||||||
|
* @response 200 - `{ success: true, case: ModerationCase }`
|
||||||
|
* @response 400 - Missing required fields
|
||||||
|
* @response 404 - Case not found
|
||||||
|
* @response 500 - Error clearing case
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* PUT /api/moderation/CASE-0001/clear
|
||||||
|
* { "clearedBy": "987654321", "clearedByName": "Admin1", "reason": "Appeal accepted" }
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/moderation\/CASE-\d+\/clear$/i) && method === "PUT") {
|
||||||
|
const caseId = (pathname.split("/")[3] || "").toUpperCase();
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const data = await req.json() as Record<string, any>;
|
||||||
|
|
||||||
|
if (!data.clearedBy || !data.clearedByName) {
|
||||||
|
return errorResponse("Missing required fields: clearedBy, clearedByName", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedCase = await moderationService.clearCase({
|
||||||
|
caseId,
|
||||||
|
clearedBy: data.clearedBy,
|
||||||
|
clearedByName: data.clearedByName,
|
||||||
|
reason: data.reason || "Cleared via API",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updatedCase) {
|
||||||
|
return errorResponse("Case not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ success: true, case: updatedCase });
|
||||||
|
}, "clear moderation case");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const moderationRoutes: RouteModule = {
|
||||||
|
name: "moderation",
|
||||||
|
handler
|
||||||
|
};
|
||||||
207
api/src/routes/quests.routes.ts
Normal file
207
api/src/routes/quests.routes.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Quest management endpoints for Aurora API.
|
||||||
|
* Provides CRUD operations for game quests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import { jsonResponse, errorResponse, parseIdFromPath, withErrorHandling } from "./utils";
|
||||||
|
import { CreateQuestSchema, UpdateQuestSchema } from "@shared/modules/quest/quest.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quest routes handler.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - GET /api/quests - List all quests
|
||||||
|
* - POST /api/quests - Create a new quest
|
||||||
|
* - PUT /api/quests/:id - Update an existing quest
|
||||||
|
* - DELETE /api/quests/:id - Delete a quest
|
||||||
|
*/
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method, req } = ctx;
|
||||||
|
|
||||||
|
// Only handle requests to /api/quests*
|
||||||
|
if (!pathname.startsWith("/api/quests")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { questService } = await import("@shared/modules/quest/quest.service");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/quests
|
||||||
|
* @description Returns all quests in the system.
|
||||||
|
* @response 200 - `{ success: true, data: Quest[] }`
|
||||||
|
* @response 500 - Error fetching quests
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Response
|
||||||
|
* {
|
||||||
|
* "success": true,
|
||||||
|
* "data": [
|
||||||
|
* {
|
||||||
|
* "id": 1,
|
||||||
|
* "name": "Daily Login",
|
||||||
|
* "description": "Login once to claim",
|
||||||
|
* "triggerEvent": "login",
|
||||||
|
* "requirements": { "target": 1 },
|
||||||
|
* "rewards": { "xp": 50, "balance": 100 }
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/quests" && method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const quests = await questService.getAllQuests();
|
||||||
|
return jsonResponse({
|
||||||
|
success: true,
|
||||||
|
data: quests.map(q => ({
|
||||||
|
id: q.id,
|
||||||
|
name: q.name,
|
||||||
|
description: q.description,
|
||||||
|
triggerEvent: q.triggerEvent,
|
||||||
|
requirements: q.requirements,
|
||||||
|
rewards: q.rewards,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}, "fetch quests");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/quests
|
||||||
|
* @description Creates a new quest.
|
||||||
|
*
|
||||||
|
* @body {
|
||||||
|
* name: string,
|
||||||
|
* description?: string,
|
||||||
|
* triggerEvent: string,
|
||||||
|
* target: number,
|
||||||
|
* xpReward: number,
|
||||||
|
* balanceReward: number
|
||||||
|
* }
|
||||||
|
* @response 200 - `{ success: true, quest: Quest }`
|
||||||
|
* @response 400 - Validation error
|
||||||
|
* @response 500 - Error creating quest
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* POST /api/quests
|
||||||
|
* {
|
||||||
|
* "name": "Win 5 Battles",
|
||||||
|
* "description": "Defeat 5 enemies in combat",
|
||||||
|
* "triggerEvent": "battle_win",
|
||||||
|
* "target": 5,
|
||||||
|
* "xpReward": 200,
|
||||||
|
* "balanceReward": 500
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/quests" && method === "POST") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const rawData = await req.json();
|
||||||
|
const parseResult = CreateQuestSchema.safeParse(rawData);
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
return Response.json({
|
||||||
|
error: "Invalid payload",
|
||||||
|
issues: parseResult.error.issues.map(i => ({ path: i.path, message: i.message }))
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parseResult.data;
|
||||||
|
const result = await questService.createQuest({
|
||||||
|
name: data.name,
|
||||||
|
description: data.description || "",
|
||||||
|
triggerEvent: data.triggerEvent,
|
||||||
|
requirements: { target: data.target },
|
||||||
|
rewards: {
|
||||||
|
xp: data.xpReward,
|
||||||
|
balance: data.balanceReward
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse({ success: true, quest: result[0] });
|
||||||
|
}, "create quest");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route PUT /api/quests/:id
|
||||||
|
* @description Updates an existing quest by ID.
|
||||||
|
*
|
||||||
|
* @param id - Quest ID (numeric)
|
||||||
|
* @body Partial quest fields to update
|
||||||
|
* @response 200 - `{ success: true, quest: Quest }`
|
||||||
|
* @response 400 - Invalid quest ID or validation error
|
||||||
|
* @response 404 - Quest not found
|
||||||
|
* @response 500 - Error updating quest
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/quests\/\d+$/) && method === "PUT") {
|
||||||
|
const id = parseIdFromPath(pathname);
|
||||||
|
if (!id) {
|
||||||
|
return errorResponse("Invalid quest ID", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const rawData = await req.json();
|
||||||
|
const parseResult = UpdateQuestSchema.safeParse(rawData);
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
return Response.json({
|
||||||
|
error: "Invalid payload",
|
||||||
|
issues: parseResult.error.issues.map(i => ({ path: i.path, message: i.message }))
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parseResult.data;
|
||||||
|
const result = await questService.updateQuest(id, {
|
||||||
|
...(data.name !== undefined && { name: data.name }),
|
||||||
|
...(data.description !== undefined && { description: data.description }),
|
||||||
|
...(data.triggerEvent !== undefined && { triggerEvent: data.triggerEvent }),
|
||||||
|
...(data.target !== undefined && { requirements: { target: data.target } }),
|
||||||
|
...((data.xpReward !== undefined || data.balanceReward !== undefined) && {
|
||||||
|
rewards: {
|
||||||
|
xp: data.xpReward ?? 0,
|
||||||
|
balance: data.balanceReward ?? 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result || result.length === 0) {
|
||||||
|
return errorResponse("Quest not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ success: true, quest: result[0] });
|
||||||
|
}, "update quest");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route DELETE /api/quests/:id
|
||||||
|
* @description Deletes a quest by ID.
|
||||||
|
*
|
||||||
|
* @param id - Quest ID (numeric)
|
||||||
|
* @response 200 - `{ success: true, deleted: number }`
|
||||||
|
* @response 400 - Invalid quest ID
|
||||||
|
* @response 404 - Quest not found
|
||||||
|
* @response 500 - Error deleting quest
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/quests\/\d+$/) && method === "DELETE") {
|
||||||
|
const id = parseIdFromPath(pathname);
|
||||||
|
if (!id) {
|
||||||
|
return errorResponse("Invalid quest ID", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const result = await questService.deleteQuest(id);
|
||||||
|
|
||||||
|
if (!result || result.length === 0) {
|
||||||
|
return errorResponse("Quest not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ success: true, deleted: (result[0] as { id: number }).id });
|
||||||
|
}, "delete quest");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const questsRoutes: RouteModule = {
|
||||||
|
name: "quests",
|
||||||
|
handler
|
||||||
|
};
|
||||||
274
api/src/routes/schemas.ts
Normal file
274
api/src/routes/schemas.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Centralized Zod validation schemas for all Aurora API endpoints.
|
||||||
|
* Provides type-safe request/response validation for every entity in the system.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Common Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard pagination query parameters.
|
||||||
|
*/
|
||||||
|
export const PaginationSchema = z.object({
|
||||||
|
limit: z.coerce.number().min(1).max(100).optional().default(50),
|
||||||
|
offset: z.coerce.number().min(0).optional().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Numeric ID parameter validation.
|
||||||
|
*/
|
||||||
|
export const NumericIdSchema = z.coerce.number().int().positive();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discord snowflake ID validation (string of digits).
|
||||||
|
*/
|
||||||
|
export const SnowflakeIdSchema = z.string().regex(/^\d{17,20}$/, "Invalid Discord ID format");
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Items Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valid item types in the system.
|
||||||
|
*/
|
||||||
|
export const ItemTypeEnum = z.enum([
|
||||||
|
"CONSUMABLE",
|
||||||
|
"EQUIPMENT",
|
||||||
|
"MATERIAL",
|
||||||
|
"LOOTBOX",
|
||||||
|
"COLLECTIBLE",
|
||||||
|
"KEY",
|
||||||
|
"TOOL"
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valid item rarities.
|
||||||
|
*/
|
||||||
|
export const ItemRarityEnum = z.enum(["C", "R", "SR", "SSR"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query parameters for listing items.
|
||||||
|
*/
|
||||||
|
export const ItemQuerySchema = PaginationSchema.extend({
|
||||||
|
search: z.string().optional(),
|
||||||
|
type: z.string().optional(),
|
||||||
|
rarity: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for creating a new item.
|
||||||
|
*/
|
||||||
|
export const CreateItemSchema = z.object({
|
||||||
|
name: z.string().min(1, "Name is required").max(100),
|
||||||
|
description: z.string().max(500).nullable().optional(),
|
||||||
|
rarity: ItemRarityEnum.optional().default("C"),
|
||||||
|
type: ItemTypeEnum,
|
||||||
|
price: z.union([z.string(), z.number()]).nullable().optional(),
|
||||||
|
iconUrl: z.string().optional(),
|
||||||
|
imageUrl: z.string().optional(),
|
||||||
|
usageData: z.any().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for updating an existing item.
|
||||||
|
*/
|
||||||
|
export const UpdateItemSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
description: z.string().max(500).nullable().optional(),
|
||||||
|
rarity: ItemRarityEnum.optional(),
|
||||||
|
type: ItemTypeEnum.optional(),
|
||||||
|
price: z.union([z.string(), z.number()]).nullable().optional(),
|
||||||
|
iconUrl: z.string().optional(),
|
||||||
|
imageUrl: z.string().optional(),
|
||||||
|
usageData: z.any().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Users Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query parameters for listing users.
|
||||||
|
*/
|
||||||
|
export const UserQuerySchema = PaginationSchema.extend({
|
||||||
|
search: z.string().optional(),
|
||||||
|
sortBy: z.enum(["balance", "level", "xp", "username"]).optional().default("balance"),
|
||||||
|
sortOrder: z.enum(["asc", "desc"]).optional().default("desc"),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for updating a user.
|
||||||
|
*/
|
||||||
|
export const UpdateUserSchema = z.object({
|
||||||
|
username: z.string().min(1).max(32).optional(),
|
||||||
|
balance: z.union([z.string(), z.number()]).optional(),
|
||||||
|
xp: z.union([z.string(), z.number()]).optional(),
|
||||||
|
level: z.coerce.number().int().min(0).optional(),
|
||||||
|
dailyStreak: z.coerce.number().int().min(0).optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
settings: z.record(z.string(), z.any()).optional(),
|
||||||
|
classId: z.union([z.string(), z.number()]).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for adding an item to user inventory.
|
||||||
|
*/
|
||||||
|
export const InventoryAddSchema = z.object({
|
||||||
|
itemId: z.coerce.number().int().positive("Item ID is required"),
|
||||||
|
quantity: z.union([z.string(), z.number()]).refine(
|
||||||
|
(val) => BigInt(val) > 0n,
|
||||||
|
"Quantity must be positive"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query params for removing inventory items.
|
||||||
|
*/
|
||||||
|
export const InventoryRemoveQuerySchema = z.object({
|
||||||
|
amount: z.coerce.number().int().min(1).optional().default(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Classes Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for creating a new class.
|
||||||
|
*/
|
||||||
|
export const CreateClassSchema = z.object({
|
||||||
|
id: z.union([z.string(), z.number()]),
|
||||||
|
name: z.string().min(1, "Name is required").max(50),
|
||||||
|
balance: z.union([z.string(), z.number()]).optional().default("0"),
|
||||||
|
roleId: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for updating a class.
|
||||||
|
*/
|
||||||
|
export const UpdateClassSchema = z.object({
|
||||||
|
name: z.string().min(1).max(50).optional(),
|
||||||
|
balance: z.union([z.string(), z.number()]).optional(),
|
||||||
|
roleId: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Moderation Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valid moderation case types.
|
||||||
|
*/
|
||||||
|
export const ModerationTypeEnum = z.enum([
|
||||||
|
"warn",
|
||||||
|
"timeout",
|
||||||
|
"kick",
|
||||||
|
"ban",
|
||||||
|
"note",
|
||||||
|
"prune"
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query parameters for searching moderation cases.
|
||||||
|
*/
|
||||||
|
export const CaseQuerySchema = PaginationSchema.extend({
|
||||||
|
userId: z.string().optional(),
|
||||||
|
moderatorId: z.string().optional(),
|
||||||
|
type: ModerationTypeEnum.optional(),
|
||||||
|
active: z.preprocess(
|
||||||
|
(val) => val === "true" ? true : val === "false" ? false : undefined,
|
||||||
|
z.boolean().optional()
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for creating a moderation case.
|
||||||
|
*/
|
||||||
|
export const CreateCaseSchema = z.object({
|
||||||
|
type: ModerationTypeEnum,
|
||||||
|
userId: z.string().min(1, "User ID is required"),
|
||||||
|
username: z.string().min(1, "Username is required"),
|
||||||
|
moderatorId: z.string().min(1, "Moderator ID is required"),
|
||||||
|
moderatorName: z.string().min(1, "Moderator name is required"),
|
||||||
|
reason: z.string().min(1, "Reason is required").max(1000),
|
||||||
|
metadata: z.record(z.string(), z.any()).optional().default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for clearing/resolving a moderation case.
|
||||||
|
*/
|
||||||
|
export const ClearCaseSchema = z.object({
|
||||||
|
clearedBy: z.string().min(1, "Cleared by ID is required"),
|
||||||
|
clearedByName: z.string().min(1, "Cleared by name is required"),
|
||||||
|
reason: z.string().max(500).optional().default("Cleared via API"),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Case ID pattern validation (CASE-XXXX format).
|
||||||
|
*/
|
||||||
|
export const CaseIdPattern = /^CASE-\d+$/i;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Transactions Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query parameters for listing transactions.
|
||||||
|
*/
|
||||||
|
export const TransactionQuerySchema = PaginationSchema.extend({
|
||||||
|
userId: z.string().optional(),
|
||||||
|
type: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Lootdrops Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query parameters for listing lootdrops.
|
||||||
|
*/
|
||||||
|
export const LootdropQuerySchema = z.object({
|
||||||
|
limit: z.coerce.number().min(1).max(100).optional().default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for spawning a lootdrop.
|
||||||
|
*/
|
||||||
|
export const CreateLootdropSchema = z.object({
|
||||||
|
channelId: z.string().min(1, "Channel ID is required"),
|
||||||
|
amount: z.coerce.number().int().positive().optional(),
|
||||||
|
currency: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Admin Actions Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for toggling maintenance mode.
|
||||||
|
*/
|
||||||
|
export const MaintenanceModeSchema = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
reason: z.string().max(200).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Type Exports
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ItemQuery = z.infer<typeof ItemQuerySchema>;
|
||||||
|
export type CreateItem = z.infer<typeof CreateItemSchema>;
|
||||||
|
export type UpdateItem = z.infer<typeof UpdateItemSchema>;
|
||||||
|
export type UserQuery = z.infer<typeof UserQuerySchema>;
|
||||||
|
export type UpdateUser = z.infer<typeof UpdateUserSchema>;
|
||||||
|
export type InventoryAdd = z.infer<typeof InventoryAddSchema>;
|
||||||
|
export type CreateClass = z.infer<typeof CreateClassSchema>;
|
||||||
|
export type UpdateClass = z.infer<typeof UpdateClassSchema>;
|
||||||
|
export type CaseQuery = z.infer<typeof CaseQuerySchema>;
|
||||||
|
export type CreateCase = z.infer<typeof CreateCaseSchema>;
|
||||||
|
export type ClearCase = z.infer<typeof ClearCaseSchema>;
|
||||||
|
export type TransactionQuery = z.infer<typeof TransactionQuerySchema>;
|
||||||
|
export type CreateLootdrop = z.infer<typeof CreateLootdropSchema>;
|
||||||
|
export type MaintenanceMode = z.infer<typeof MaintenanceModeSchema>;
|
||||||
152
api/src/routes/settings.routes.ts
Normal file
152
api/src/routes/settings.routes.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Bot settings endpoints for Aurora API.
|
||||||
|
* Provides endpoints for reading and updating bot configuration,
|
||||||
|
* as well as fetching Discord metadata.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings routes handler.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - GET /api/settings - Get current bot configuration
|
||||||
|
* - POST /api/settings - Update bot configuration (partial merge)
|
||||||
|
* - GET /api/settings/meta - Get Discord metadata (roles, channels, commands)
|
||||||
|
*/
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method, req } = ctx;
|
||||||
|
|
||||||
|
// Only handle requests to /api/settings*
|
||||||
|
if (!pathname.startsWith("/api/settings")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/settings
|
||||||
|
* @description Returns the current bot configuration from database.
|
||||||
|
* Configuration includes economy settings, leveling settings,
|
||||||
|
* command toggles, and other system settings.
|
||||||
|
* @response 200 - Full configuration object (DB format with strings for BigInts)
|
||||||
|
* @response 500 - Error fetching settings
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Response
|
||||||
|
* {
|
||||||
|
* "economy": { "daily": { "amount": "100", "streakBonus": "10" } },
|
||||||
|
* "leveling": { "base": 100, "exponent": 1.5 },
|
||||||
|
* "commands": { "disabled": [], "channelLocks": {} }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/settings" && method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
|
||||||
|
const settings = await gameSettingsService.getSettings();
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
// Return defaults if no settings in DB yet
|
||||||
|
return jsonResponse(gameSettingsService.getDefaults());
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(settings);
|
||||||
|
}, "fetch settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/settings
|
||||||
|
* @description Updates bot configuration with partial merge.
|
||||||
|
* Only the provided fields will be updated; other settings remain unchanged.
|
||||||
|
* After updating, commands are automatically reloaded.
|
||||||
|
*
|
||||||
|
* @body Partial configuration object (DB format with strings for BigInts)
|
||||||
|
* @response 200 - `{ success: true }`
|
||||||
|
* @response 400 - Validation error
|
||||||
|
* @response 500 - Error saving settings
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request - Only update economy daily reward
|
||||||
|
* POST /api/settings
|
||||||
|
* { "economy": { "daily": { "amount": "150" } } }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/settings" && method === "POST") {
|
||||||
|
try {
|
||||||
|
const partialConfig = await req.json() as Record<string, unknown>;
|
||||||
|
const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
|
||||||
|
|
||||||
|
// Use upsertSettings to merge partial update
|
||||||
|
await gameSettingsService.upsertSettings(partialConfig as Record<string, unknown>);
|
||||||
|
|
||||||
|
const { systemEvents, EVENTS } = await import("@shared/lib/events");
|
||||||
|
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
|
||||||
|
|
||||||
|
return jsonResponse({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
// Return 400 for validation errors
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return errorResponse("Failed to save settings", 400, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/settings/meta
|
||||||
|
* @description Returns Discord server metadata for settings UI.
|
||||||
|
* Provides lists of roles, channels, and registered commands.
|
||||||
|
*
|
||||||
|
* @response 200 - `{ roles: Role[], channels: Channel[], commands: Command[] }`
|
||||||
|
* @response 500 - Error fetching metadata
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Response
|
||||||
|
* {
|
||||||
|
* "roles": [
|
||||||
|
* { "id": "123456789", "name": "Admin", "color": "#FF0000" }
|
||||||
|
* ],
|
||||||
|
* "channels": [
|
||||||
|
* { "id": "987654321", "name": "general", "type": 0 }
|
||||||
|
* ],
|
||||||
|
* "commands": [
|
||||||
|
* { "name": "daily", "category": "economy" }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/settings/meta" && method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const { AuroraClient } = await import("../../../bot/lib/BotClient");
|
||||||
|
const { env } = await import("@shared/lib/env");
|
||||||
|
|
||||||
|
if (!env.DISCORD_GUILD_ID) {
|
||||||
|
return jsonResponse({ roles: [], channels: [], commands: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = AuroraClient.guilds.cache.get(env.DISCORD_GUILD_ID);
|
||||||
|
if (!guild) {
|
||||||
|
return jsonResponse({ roles: [], channels: [], commands: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map roles and channels to a simplified format
|
||||||
|
const roles = guild.roles.cache
|
||||||
|
.sort((a, b) => b.position - a.position)
|
||||||
|
.map(r => ({ id: r.id, name: r.name, color: r.hexColor }));
|
||||||
|
|
||||||
|
const channels = guild.channels.cache
|
||||||
|
.map(c => ({ id: c.id, name: c.name, type: c.type }));
|
||||||
|
|
||||||
|
const commands = Array.from(AuroraClient.knownCommands.entries())
|
||||||
|
.map(([name, category]) => ({ name, category }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.category !== b.category) return a.category.localeCompare(b.category);
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse({ guildId: env.DISCORD_GUILD_ID, roles, channels, commands });
|
||||||
|
}, "fetch settings meta");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const settingsRoutes: RouteModule = {
|
||||||
|
name: "settings",
|
||||||
|
handler
|
||||||
|
};
|
||||||
94
api/src/routes/stats.helper.ts
Normal file
94
api/src/routes/stats.helper.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Dashboard stats helper for Aurora API.
|
||||||
|
* Provides the getFullDashboardStats function used by stats routes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from "@shared/lib/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches comprehensive dashboard statistics.
|
||||||
|
* Aggregates data from multiple services with error isolation.
|
||||||
|
*
|
||||||
|
* @returns Full dashboard stats object including bot info, user counts,
|
||||||
|
* economy data, leaderboards, and system status.
|
||||||
|
*/
|
||||||
|
export async function getFullDashboardStats() {
|
||||||
|
// Import services (dynamic to avoid circular deps)
|
||||||
|
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||||
|
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||||
|
const { getClientStats } = await import("../../../bot/lib/clientStats");
|
||||||
|
|
||||||
|
// Fetch all data in parallel with error isolation
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
Promise.resolve(getClientStats()),
|
||||||
|
dashboardService.getActiveUserCount(),
|
||||||
|
dashboardService.getTotalUserCount(),
|
||||||
|
dashboardService.getEconomyStats(),
|
||||||
|
dashboardService.getRecentEvents(10),
|
||||||
|
dashboardService.getTotalItems(),
|
||||||
|
dashboardService.getActiveLootdrops(),
|
||||||
|
dashboardService.getLeaderboards(),
|
||||||
|
Promise.resolve(lootdropService.getLootdropState()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Helper to unwrap result or return default
|
||||||
|
const unwrap = <T>(result: PromiseSettledResult<T>, defaultValue: T, name: string): T => {
|
||||||
|
if (result.status === 'fulfilled') return result.value;
|
||||||
|
logger.error("web", `Failed to fetch ${name}`, result.reason);
|
||||||
|
return defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clientStats = unwrap(results[0], {
|
||||||
|
bot: { name: 'Aurora', avatarUrl: null, status: null },
|
||||||
|
guilds: 0,
|
||||||
|
commandsRegistered: 0,
|
||||||
|
commandsKnown: 0,
|
||||||
|
cachedUsers: 0,
|
||||||
|
ping: 0,
|
||||||
|
uptime: 0,
|
||||||
|
lastCommandTimestamp: null
|
||||||
|
}, 'clientStats');
|
||||||
|
|
||||||
|
const activeUsers = unwrap(results[1], 0, 'activeUsers');
|
||||||
|
const totalUsers = unwrap(results[2], 0, 'totalUsers');
|
||||||
|
const economyStats = unwrap(results[3], { totalWealth: 0n, avgLevel: 0, topStreak: 0 }, 'economyStats');
|
||||||
|
const recentEvents = unwrap(results[4], [], 'recentEvents');
|
||||||
|
const totalItems = unwrap(results[5], 0, 'totalItems');
|
||||||
|
const activeLootdrops = unwrap(results[6], [], 'activeLootdrops');
|
||||||
|
const leaderboards = unwrap(results[7], { topLevels: [], topWealth: [], topNetWorth: [] }, 'leaderboards');
|
||||||
|
const lootdropState = unwrap(results[8], undefined, 'lootdropState');
|
||||||
|
|
||||||
|
return {
|
||||||
|
bot: clientStats.bot,
|
||||||
|
guilds: { count: clientStats.guilds },
|
||||||
|
users: { active: activeUsers, total: totalUsers },
|
||||||
|
commands: {
|
||||||
|
total: clientStats.commandsKnown,
|
||||||
|
active: clientStats.commandsRegistered,
|
||||||
|
disabled: clientStats.commandsKnown - clientStats.commandsRegistered
|
||||||
|
},
|
||||||
|
ping: { avg: clientStats.ping },
|
||||||
|
economy: {
|
||||||
|
totalWealth: economyStats.totalWealth.toString(),
|
||||||
|
avgLevel: economyStats.avgLevel,
|
||||||
|
topStreak: economyStats.topStreak,
|
||||||
|
totalItems,
|
||||||
|
},
|
||||||
|
recentEvents: recentEvents.map(event => ({
|
||||||
|
...event,
|
||||||
|
timestamp: event.timestamp instanceof Date ? event.timestamp.toISOString() : event.timestamp,
|
||||||
|
})),
|
||||||
|
activeLootdrops: activeLootdrops.map(drop => ({
|
||||||
|
rewardAmount: drop.rewardAmount,
|
||||||
|
currency: drop.currency,
|
||||||
|
createdAt: drop.createdAt.toISOString(),
|
||||||
|
expiresAt: drop.expiresAt ? drop.expiresAt.toISOString() : null,
|
||||||
|
// Explicitly excluding channelId/messageId to prevent sniping
|
||||||
|
})),
|
||||||
|
lootdropState,
|
||||||
|
leaderboards,
|
||||||
|
uptime: clientStats.uptime,
|
||||||
|
lastCommandTimestamp: clientStats.lastCommandTimestamp,
|
||||||
|
maintenanceMode: (await import("../../../bot/lib/BotClient")).AuroraClient.maintenanceMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
85
api/src/routes/stats.routes.ts
Normal file
85
api/src/routes/stats.routes.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Statistics endpoints for Aurora API.
|
||||||
|
* Provides dashboard statistics and activity aggregation data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
|
||||||
|
|
||||||
|
// Cache for activity stats (heavy aggregation)
|
||||||
|
let activityPromise: Promise<import("@shared/modules/dashboard/dashboard.types").ActivityData[]> | null = null;
|
||||||
|
let lastActivityFetch: number = 0;
|
||||||
|
const ACTIVITY_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stats routes handler.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - GET /api/stats - Full dashboard statistics
|
||||||
|
* - GET /api/stats/activity - Activity aggregation with caching
|
||||||
|
*/
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method } = ctx;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/stats
|
||||||
|
* @description Returns comprehensive dashboard statistics including
|
||||||
|
* bot info, user counts, economy data, and leaderboards.
|
||||||
|
* @response 200 - Full dashboard stats object
|
||||||
|
* @response 500 - Error fetching statistics
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/stats" && method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
// Import the stats function from wherever it's defined
|
||||||
|
// This will be passed in during initialization
|
||||||
|
const { getFullDashboardStats } = await import("./stats.helper.ts");
|
||||||
|
const stats = await getFullDashboardStats();
|
||||||
|
return jsonResponse(stats);
|
||||||
|
}, "fetch dashboard stats");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/stats/activity
|
||||||
|
* @description Returns activity aggregation data with 5-minute caching.
|
||||||
|
* Heavy query, results are cached to reduce database load.
|
||||||
|
* @response 200 - Array of activity data points
|
||||||
|
* @response 500 - Error fetching activity statistics
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Response
|
||||||
|
* [
|
||||||
|
* { "date": "2024-02-08", "commands": 150, "users": 25 },
|
||||||
|
* { "date": "2024-02-07", "commands": 200, "users": 30 }
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/stats/activity" && method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// If we have a valid cache, return it
|
||||||
|
if (activityPromise && (now - lastActivityFetch < ACTIVITY_CACHE_TTL)) {
|
||||||
|
const data = await activityPromise;
|
||||||
|
return jsonResponse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, trigger a new fetch (deduplicated by the promise)
|
||||||
|
if (!activityPromise || (now - lastActivityFetch >= ACTIVITY_CACHE_TTL)) {
|
||||||
|
activityPromise = (async () => {
|
||||||
|
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||||
|
return await dashboardService.getActivityAggregation();
|
||||||
|
})();
|
||||||
|
lastActivityFetch = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activity = await activityPromise;
|
||||||
|
return jsonResponse(activity);
|
||||||
|
}, "fetch activity stats");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const statsRoutes: RouteModule = {
|
||||||
|
name: "stats",
|
||||||
|
handler
|
||||||
|
};
|
||||||
91
api/src/routes/transactions.routes.ts
Normal file
91
api/src/routes/transactions.routes.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Transaction listing endpoints for Aurora API.
|
||||||
|
* Provides read access to economy transaction history.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import { jsonResponse, withErrorHandling } from "./utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transactions routes handler.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - GET /api/transactions - List transactions with filters
|
||||||
|
*/
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method, url } = ctx;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/transactions
|
||||||
|
* @description Returns economy transactions with optional filtering.
|
||||||
|
*
|
||||||
|
* @query userId - Filter by user ID (Discord snowflake)
|
||||||
|
* @query type - Filter by transaction type
|
||||||
|
* @query limit - Max results (default: 50)
|
||||||
|
* @query offset - Pagination offset (default: 0)
|
||||||
|
*
|
||||||
|
* @response 200 - `{ transactions: Transaction[] }`
|
||||||
|
* @response 500 - Error fetching transactions
|
||||||
|
*
|
||||||
|
* Transaction Types:
|
||||||
|
* - DAILY_REWARD - Daily claim reward
|
||||||
|
* - TRANSFER_IN - Received from another user
|
||||||
|
* - TRANSFER_OUT - Sent to another user
|
||||||
|
* - LOOTDROP_CLAIM - Claimed lootdrop
|
||||||
|
* - SHOP_BUY - Item purchase
|
||||||
|
* - QUEST_REWARD - Quest completion reward
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* GET /api/transactions?userId=123456789&type=DAILY_REWARD&limit=10
|
||||||
|
*
|
||||||
|
* // Response
|
||||||
|
* {
|
||||||
|
* "transactions": [
|
||||||
|
* {
|
||||||
|
* "id": "1",
|
||||||
|
* "userId": "123456789",
|
||||||
|
* "amount": "100",
|
||||||
|
* "type": "DAILY_REWARD",
|
||||||
|
* "description": "Daily reward (Streak: 3)",
|
||||||
|
* "createdAt": "2024-01-15T12:00:00Z"
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/transactions" && method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const { transactions } = await import("@shared/db/schema");
|
||||||
|
const { DrizzleClient } = await import("@shared/db/DrizzleClient");
|
||||||
|
const { eq, desc } = await import("drizzle-orm");
|
||||||
|
|
||||||
|
const userId = url.searchParams.get("userId");
|
||||||
|
const type = url.searchParams.get("type");
|
||||||
|
const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
|
||||||
|
const offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
|
||||||
|
|
||||||
|
let query = DrizzleClient.select().from(transactions);
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
query = query.where(eq(transactions.userId, BigInt(userId))) as typeof query;
|
||||||
|
}
|
||||||
|
if (type) {
|
||||||
|
query = query.where(eq(transactions.type, type)) as typeof query;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query
|
||||||
|
.orderBy(desc(transactions.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
return jsonResponse({ transactions: result });
|
||||||
|
}, "fetch transactions");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const transactionsRoutes: RouteModule = {
|
||||||
|
name: "transactions",
|
||||||
|
handler
|
||||||
|
};
|
||||||
94
api/src/routes/types.ts
Normal file
94
api/src/routes/types.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Shared types for the Aurora API routing system.
|
||||||
|
* Provides type definitions for route handlers, responses, and errors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard API error response structure.
|
||||||
|
*/
|
||||||
|
export interface ApiErrorResponse {
|
||||||
|
error: string;
|
||||||
|
details?: string;
|
||||||
|
issues?: Array<{ path: (string | number)[]; message: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard API success response with optional data wrapper.
|
||||||
|
*/
|
||||||
|
export interface ApiSuccessResponse<T = unknown> {
|
||||||
|
success: true;
|
||||||
|
[key: string]: T | true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route context passed to all route handlers.
|
||||||
|
* Contains parsed URL information and the original request.
|
||||||
|
*/
|
||||||
|
export interface RouteContext {
|
||||||
|
/** The original HTTP request */
|
||||||
|
req: Request;
|
||||||
|
/** Parsed URL object */
|
||||||
|
url: URL;
|
||||||
|
/** HTTP method (GET, POST, PUT, DELETE, etc.) */
|
||||||
|
method: string;
|
||||||
|
/** URL pathname without query string */
|
||||||
|
pathname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A route handler function that processes a request and returns a response.
|
||||||
|
* Returns null if the route doesn't match, allowing the next handler to try.
|
||||||
|
*/
|
||||||
|
export type RouteHandler = (ctx: RouteContext) => Promise<Response | null> | Response | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A route module that exports a handler function.
|
||||||
|
*/
|
||||||
|
export interface RouteModule {
|
||||||
|
/** Human-readable name for debugging */
|
||||||
|
name: string;
|
||||||
|
/** The route handler function */
|
||||||
|
handler: RouteHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom API error class with HTTP status code support.
|
||||||
|
*/
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly status: number = 500,
|
||||||
|
public readonly details?: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a 400 Bad Request error.
|
||||||
|
*/
|
||||||
|
static badRequest(message: string, details?: string): ApiError {
|
||||||
|
return new ApiError(message, 400, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a 404 Not Found error.
|
||||||
|
*/
|
||||||
|
static notFound(resource: string): ApiError {
|
||||||
|
return new ApiError(`${resource} not found`, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a 409 Conflict error.
|
||||||
|
*/
|
||||||
|
static conflict(message: string): ApiError {
|
||||||
|
return new ApiError(message, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a 500 Internal Server Error.
|
||||||
|
*/
|
||||||
|
static internal(message: string, details?: string): ApiError {
|
||||||
|
return new ApiError(message, 500, details);
|
||||||
|
}
|
||||||
|
}
|
||||||
263
api/src/routes/users.routes.ts
Normal file
263
api/src/routes/users.routes.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview User management endpoints for Aurora API.
|
||||||
|
* Provides CRUD operations for users and user inventory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import {
|
||||||
|
jsonResponse,
|
||||||
|
errorResponse,
|
||||||
|
parseBody,
|
||||||
|
parseIdFromPath,
|
||||||
|
parseStringIdFromPath,
|
||||||
|
withErrorHandling
|
||||||
|
} from "./utils";
|
||||||
|
import { UpdateUserSchema, InventoryAddSchema } from "./schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Users routes handler.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - GET /api/users - List users with filters
|
||||||
|
* - GET /api/users/:id - Get single user
|
||||||
|
* - PUT /api/users/:id - Update user
|
||||||
|
* - GET /api/users/:id/inventory - Get user inventory
|
||||||
|
* - POST /api/users/:id/inventory - Add item to inventory
|
||||||
|
* - DELETE /api/users/:id/inventory/:itemId - Remove item from inventory
|
||||||
|
*/
|
||||||
|
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||||
|
const { pathname, method, req, url } = ctx;
|
||||||
|
|
||||||
|
// Only handle requests to /api/users*
|
||||||
|
if (!pathname.startsWith("/api/users")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/users
|
||||||
|
* @description Returns a paginated list of users with optional filtering and sorting.
|
||||||
|
*
|
||||||
|
* @query search - Filter by username (partial match)
|
||||||
|
* @query sortBy - Sort field: balance, level, xp, username (default: balance)
|
||||||
|
* @query sortOrder - Sort direction: asc, desc (default: desc)
|
||||||
|
* @query limit - Max results (default: 50)
|
||||||
|
* @query offset - Pagination offset (default: 0)
|
||||||
|
*
|
||||||
|
* @response 200 - `{ users: User[], total: number }`
|
||||||
|
* @response 500 - Error fetching users
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Request
|
||||||
|
* GET /api/users?sortBy=level&sortOrder=desc&limit=10
|
||||||
|
*/
|
||||||
|
if (pathname === "/api/users" && method === "GET") {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const { users } = await import("@shared/db/schema");
|
||||||
|
const { DrizzleClient } = await import("@shared/db/DrizzleClient");
|
||||||
|
const { ilike, desc, asc, sql } = await import("drizzle-orm");
|
||||||
|
|
||||||
|
const 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;
|
||||||
|
|
||||||
|
let query = DrizzleClient.select().from(users);
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
query = query.where(ilike(users.username, `%${search}%`)) as typeof query;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortColumn = sortBy === "level" ? users.level :
|
||||||
|
sortBy === "xp" ? users.xp :
|
||||||
|
sortBy === "username" ? users.username : users.balance;
|
||||||
|
const orderFn = sortOrder === "asc" ? asc : desc;
|
||||||
|
|
||||||
|
const result = await query
|
||||||
|
.orderBy(orderFn(sortColumn))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
const countResult = await DrizzleClient.select({ count: sql<number>`count(*)` }).from(users);
|
||||||
|
const total = Number(countResult[0]?.count || 0);
|
||||||
|
|
||||||
|
return jsonResponse({ users: result, total });
|
||||||
|
}, "fetch users");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/users/:id
|
||||||
|
* @description Returns a single user by Discord ID.
|
||||||
|
* Includes related class information if the user has a class assigned.
|
||||||
|
*
|
||||||
|
* @param id - Discord User ID (snowflake)
|
||||||
|
* @response 200 - Full user object with class relation
|
||||||
|
* @response 404 - User not found
|
||||||
|
* @response 500 - Error fetching user
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Response
|
||||||
|
* {
|
||||||
|
* "id": "123456789012345678",
|
||||||
|
* "username": "Player1",
|
||||||
|
* "balance": "1000",
|
||||||
|
* "level": 5,
|
||||||
|
* "class": { "id": "1", "name": "Warrior" }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/users\/\d+$/) && method === "GET") {
|
||||||
|
const id = parseStringIdFromPath(pathname);
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const { userService } = await import("@shared/modules/user/user.service");
|
||||||
|
const user = await userService.getUserById(id);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return errorResponse("User not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(user);
|
||||||
|
}, "fetch user");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route PUT /api/users/:id
|
||||||
|
* @description Updates user fields. Only provided fields will be updated.
|
||||||
|
*
|
||||||
|
* @param id - Discord User ID (snowflake)
|
||||||
|
* @body {
|
||||||
|
* username?: string,
|
||||||
|
* balance?: string | number,
|
||||||
|
* xp?: string | number,
|
||||||
|
* level?: number,
|
||||||
|
* dailyStreak?: number,
|
||||||
|
* isActive?: boolean,
|
||||||
|
* settings?: object,
|
||||||
|
* classId?: string | number
|
||||||
|
* }
|
||||||
|
* @response 200 - `{ success: true, user: User }`
|
||||||
|
* @response 404 - User not found
|
||||||
|
* @response 500 - Error updating user
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/users\/\d+$/) && method === "PUT") {
|
||||||
|
const id = parseStringIdFromPath(pathname);
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const { userService } = await import("@shared/modules/user/user.service");
|
||||||
|
const data = await req.json() as Record<string, any>;
|
||||||
|
|
||||||
|
const existing = await userService.getUserById(id);
|
||||||
|
if (!existing) {
|
||||||
|
return errorResponse("User not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update data (only allow safe fields)
|
||||||
|
const updateData: any = {};
|
||||||
|
if (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);
|
||||||
|
|
||||||
|
const updatedUser = await userService.updateUser(id, updateData);
|
||||||
|
return jsonResponse({ success: true, user: updatedUser });
|
||||||
|
}, "update user");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/users/:id/inventory
|
||||||
|
* @description Returns user's inventory with item details.
|
||||||
|
*
|
||||||
|
* @param id - Discord User ID (snowflake)
|
||||||
|
* @response 200 - `{ inventory: InventoryEntry[] }`
|
||||||
|
* @response 500 - Error fetching inventory
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Response
|
||||||
|
* {
|
||||||
|
* "inventory": [
|
||||||
|
* {
|
||||||
|
* "userId": "123456789",
|
||||||
|
* "itemId": 1,
|
||||||
|
* "quantity": "5",
|
||||||
|
* "item": { "id": 1, "name": "Health Potion", ... }
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/users\/\d+\/inventory$/) && method === "GET") {
|
||||||
|
const id = pathname.split("/")[3] || "0";
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
|
||||||
|
const inventory = await inventoryService.getInventory(id);
|
||||||
|
return jsonResponse({ inventory });
|
||||||
|
}, "fetch inventory");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/users/:id/inventory
|
||||||
|
* @description Adds an item to user's inventory.
|
||||||
|
*
|
||||||
|
* @param id - Discord User ID (snowflake)
|
||||||
|
* @body { itemId: number, quantity: string | number }
|
||||||
|
* @response 201 - `{ success: true, entry: InventoryEntry }`
|
||||||
|
* @response 400 - Missing required fields
|
||||||
|
* @response 500 - Error adding item
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/users\/\d+\/inventory$/) && method === "POST") {
|
||||||
|
const id = pathname.split("/")[3] || "0";
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
|
||||||
|
const data = await req.json() as Record<string, any>;
|
||||||
|
|
||||||
|
if (!data.itemId || !data.quantity) {
|
||||||
|
return errorResponse("Missing required fields: itemId, quantity", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = await inventoryService.addItem(id, data.itemId, BigInt(data.quantity));
|
||||||
|
return jsonResponse({ success: true, entry }, 201);
|
||||||
|
}, "add item to inventory");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route DELETE /api/users/:id/inventory/:itemId
|
||||||
|
* @description Removes an item from user's inventory.
|
||||||
|
*
|
||||||
|
* @param id - Discord User ID (snowflake)
|
||||||
|
* @param itemId - Item ID to remove
|
||||||
|
* @query amount - Quantity to remove (default: 1)
|
||||||
|
* @response 204 - Item removed (no content)
|
||||||
|
* @response 500 - Error removing item
|
||||||
|
*/
|
||||||
|
if (pathname.match(/^\/api\/users\/\d+\/inventory\/\d+$/) && method === "DELETE") {
|
||||||
|
const parts = pathname.split("/");
|
||||||
|
const userId = parts[3] || "";
|
||||||
|
const itemId = parseInt(parts[5] || "0");
|
||||||
|
|
||||||
|
if (!userId) return null;
|
||||||
|
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
|
||||||
|
|
||||||
|
const amount = url.searchParams.get("amount");
|
||||||
|
const quantity = amount ? BigInt(amount) : 1n;
|
||||||
|
|
||||||
|
await inventoryService.removeItem(userId, itemId, quantity);
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}, "remove item from inventory");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usersRoutes: RouteModule = {
|
||||||
|
name: "users",
|
||||||
|
handler
|
||||||
|
};
|
||||||
213
api/src/routes/utils.ts
Normal file
213
api/src/routes/utils.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Utility functions for Aurora API route handlers.
|
||||||
|
* Provides helpers for response formatting, parameter parsing, and validation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z, ZodError, type ZodSchema } from "zod";
|
||||||
|
import type { ApiErrorResponse } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON replacer function that handles BigInt serialization.
|
||||||
|
* Converts BigInt values to strings for JSON compatibility.
|
||||||
|
*/
|
||||||
|
export function jsonReplacer(_key: string, value: unknown): unknown {
|
||||||
|
return typeof value === "bigint" ? value.toString() : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a JSON response with proper content-type header and BigInt handling.
|
||||||
|
*
|
||||||
|
* @param data - The data to serialize as JSON
|
||||||
|
* @param status - HTTP status code (default: 200)
|
||||||
|
* @returns A Response object with JSON content
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* return jsonResponse({ items: [...], total: 10 });
|
||||||
|
* return jsonResponse({ success: true, item }, 201);
|
||||||
|
*/
|
||||||
|
export function jsonResponse<T>(data: T, status: number = 200): Response {
|
||||||
|
return new Response(JSON.stringify(data, jsonReplacer), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a standardized error response.
|
||||||
|
*
|
||||||
|
* @param error - Error message
|
||||||
|
* @param status - HTTP status code (default: 500)
|
||||||
|
* @param details - Optional additional error details
|
||||||
|
* @returns A Response object with error JSON
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* return errorResponse("Item not found", 404);
|
||||||
|
* return errorResponse("Validation failed", 400, "Name is required");
|
||||||
|
*/
|
||||||
|
export function errorResponse(
|
||||||
|
error: string,
|
||||||
|
status: number = 500,
|
||||||
|
details?: string
|
||||||
|
): Response {
|
||||||
|
const body: ApiErrorResponse = { error };
|
||||||
|
if (details) body.details = details;
|
||||||
|
|
||||||
|
return Response.json(body, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a validation error response from a ZodError.
|
||||||
|
*
|
||||||
|
* @param zodError - The ZodError from a failed parse
|
||||||
|
* @returns A 400 Response with validation issue details
|
||||||
|
*/
|
||||||
|
export function validationErrorResponse(zodError: ZodError): Response {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: "Invalid payload",
|
||||||
|
issues: zodError.issues.map(issue => ({
|
||||||
|
path: issue.path,
|
||||||
|
message: issue.message
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses and validates a request body against a Zod schema.
|
||||||
|
*
|
||||||
|
* @param req - The HTTP request
|
||||||
|
* @param schema - Zod schema to validate against
|
||||||
|
* @returns Validated data or an error Response
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const result = await parseBody(req, CreateItemSchema);
|
||||||
|
* if (result instanceof Response) return result; // Validation failed
|
||||||
|
* const data = result; // Type-safe validated data
|
||||||
|
*/
|
||||||
|
export async function parseBody<T extends ZodSchema>(
|
||||||
|
req: Request,
|
||||||
|
schema: T
|
||||||
|
): Promise<z.infer<T> | Response> {
|
||||||
|
try {
|
||||||
|
const rawBody = await req.json();
|
||||||
|
const parsed = schema.safeParse(rawBody);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return validationErrorResponse(parsed.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.data;
|
||||||
|
} catch (e) {
|
||||||
|
return errorResponse("Invalid JSON body", 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses query parameters against a Zod schema.
|
||||||
|
*
|
||||||
|
* @param url - The URL containing query parameters
|
||||||
|
* @param schema - Zod schema to validate against
|
||||||
|
* @returns Validated query params or an error Response
|
||||||
|
*/
|
||||||
|
export function parseQuery<T extends ZodSchema>(
|
||||||
|
url: URL,
|
||||||
|
schema: T
|
||||||
|
): z.infer<T> | Response {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
url.searchParams.forEach((value, key) => {
|
||||||
|
params[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(params);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return validationErrorResponse(parsed.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a numeric ID from a URL path segment.
|
||||||
|
*
|
||||||
|
* @param pathname - The URL pathname
|
||||||
|
* @param position - Position from the end (0 = last segment, 1 = second-to-last, etc.)
|
||||||
|
* @returns The parsed integer ID or null if invalid
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* parseIdFromPath("/api/items/123") // returns 123
|
||||||
|
* parseIdFromPath("/api/items/abc") // returns null
|
||||||
|
* parseIdFromPath("/api/users/456/inventory", 1) // returns 456
|
||||||
|
*/
|
||||||
|
export function parseIdFromPath(pathname: string, position: number = 0): number | null {
|
||||||
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
|
const segment = segments[segments.length - 1 - position];
|
||||||
|
|
||||||
|
if (!segment) return null;
|
||||||
|
|
||||||
|
const id = parseInt(segment, 10);
|
||||||
|
return isNaN(id) ? null : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a string ID (like Discord snowflake) from a URL path segment.
|
||||||
|
*
|
||||||
|
* @param pathname - The URL pathname
|
||||||
|
* @param position - Position from the end (0 = last segment)
|
||||||
|
* @returns The string ID or null if segment doesn't exist
|
||||||
|
*/
|
||||||
|
export function parseStringIdFromPath(pathname: string, position: number = 0): string | null {
|
||||||
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
|
const segment = segments[segments.length - 1 - position];
|
||||||
|
return segment || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a pathname matches a pattern with optional parameter placeholders.
|
||||||
|
*
|
||||||
|
* @param pathname - The actual URL pathname
|
||||||
|
* @param pattern - The pattern to match (use :id for numeric params, :param for string params)
|
||||||
|
* @returns True if the pattern matches
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* matchPath("/api/items/123", "/api/items/:id") // true
|
||||||
|
* matchPath("/api/items", "/api/items/:id") // false
|
||||||
|
*/
|
||||||
|
export function matchPath(pathname: string, pattern: string): boolean {
|
||||||
|
const pathParts = pathname.split("/").filter(Boolean);
|
||||||
|
const patternParts = pattern.split("/").filter(Boolean);
|
||||||
|
|
||||||
|
if (pathParts.length !== patternParts.length) return false;
|
||||||
|
|
||||||
|
return patternParts.every((part, i) => {
|
||||||
|
if (part.startsWith(":")) return true; // Matches any value
|
||||||
|
return part === pathParts[i];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps an async route handler with consistent error handling.
|
||||||
|
* Catches all errors and returns appropriate error responses.
|
||||||
|
*
|
||||||
|
* @param handler - The async handler function
|
||||||
|
* @param logContext - Context string for error logging
|
||||||
|
* @returns A wrapped handler with error handling
|
||||||
|
*/
|
||||||
|
export function withErrorHandling(
|
||||||
|
handler: () => Promise<Response>,
|
||||||
|
logContext: string
|
||||||
|
): Promise<Response> {
|
||||||
|
return handler().catch((error: unknown) => {
|
||||||
|
// Dynamic import to avoid circular dependencies
|
||||||
|
return import("@shared/lib/logger").then(({ logger }) => {
|
||||||
|
logger.error("web", `Error in ${logContext}`, error);
|
||||||
|
return errorResponse(
|
||||||
|
`Failed to ${logContext.toLowerCase()}`,
|
||||||
|
500,
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
446
api/src/server.items.test.ts
Normal file
446
api/src/server.items.test.ts
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
import { describe, test, expect, afterAll, beforeAll, mock } from "bun:test";
|
||||||
|
import type { WebServerInstance } from "./server";
|
||||||
|
import { createWebServer } from "./server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Items API Integration Tests
|
||||||
|
*
|
||||||
|
* Tests the full CRUD functionality for the Items management API.
|
||||||
|
* Uses mocked database and service layers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- Mock Types ---
|
||||||
|
interface MockItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
rarity: string;
|
||||||
|
type: string;
|
||||||
|
price: bigint | null;
|
||||||
|
iconUrl: string;
|
||||||
|
imageUrl: string;
|
||||||
|
usageData: { consume: boolean; effects: any[] } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mock Data ---
|
||||||
|
let mockItems: MockItem[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Health Potion",
|
||||||
|
description: "Restores health",
|
||||||
|
rarity: "C",
|
||||||
|
type: "CONSUMABLE",
|
||||||
|
price: 100n,
|
||||||
|
iconUrl: "/assets/items/1.png",
|
||||||
|
imageUrl: "/assets/items/1.png",
|
||||||
|
usageData: { consume: true, effects: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "Iron Sword",
|
||||||
|
description: "A basic sword",
|
||||||
|
rarity: "R",
|
||||||
|
type: "EQUIPMENT",
|
||||||
|
price: 500n,
|
||||||
|
iconUrl: "/assets/items/2.png",
|
||||||
|
imageUrl: "/assets/items/2.png",
|
||||||
|
usageData: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let mockIdCounter = 3;
|
||||||
|
|
||||||
|
// --- Mock Items Service ---
|
||||||
|
mock.module("@shared/modules/items/items.service", () => ({
|
||||||
|
itemsService: {
|
||||||
|
getAllItems: mock(async (filters: any = {}) => {
|
||||||
|
let filtered = [...mockItems];
|
||||||
|
|
||||||
|
if (filters.search) {
|
||||||
|
const search = filters.search.toLowerCase();
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(item) =>
|
||||||
|
item.name.toLowerCase().includes(search) ||
|
||||||
|
(item.description?.toLowerCase().includes(search) ?? false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.type) {
|
||||||
|
filtered = filtered.filter((item) => item.type === filters.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.rarity) {
|
||||||
|
filtered = filtered.filter((item) => item.rarity === filters.rarity);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: filtered,
|
||||||
|
total: filtered.length,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
getItemById: mock(async (id: number) => {
|
||||||
|
return mockItems.find((item) => item.id === id) ?? null;
|
||||||
|
}),
|
||||||
|
|
||||||
|
isNameTaken: mock(async (name: string, excludeId?: number) => {
|
||||||
|
return mockItems.some(
|
||||||
|
(item) =>
|
||||||
|
item.name.toLowerCase() === name.toLowerCase() &&
|
||||||
|
item.id !== excludeId
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
createItem: mock(async (data: any) => {
|
||||||
|
const newItem: MockItem = {
|
||||||
|
id: mockIdCounter++,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description ?? null,
|
||||||
|
rarity: data.rarity ?? "C",
|
||||||
|
type: data.type,
|
||||||
|
price: data.price ?? null,
|
||||||
|
iconUrl: data.iconUrl,
|
||||||
|
imageUrl: data.imageUrl,
|
||||||
|
usageData: data.usageData ?? null,
|
||||||
|
};
|
||||||
|
mockItems.push(newItem);
|
||||||
|
return newItem;
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateItem: mock(async (id: number, data: any) => {
|
||||||
|
const index = mockItems.findIndex((item) => item.id === id);
|
||||||
|
if (index === -1) return null;
|
||||||
|
|
||||||
|
mockItems[index] = { ...mockItems[index], ...data };
|
||||||
|
return mockItems[index];
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteItem: mock(async (id: number) => {
|
||||||
|
const index = mockItems.findIndex((item) => item.id === id);
|
||||||
|
if (index === -1) return null;
|
||||||
|
|
||||||
|
const [deleted] = mockItems.splice(index, 1);
|
||||||
|
return deleted;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// --- Mock Utilities ---
|
||||||
|
mock.module("@shared/lib/utils", () => ({
|
||||||
|
deepMerge: (target: any, source: any) => ({ ...target, ...source }),
|
||||||
|
jsonReplacer: (key: string, value: any) =>
|
||||||
|
typeof value === "bigint" ? value.toString() : value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// --- Mock Auth (bypass authentication) ---
|
||||||
|
mock.module("./routes/auth.routes", () => ({
|
||||||
|
authRoutes: { name: "auth", handler: () => null },
|
||||||
|
isAuthenticated: () => true,
|
||||||
|
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// --- Mock Logger ---
|
||||||
|
mock.module("@shared/lib/logger", () => ({
|
||||||
|
logger: {
|
||||||
|
info: () => { },
|
||||||
|
warn: () => { },
|
||||||
|
error: () => { },
|
||||||
|
debug: () => { },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("Items API", () => {
|
||||||
|
const port = 3002;
|
||||||
|
const hostname = "127.0.0.1";
|
||||||
|
const baseUrl = `http://${hostname}:${port}`;
|
||||||
|
let serverInstance: WebServerInstance | null = null;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Reset mock data before all tests
|
||||||
|
mockItems = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Health Potion",
|
||||||
|
description: "Restores health",
|
||||||
|
rarity: "C",
|
||||||
|
type: "CONSUMABLE",
|
||||||
|
price: 100n,
|
||||||
|
iconUrl: "/assets/items/1.png",
|
||||||
|
imageUrl: "/assets/items/1.png",
|
||||||
|
usageData: { consume: true, effects: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "Iron Sword",
|
||||||
|
description: "A basic sword",
|
||||||
|
rarity: "R",
|
||||||
|
type: "EQUIPMENT",
|
||||||
|
price: 500n,
|
||||||
|
iconUrl: "/assets/items/2.png",
|
||||||
|
imageUrl: "/assets/items/2.png",
|
||||||
|
usageData: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockIdCounter = 3;
|
||||||
|
|
||||||
|
serverInstance = await createWebServer({ port, hostname });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (serverInstance) {
|
||||||
|
await serverInstance.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// GET /api/items Tests
|
||||||
|
// ===========================================
|
||||||
|
describe("GET /api/items", () => {
|
||||||
|
test("should return all items", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/items`);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
const data = (await response.json()) as { items: MockItem[]; total: number };
|
||||||
|
expect(data.items).toBeInstanceOf(Array);
|
||||||
|
expect(data.total).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should filter items by search query", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/items?search=potion`);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
const data = (await response.json()) as { items: MockItem[]; total: number };
|
||||||
|
expect(data.items.every((item) =>
|
||||||
|
item.name.toLowerCase().includes("potion") ||
|
||||||
|
(item.description?.toLowerCase().includes("potion") ?? false)
|
||||||
|
)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should filter items by type", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/items?type=CONSUMABLE`);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
const data = (await response.json()) as { items: MockItem[]; total: number };
|
||||||
|
expect(data.items.every((item) => item.type === "CONSUMABLE")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should filter items by rarity", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/items?rarity=C`);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
const data = (await response.json()) as { items: MockItem[]; total: number };
|
||||||
|
expect(data.items.every((item) => item.rarity === "C")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// GET /api/items/:id Tests
|
||||||
|
// ===========================================
|
||||||
|
describe("GET /api/items/:id", () => {
|
||||||
|
test("should return a single item by ID", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/items/1`);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
const data = (await response.json()) as MockItem;
|
||||||
|
expect(data.id).toBe(1);
|
||||||
|
expect(data.name).toBe("Health Potion");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 404 for non-existent item", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/items/9999`);
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
|
||||||
|
const data = (await response.json()) as { error: string };
|
||||||
|
expect(data.error).toBe("Item not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// POST /api/items Tests
|
||||||
|
// ===========================================
|
||||||
|
describe("POST /api/items", () => {
|
||||||
|
test("should create a new item", async () => {
|
||||||
|
const newItem = {
|
||||||
|
name: "Magic Staff",
|
||||||
|
description: "A powerful staff",
|
||||||
|
rarity: "SR",
|
||||||
|
type: "EQUIPMENT",
|
||||||
|
price: "1000",
|
||||||
|
iconUrl: "/assets/items/placeholder.png",
|
||||||
|
imageUrl: "/assets/items/placeholder.png",
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/api/items`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(newItem),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
|
||||||
|
const data = (await response.json()) as { success: boolean; item: MockItem };
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
expect(data.item.name).toBe("Magic Staff");
|
||||||
|
expect(data.item.id).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject item without required fields", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/items`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ description: "No name or type" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
|
||||||
|
const data = (await response.json()) as { error: string };
|
||||||
|
expect(data.error).toContain("required");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject duplicate item name", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/items`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: "Health Potion", // Already exists
|
||||||
|
type: "CONSUMABLE",
|
||||||
|
iconUrl: "/assets/items/placeholder.png",
|
||||||
|
imageUrl: "/assets/items/placeholder.png",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
|
||||||
|
const data = (await response.json()) as { error: string };
|
||||||
|
expect(data.error).toContain("already exists");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// PUT /api/items/:id Tests
|
||||||
|
// ===========================================
|
||||||
|
describe("PUT /api/items/:id", () => {
|
||||||
|
test("should update an existing item", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/items/1`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
description: "Updated description",
|
||||||
|
price: "200",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
const data = (await response.json()) as { success: boolean; item: MockItem };
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
expect(data.item.description).toBe("Updated description");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 404 for updating non-existent item", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/items/9999`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: "New Name" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject duplicate name when updating", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/items/2`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: "Health Potion", // ID 1 has this name
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// DELETE /api/items/:id Tests
|
||||||
|
// ===========================================
|
||||||
|
describe("DELETE /api/items/:id", () => {
|
||||||
|
test("should delete an existing item", async () => {
|
||||||
|
// First, create an item to delete
|
||||||
|
const createResponse = await fetch(`${baseUrl}/api/items`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: "Item to Delete",
|
||||||
|
type: "MATERIAL",
|
||||||
|
iconUrl: "/assets/items/placeholder.png",
|
||||||
|
imageUrl: "/assets/items/placeholder.png",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { item } = (await createResponse.json()) as { item: MockItem };
|
||||||
|
|
||||||
|
// Now delete it
|
||||||
|
const deleteResponse = await fetch(`${baseUrl}/api/items/${item.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deleteResponse.status).toBe(204);
|
||||||
|
|
||||||
|
// Verify it's gone
|
||||||
|
const getResponse = await fetch(`${baseUrl}/api/items/${item.id}`);
|
||||||
|
expect(getResponse.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 404 for deleting non-existent item", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/items/9999`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// Static Asset Serving Tests
|
||||||
|
// ===========================================
|
||||||
|
describe("Static Asset Serving (/assets/*)", () => {
|
||||||
|
test("should return 404 for non-existent asset", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/assets/items/nonexistent.png`);
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should prevent path traversal attacks", async () => {
|
||||||
|
// Note: fetch() and HTTP servers normalize ".." segments before the handler sees them,
|
||||||
|
// so we can't send raw traversal paths over HTTP. Instead, test that a suspicious
|
||||||
|
// asset path (with encoded sequences) doesn't serve sensitive file content.
|
||||||
|
const response = await fetch(`${baseUrl}/assets/..%2f..%2f..%2fetc%2fpasswd`);
|
||||||
|
// Should not serve actual file content — expect 403 or 404
|
||||||
|
expect([403, 404]).toContain(response.status);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// Validation Edge Cases
|
||||||
|
// ===========================================
|
||||||
|
describe("Validation Edge Cases", () => {
|
||||||
|
test("should handle empty search query gracefully", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/items?search=`);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle invalid pagination values", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/items?limit=abc&offset=xyz`);
|
||||||
|
// Should not crash, may use defaults
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle missing content-type header", async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/items`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name: "Test", type: "MATERIAL" }),
|
||||||
|
});
|
||||||
|
// May fail due to no content-type, but shouldn't crash
|
||||||
|
expect([200, 201, 400, 415]).toContain(response.status);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
201
api/src/server.settings.test.ts
Normal file
201
api/src/server.settings.test.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { describe, expect, it, mock, beforeEach, afterEach, jest } from "bun:test";
|
||||||
|
import { type WebServerInstance } from "./server";
|
||||||
|
|
||||||
|
// Mock gameSettingsService — the route now uses this instead of config/saveConfig
|
||||||
|
const mockSettings = {
|
||||||
|
leveling: {
|
||||||
|
base: 100,
|
||||||
|
exponent: 1.5,
|
||||||
|
chat: { minXp: 10, maxXp: 20, cooldownMs: 60000 }
|
||||||
|
},
|
||||||
|
economy: {
|
||||||
|
daily: { amount: "100", streakBonus: "10", weeklyBonus: "50", cooldownMs: 86400000 },
|
||||||
|
transfers: { allowSelfTransfer: false, minAmount: "1" },
|
||||||
|
exam: { multMin: 1.5, multMax: 2.5 }
|
||||||
|
},
|
||||||
|
inventory: { maxStackSize: "99", maxSlots: 20 },
|
||||||
|
lootdrop: {
|
||||||
|
spawnChance: 0.1,
|
||||||
|
cooldownMs: 3600000,
|
||||||
|
minMessages: 10,
|
||||||
|
activityWindowMs: 300000,
|
||||||
|
reward: { min: 100, max: 500, currency: "gold" }
|
||||||
|
},
|
||||||
|
commands: { "help": true },
|
||||||
|
system: {},
|
||||||
|
moderation: {
|
||||||
|
prune: { maxAmount: 100, confirmThreshold: 50, batchSize: 100, batchDelayMs: 1000 },
|
||||||
|
},
|
||||||
|
trivia: {
|
||||||
|
entryFee: "50",
|
||||||
|
rewardMultiplier: 1.5,
|
||||||
|
timeoutSeconds: 30,
|
||||||
|
cooldownMs: 60000,
|
||||||
|
categories: [],
|
||||||
|
difficulty: "random"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockGetSettings = jest.fn(() => Promise.resolve(mockSettings));
|
||||||
|
const mockUpsertSettings = jest.fn(() => Promise.resolve(mockSettings));
|
||||||
|
const mockGetDefaults = jest.fn(() => mockSettings);
|
||||||
|
|
||||||
|
mock.module("@shared/modules/game-settings/game-settings.service", () => ({
|
||||||
|
gameSettingsService: {
|
||||||
|
getSettings: mockGetSettings,
|
||||||
|
upsertSettings: mockUpsertSettings,
|
||||||
|
getDefaults: mockGetDefaults,
|
||||||
|
invalidateCache: jest.fn(),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock DrizzleClient (dependency potentially imported transitively)
|
||||||
|
mock.module("@shared/db/DrizzleClient", () => ({
|
||||||
|
DrizzleClient: {}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock @shared/lib/utils (deepMerge is used by settings API)
|
||||||
|
mock.module("@shared/lib/utils", () => ({
|
||||||
|
deepMerge: (target: any, source: any) => ({ ...target, ...source }),
|
||||||
|
jsonReplacer: (key: string, value: any) =>
|
||||||
|
typeof value === "bigint" ? value.toString() : value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock BotClient
|
||||||
|
const mockGuild = {
|
||||||
|
roles: {
|
||||||
|
cache: [
|
||||||
|
{ id: "role1", name: "Admin", hexColor: "#ffffff", position: 1 },
|
||||||
|
{ id: "role2", name: "User", hexColor: "#000000", position: 0 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
channels: {
|
||||||
|
cache: [
|
||||||
|
{ id: "chan1", name: "general", type: 0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.module("../../bot/lib/BotClient", () => ({
|
||||||
|
AuroraClient: {
|
||||||
|
guilds: {
|
||||||
|
cache: {
|
||||||
|
get: () => mockGuild
|
||||||
|
}
|
||||||
|
},
|
||||||
|
commands: [
|
||||||
|
{ data: { name: "ping" } }
|
||||||
|
],
|
||||||
|
knownCommands: new Map([
|
||||||
|
["ping", "utility"],
|
||||||
|
["help", "utility"],
|
||||||
|
["disabled-cmd", "admin"]
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("@shared/lib/env", () => ({
|
||||||
|
env: {
|
||||||
|
DISCORD_GUILD_ID: "123456789"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock spawn
|
||||||
|
mock.module("bun", () => {
|
||||||
|
return {
|
||||||
|
spawn: jest.fn(() => ({
|
||||||
|
unref: () => { }
|
||||||
|
})),
|
||||||
|
serve: Bun.serve
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock auth (bypass authentication)
|
||||||
|
mock.module("./routes/auth.routes", () => ({
|
||||||
|
authRoutes: { name: "auth", handler: () => null },
|
||||||
|
isAuthenticated: () => true,
|
||||||
|
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import createWebServer after mocks
|
||||||
|
import { createWebServer } from "./server";
|
||||||
|
|
||||||
|
describe("Settings API", () => {
|
||||||
|
let serverInstance: WebServerInstance;
|
||||||
|
const PORT = 3009;
|
||||||
|
const HOSTNAME = "127.0.0.1";
|
||||||
|
const BASE_URL = `http://${HOSTNAME}:${PORT}`;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockGetSettings.mockImplementation(() => Promise.resolve(mockSettings));
|
||||||
|
mockUpsertSettings.mockImplementation(() => Promise.resolve(mockSettings));
|
||||||
|
serverInstance = await createWebServer({ port: PORT, hostname: HOSTNAME });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (serverInstance) {
|
||||||
|
await serverInstance.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/settings should return current configuration", async () => {
|
||||||
|
const res = await fetch(`${BASE_URL}/api/settings`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const data = await res.json() as any;
|
||||||
|
// Check values come through correctly
|
||||||
|
expect(data.economy.daily.amount).toBe("100");
|
||||||
|
expect(data.leveling.base).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/settings should save valid configuration via merge", async () => {
|
||||||
|
const partialConfig = { economy: { daily: { amount: "200" } } };
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE_URL}/api/settings`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(partialConfig)
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// upsertSettings should be called with the partial config
|
||||||
|
expect(mockUpsertSettings).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
economy: { daily: { amount: "200" } }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/settings should return 400 when save fails", async () => {
|
||||||
|
mockUpsertSettings.mockImplementationOnce(() => {
|
||||||
|
throw new Error("Validation failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE_URL}/api/settings`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const data = await res.json() as any;
|
||||||
|
expect(data.details).toBe("Validation failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/settings/meta should return simplified metadata", async () => {
|
||||||
|
const res = await fetch(`${BASE_URL}/api/settings/meta`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const data = await res.json() as any;
|
||||||
|
expect(data.roles).toHaveLength(2);
|
||||||
|
expect(data.roles[0]).toEqual({ id: "role1", name: "Admin", color: "#ffffff" });
|
||||||
|
expect(data.channels[0]).toEqual({ id: "chan1", name: "general", type: 0 });
|
||||||
|
|
||||||
|
// Check new commands structure
|
||||||
|
expect(data.commands).toBeArray();
|
||||||
|
expect(data.commands.length).toBeGreaterThan(0);
|
||||||
|
expect(data.commands[0]).toHaveProperty("name");
|
||||||
|
expect(data.commands[0]).toHaveProperty("category");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, test, expect, afterAll, mock } from "bun:test";
|
import { describe, test, expect, afterAll, mock } from "bun:test";
|
||||||
import type { WebServerInstance } from "./server";
|
import type { WebServerInstance } from "./server";
|
||||||
import { createWebServer } from "./server";
|
|
||||||
|
|
||||||
interface MockBotStats {
|
interface MockBotStats {
|
||||||
bot: { name: string; avatarUrl: string | null };
|
bot: { name: string; avatarUrl: string | null };
|
||||||
@@ -13,19 +12,21 @@ interface MockBotStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Mock DrizzleClient (dependency of dashboardService)
|
// 1. Mock DrizzleClient (dependency of dashboardService)
|
||||||
|
// Must provide full chainable builder for select().from().leftJoin().groupBy().orderBy().limit()
|
||||||
mock.module("@shared/db/DrizzleClient", () => {
|
mock.module("@shared/db/DrizzleClient", () => {
|
||||||
const mockBuilder = {
|
const mockBuilder: Record<string, any> = {};
|
||||||
where: mock(() => Promise.resolve([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }])),
|
// Every chainable method returns mock builder; terminal calls return resolved promise
|
||||||
then: (onfulfilled: any) => onfulfilled([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]),
|
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);
|
||||||
const mockFrom = {
|
mockBuilder.limit = mock(() => Promise.resolve([]));
|
||||||
from: mock(() => mockBuilder),
|
mockBuilder.leftJoin = mock(() => mockBuilder);
|
||||||
};
|
mockBuilder.groupBy = mock(() => mockBuilder);
|
||||||
|
mockBuilder.from = mock(() => mockBuilder);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
DrizzleClient: {
|
DrizzleClient: {
|
||||||
select: mock(() => mockFrom),
|
select: mock(() => mockBuilder),
|
||||||
query: {
|
query: {
|
||||||
transactions: { findMany: mock(() => Promise.resolve([])) },
|
transactions: { findMany: mock(() => Promise.resolve([])) },
|
||||||
moderationCases: { findMany: mock(() => Promise.resolve([])) },
|
moderationCases: { findMany: mock(() => Promise.resolve([])) },
|
||||||
@@ -33,6 +34,7 @@ mock.module("@shared/db/DrizzleClient", () => {
|
|||||||
findFirst: mock(() => Promise.resolve({ username: "test" })),
|
findFirst: mock(() => Promise.resolve({ username: "test" })),
|
||||||
findMany: mock(() => Promise.resolve([])),
|
findMany: mock(() => Promise.resolve([])),
|
||||||
},
|
},
|
||||||
|
lootdrops: { findMany: mock(() => Promise.resolve([])) },
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -51,10 +53,42 @@ mock.module("../../bot/lib/clientStats", () => ({
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 3. System Events (No mock needed, use real events)
|
// 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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 4. Mock auth (bypass authentication for testing)
|
||||||
|
mock.module("./routes/auth.routes", () => ({
|
||||||
|
authRoutes: { name: "auth", handler: () => null },
|
||||||
|
isAuthenticated: () => true,
|
||||||
|
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 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", () => {
|
describe("WebServer Security & Limits", () => {
|
||||||
const port = 3001;
|
const port = 3001;
|
||||||
|
const hostname = "127.0.0.1";
|
||||||
let serverInstance: WebServerInstance | null = null;
|
let serverInstance: WebServerInstance | null = null;
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -64,8 +98,8 @@ describe("WebServer Security & Limits", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("should reject more than 10 concurrent WebSocket connections", async () => {
|
test("should reject more than 10 concurrent WebSocket connections", async () => {
|
||||||
serverInstance = await createWebServer({ port, hostname: "localhost" });
|
serverInstance = await createWebServer({ port, hostname });
|
||||||
const wsUrl = `ws://localhost:${port}/ws`;
|
const wsUrl = `ws://${hostname}:${port}/ws`;
|
||||||
const sockets: WebSocket[] = [];
|
const sockets: WebSocket[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -92,45 +126,28 @@ describe("WebServer Security & Limits", () => {
|
|||||||
|
|
||||||
test("should return 200 for health check", async () => {
|
test("should return 200 for health check", async () => {
|
||||||
if (!serverInstance) {
|
if (!serverInstance) {
|
||||||
serverInstance = await createWebServer({ port, hostname: "localhost" });
|
serverInstance = await createWebServer({ port, hostname });
|
||||||
}
|
}
|
||||||
const response = await fetch(`http://localhost:${port}/api/health`);
|
const response = await fetch(`http://${hostname}:${port}/api/health`);
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
const data = (await response.json()) as { status: string };
|
const data = (await response.json()) as { status: string };
|
||||||
expect(data.status).toBe("ok");
|
expect(data.status).toBe("ok");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Administrative Actions Authorization", () => {
|
describe("Administrative Actions", () => {
|
||||||
test("should reject administrative actions without token", async () => {
|
test("should allow administrative actions without token", async () => {
|
||||||
const response = await fetch(`http://localhost:${port}/api/actions/reload-commands`, {
|
const response = await fetch(`http://${hostname}:${port}/api/actions/reload-commands`, {
|
||||||
method: "POST"
|
method: "POST"
|
||||||
});
|
});
|
||||||
expect(response.status).toBe(401);
|
// Should be 200 (OK) or 500 (if underlying service fails, but NOT 401)
|
||||||
});
|
expect(response.status).not.toBe(401);
|
||||||
|
|
||||||
test("should reject administrative actions with invalid token", async () => {
|
|
||||||
const response = await fetch(`http://localhost:${port}/api/actions/reload-commands`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Authorization": "Bearer wrong-token" }
|
|
||||||
});
|
|
||||||
expect(response.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should accept administrative actions with valid token", async () => {
|
|
||||||
const { env } = await import("@shared/lib/env");
|
|
||||||
const response = await fetch(`http://localhost:${port}/api/actions/reload-commands`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Authorization": `Bearer ${env.ADMIN_TOKEN}` }
|
|
||||||
});
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should reject maintenance mode with invalid payload", async () => {
|
test("should reject maintenance mode with invalid payload", async () => {
|
||||||
const { env } = await import("@shared/lib/env");
|
const response = await fetch(`http://${hostname}:${port}/api/actions/maintenance-mode`, {
|
||||||
const response = await fetch(`http://localhost:${port}/api/actions/maintenance-mode`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${env.ADMIN_TOKEN}`,
|
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ not_enabled: true }) // Wrong field
|
body: JSON.stringify({ not_enabled: true }) // Wrong field
|
||||||
243
api/src/server.ts
Normal file
243
api/src/server.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* @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 { logger } from "@shared/lib/logger";
|
||||||
|
import { handleRequest } from "./routes";
|
||||||
|
import { getFullDashboardStats } from "./routes/stats.helper";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
export interface WebServerConfig {
|
||||||
|
port?: number;
|
||||||
|
hostname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebServerInstance {
|
||||||
|
server: ReturnType<typeof serve>;
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and starts the API server.
|
||||||
|
*
|
||||||
|
* @param config - Server configuration options
|
||||||
|
* @param config.port - Port to listen on (default: 3000)
|
||||||
|
* @param config.hostname - Hostname to bind to (default: "localhost")
|
||||||
|
* @returns Promise resolving to server instance with stop() method
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const server = await createWebServer({ port: 3000, hostname: "0.0.0.0" });
|
||||||
|
* console.log(`Server running at ${server.url}`);
|
||||||
|
*
|
||||||
|
* // To stop the server:
|
||||||
|
* await server.stop();
|
||||||
|
*/
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
// Configuration constants
|
||||||
|
const MAX_CONNECTIONS = 10;
|
||||||
|
const MAX_PAYLOAD_BYTES = 16384; // 16KB
|
||||||
|
const IDLE_TIMEOUT_SECONDS = 60;
|
||||||
|
|
||||||
|
// Interval for broadcasting stats to all connected WS clients
|
||||||
|
let statsBroadcastInterval: Timer | undefined;
|
||||||
|
|
||||||
|
const server = serve({
|
||||||
|
port,
|
||||||
|
hostname,
|
||||||
|
async fetch(req, server) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
|
||||||
|
// WebSocket upgrade handling
|
||||||
|
if (url.pathname === "/ws") {
|
||||||
|
const currentConnections = server.pendingWebSockets;
|
||||||
|
if (currentConnections >= MAX_CONNECTIONS) {
|
||||||
|
logger.warn("web", `Connection rejected: limit reached (${currentConnections}/${MAX_CONNECTIONS})`);
|
||||||
|
return new Response("Connection limit reached", { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = server.upgrade(req);
|
||||||
|
if (success) return undefined;
|
||||||
|
return new Response("WebSocket upgrade failed", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegate to modular route handlers
|
||||||
|
const response = await handleRequest(req, url);
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
// Serve panel static files (production)
|
||||||
|
const panelDistDir = join(import.meta.dir, "../../panel/dist");
|
||||||
|
const staticResponse = await servePanelStatic(url.pathname, panelDistDir);
|
||||||
|
if (staticResponse) return staticResponse;
|
||||||
|
|
||||||
|
// No matching route found
|
||||||
|
return new Response("Not Found", { status: 404 });
|
||||||
|
},
|
||||||
|
|
||||||
|
websocket: {
|
||||||
|
/**
|
||||||
|
* Called when a WebSocket client connects.
|
||||||
|
* Subscribes the client to the dashboard channel and sends initial stats.
|
||||||
|
*/
|
||||||
|
open(ws) {
|
||||||
|
ws.subscribe("dashboard");
|
||||||
|
logger.debug("web", `Client connected. Total: ${server.pendingWebSockets}`);
|
||||||
|
|
||||||
|
// Send initial stats
|
||||||
|
getFullDashboardStats().then(stats => {
|
||||||
|
ws.send(JSON.stringify({ type: "STATS_UPDATE", data: stats }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start broadcast interval if this is the first client
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a WebSocket message is received.
|
||||||
|
* Handles PING/PONG heartbeat messages.
|
||||||
|
*/
|
||||||
|
async message(ws, message) {
|
||||||
|
try {
|
||||||
|
const messageStr = message.toString();
|
||||||
|
|
||||||
|
// Defense-in-depth: redundant length check before parsing
|
||||||
|
if (messageStr.length > MAX_PAYLOAD_BYTES) {
|
||||||
|
logger.error("web", "Payload exceeded maximum limit");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawData = JSON.parse(messageStr);
|
||||||
|
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);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.type === "PING") {
|
||||||
|
ws.send(JSON.stringify({ type: "PONG" }));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("web", "Failed to handle message", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a WebSocket client disconnects.
|
||||||
|
* Stops the broadcast interval if no clients remain.
|
||||||
|
*/
|
||||||
|
close(ws) {
|
||||||
|
ws.unsubscribe("dashboard");
|
||||||
|
logger.debug("web", `Client disconnected. Total remaining: ${server.pendingWebSockets}`);
|
||||||
|
|
||||||
|
// Stop broadcast interval if no clients left
|
||||||
|
if (server.pendingWebSockets === 0 && statsBroadcastInterval) {
|
||||||
|
clearInterval(statsBroadcastInterval);
|
||||||
|
statsBroadcastInterval = undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
maxPayloadLength: MAX_PAYLOAD_BYTES,
|
||||||
|
idleTimeout: IDLE_TIMEOUT_SECONDS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for real-time events from the system bus
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the web server from the main application root.
|
||||||
|
* Kept for backward compatibility.
|
||||||
|
*
|
||||||
|
* @param webProjectPath - Deprecated, no longer used
|
||||||
|
* @param config - Server configuration options
|
||||||
|
* @returns Promise resolving to server instance
|
||||||
|
*/
|
||||||
|
export async function startWebServerFromRoot(
|
||||||
|
webProjectPath: string,
|
||||||
|
config: WebServerConfig = {}
|
||||||
|
): Promise<WebServerInstance> {
|
||||||
|
return createWebServer(config);
|
||||||
|
}
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// Environment setup & latest features
|
// Environment setup & latest features
|
||||||
"lib": [
|
"lib": ["ESNext"],
|
||||||
"ESNext",
|
|
||||||
"DOM"
|
|
||||||
],
|
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "Preserve",
|
"module": "Preserve",
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"jsx": "react-jsx",
|
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
// Bundler mode
|
// Bundler mode
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
@@ -38,8 +34,5 @@
|
|||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noPropertyAccessFromIndexSignature": false
|
"noPropertyAccessFromIndexSignature": false
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": ["node_modules"]
|
||||||
"dist",
|
|
||||||
"node_modules"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
0
bot/assets/graphics/items/.gitkeep
Normal file
0
bot/assets/graphics/items/.gitkeep
Normal file
@@ -1,7 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
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 { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const moderationCase = createCommand({
|
export const moderationCase = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -16,39 +17,35 @@ export const moderationCase = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||||
|
|
||||||
try {
|
// Validate case ID format
|
||||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
if (!caseId.match(/^CASE-\d+$/)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate case ID format
|
// Get the case
|
||||||
if (!caseId.match(/^CASE-\d+$/)) {
|
const moderationCase = await moderationService.getCaseById(caseId);
|
||||||
|
|
||||||
|
if (!moderationCase) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display the case
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
embeds: [getCaseEmbed(moderationCase)]
|
||||||
});
|
});
|
||||||
return;
|
},
|
||||||
}
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
// 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.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getCasesListEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const cases = createCommand({
|
export const cases = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -22,33 +23,29 @@ export const cases = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
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 {
|
// Get cases for the user
|
||||||
const targetUser = interaction.options.getUser("user", true);
|
const userCases = await moderationService.getUserCases(targetUser.id, activeOnly);
|
||||||
const activeOnly = interaction.options.getBoolean("active_only") || false;
|
|
||||||
|
|
||||||
// Get cases for the user
|
const title = activeOnly
|
||||||
const userCases = await ModerationService.getUserCases(targetUser.id, activeOnly);
|
? `⚠️ Active Cases for ${targetUser.username}`
|
||||||
|
: `📋 All Cases for ${targetUser.username}`;
|
||||||
|
|
||||||
const title = activeOnly
|
const description = userCases.length === 0
|
||||||
? `⚠️ Active Cases for ${targetUser.username}`
|
? undefined
|
||||||
: `📋 All Cases for ${targetUser.username}`;
|
: `Total cases: **${userCases.length}**`;
|
||||||
|
|
||||||
const description = userCases.length === 0
|
// Display the cases
|
||||||
? undefined
|
await interaction.editReply({
|
||||||
: `Total cases: **${userCases.length}**`;
|
embeds: [getCasesListEmbed(userCases, title, description)]
|
||||||
|
});
|
||||||
// Display the cases
|
},
|
||||||
await interaction.editReply({
|
{ ephemeral: true }
|
||||||
embeds: [getCasesListEmbed(userCases, title, description)]
|
);
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Cases command error:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("An error occurred while fetching cases.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const clearwarning = createCommand({
|
export const clearwarning = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -23,62 +24,58 @@ export const clearwarning = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
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 {
|
// Validate case ID format
|
||||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
if (!caseId.match(/^CASE-\d+$/)) {
|
||||||
const reason = interaction.options.getString("reason") || "Cleared by moderator";
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate case ID format
|
// Check if case exists and is active
|
||||||
if (!caseId.match(/^CASE-\d+$/)) {
|
const existingCase = await moderationService.getCaseById(caseId);
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
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
|
// Send success message
|
||||||
const existingCase = await ModerationService.getCaseById(caseId);
|
|
||||||
|
|
||||||
if (!existingCase) {
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
embeds: [getClearSuccessEmbed(caseId)]
|
||||||
});
|
});
|
||||||
return;
|
},
|
||||||
}
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
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.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ModalSubmitInteraction } from "discord.js";
|
|
||||||
import { config, saveConfig } from "@shared/lib/config";
|
|
||||||
import type { GameConfigType } from "@shared/lib/config";
|
|
||||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
|
||||||
|
|
||||||
export const configCommand = createCommand({
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName("config")
|
|
||||||
.setDescription("Edit the bot configuration")
|
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
|
||||||
execute: async (interaction) => {
|
|
||||||
console.log(`Config command executed by ${interaction.user.tag}`);
|
|
||||||
const replacer = (key: string, value: any) => {
|
|
||||||
if (typeof value === 'bigint') {
|
|
||||||
return value.toString();
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentConfigJson = JSON.stringify(config, replacer, 4);
|
|
||||||
|
|
||||||
const modal = new ModalBuilder()
|
|
||||||
.setCustomId("config-modal")
|
|
||||||
.setTitle("Edit Configuration");
|
|
||||||
|
|
||||||
const jsonInput = new TextInputBuilder()
|
|
||||||
.setCustomId("json-input")
|
|
||||||
.setLabel("Configuration JSON")
|
|
||||||
.setStyle(TextInputStyle.Paragraph)
|
|
||||||
.setValue(currentConfigJson)
|
|
||||||
.setRequired(true);
|
|
||||||
|
|
||||||
const actionRow = new ActionRowBuilder<TextInputBuilder>().addComponents(jsonInput);
|
|
||||||
modal.addComponents(actionRow);
|
|
||||||
|
|
||||||
await interaction.showModal(modal);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const submitted = await interaction.awaitModalSubmit({
|
|
||||||
time: 300000, // 5 minutes
|
|
||||||
filter: (i) => i.customId === "config-modal" && i.user.id === interaction.user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
const jsonString = submitted.fields.getTextInputValue("json-input");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newConfig = JSON.parse(jsonString);
|
|
||||||
saveConfig(newConfig as GameConfigType);
|
|
||||||
|
|
||||||
await submitted.reply({
|
|
||||||
embeds: [createSuccessEmbed("Configuration updated successfully.", "Config Saved")]
|
|
||||||
});
|
|
||||||
} catch (parseError) {
|
|
||||||
await submitted.reply({
|
|
||||||
embeds: [createErrorEmbed(`Invalid JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`, "Config Update Failed")],
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
// Timeout or other error handling if needed, usually just ignore timeouts for modals
|
|
||||||
if (error instanceof Error && error.message.includes('time')) {
|
|
||||||
// specific timeout handling if desired
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
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 { DrizzleClient } from "@shared/db/DrizzleClient";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
import { items } from "@db/schema";
|
import { items } from "@db/schema";
|
||||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const createColor = createCommand({
|
export const createColor = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -31,64 +33,60 @@ export const createColor = createCommand({
|
|||||||
)
|
)
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
execute: async (interaction) => {
|
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);
|
// 1. Validate Color
|
||||||
const colorInput = interaction.options.getString("color", true);
|
const colorRegex = /^#([0-9A-F]{3}){1,2}$/i;
|
||||||
const price = interaction.options.getNumber("price") || 500;
|
if (!colorRegex.test(colorInput)) {
|
||||||
const imageUrl = interaction.options.getString("image") || "https://cdn.discordapp.com/attachments/1450061247365124199/1453122950822760559/Main_Chip_1.png";
|
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Hex Color code. Format: #RRGGBB")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Validate Color
|
// 2. Create Role
|
||||||
const colorRegex = /^#([0-9A-F]{3}){1,2}$/i;
|
const role = await interaction.guild?.roles.create({
|
||||||
if (!colorRegex.test(colorInput)) {
|
name: name,
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Hex Color code. Format: #RRGGBB")] });
|
color: colorInput as any,
|
||||||
return;
|
reason: `Created via /createcolor by ${interaction.user.tag}`
|
||||||
}
|
});
|
||||||
|
|
||||||
try {
|
if (!role) {
|
||||||
// 2. Create Role
|
throw new Error("Failed to 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) {
|
// 3. Add to guild settings
|
||||||
throw new Error("Failed to create role.");
|
await guildSettingsService.addColorRole(interaction.guildId!, role.id);
|
||||||
}
|
invalidateGuildConfigCache(interaction.guildId!);
|
||||||
|
|
||||||
// 3. Update Config
|
// 4. Create Item
|
||||||
if (!config.colorRoles.includes(role.id)) {
|
await DrizzleClient.insert(items).values({
|
||||||
config.colorRoles.push(role.id);
|
name: `Color Role - ${name}`,
|
||||||
saveConfig(config);
|
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
|
// 5. Success
|
||||||
await DrizzleClient.insert(items).values({
|
await interaction.editReply({
|
||||||
name: `Color Role - ${name}`,
|
embeds: [createSuccessEmbed(
|
||||||
description: `Use this item to apply the ${name} color to your name.`,
|
`**Role:** <@&${role.id}> (${colorInput})\n**Item:** Color Role - ${name}\n**Price:** ${price} 🪙`,
|
||||||
type: "CONSUMABLE",
|
"✅ Color Role & Item Created"
|
||||||
rarity: "Common",
|
)]
|
||||||
price: BigInt(price),
|
});
|
||||||
iconUrl: "",
|
},
|
||||||
imageUrl: imageUrl,
|
{ ephemeral: true }
|
||||||
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}`)] });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
293
bot/commands/admin/featureflags.ts
Normal file
293
bot/commands/admin/featureflags.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { createCommand } from "@shared/lib/utils";
|
||||||
|
import { SlashCommandBuilder, PermissionFlagsBits, Colors, userMention, roleMention, ChatInputCommandInteraction } from "discord.js";
|
||||||
|
import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service";
|
||||||
|
import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
|
export const featureflags = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("featureflags")
|
||||||
|
.setDescription("Manage feature flags for beta testing")
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("list")
|
||||||
|
.setDescription("List all feature flags")
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("create")
|
||||||
|
.setDescription("Create a new feature flag")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("name")
|
||||||
|
.setDescription("Name of the feature flag")
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("description")
|
||||||
|
.setDescription("Description of the feature flag")
|
||||||
|
.setRequired(false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("delete")
|
||||||
|
.setDescription("Delete a feature flag")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("name")
|
||||||
|
.setDescription("Name of the feature flag")
|
||||||
|
.setRequired(true)
|
||||||
|
.setAutocomplete(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("enable")
|
||||||
|
.setDescription("Enable a feature flag")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("name")
|
||||||
|
.setDescription("Name of the feature flag")
|
||||||
|
.setRequired(true)
|
||||||
|
.setAutocomplete(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("disable")
|
||||||
|
.setDescription("Disable a feature flag")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("name")
|
||||||
|
.setDescription("Name of the feature flag")
|
||||||
|
.setRequired(true)
|
||||||
|
.setAutocomplete(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("grant")
|
||||||
|
.setDescription("Grant access to a feature flag")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("name")
|
||||||
|
.setDescription("Name of the feature flag")
|
||||||
|
.setRequired(true)
|
||||||
|
.setAutocomplete(true)
|
||||||
|
)
|
||||||
|
.addUserOption(opt =>
|
||||||
|
opt.setName("user")
|
||||||
|
.setDescription("User to grant access to")
|
||||||
|
.setRequired(false)
|
||||||
|
)
|
||||||
|
.addRoleOption(opt =>
|
||||||
|
opt.setName("role")
|
||||||
|
.setDescription("Role to grant access to")
|
||||||
|
.setRequired(false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("revoke")
|
||||||
|
.setDescription("Revoke access from a feature flag")
|
||||||
|
.addIntegerOption(opt =>
|
||||||
|
opt.setName("id")
|
||||||
|
.setDescription("Access record ID to revoke")
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("access")
|
||||||
|
.setDescription("List access records for a feature flag")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("name")
|
||||||
|
.setDescription("Name of the feature flag")
|
||||||
|
.setRequired(true)
|
||||||
|
.setAutocomplete(true)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
autocomplete: async (interaction) => {
|
||||||
|
const focused = interaction.options.getFocused(true);
|
||||||
|
|
||||||
|
if (focused.name === "name") {
|
||||||
|
const flags = await featureFlagsService.listFlags();
|
||||||
|
const filtered = flags
|
||||||
|
.filter(f => f.name.toLowerCase().includes(focused.value.toLowerCase()))
|
||||||
|
.slice(0, 25);
|
||||||
|
|
||||||
|
await interaction.respond(
|
||||||
|
filtered.map(f => ({ name: `${f.name} (${f.enabled ? "enabled" : "disabled"})`, value: f.name }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
execute: async (interaction) => {
|
||||||
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
switch (subcommand) {
|
||||||
|
case "list":
|
||||||
|
await handleList(interaction);
|
||||||
|
break;
|
||||||
|
case "create":
|
||||||
|
await handleCreate(interaction);
|
||||||
|
break;
|
||||||
|
case "delete":
|
||||||
|
await handleDelete(interaction);
|
||||||
|
break;
|
||||||
|
case "enable":
|
||||||
|
await handleEnable(interaction);
|
||||||
|
break;
|
||||||
|
case "disable":
|
||||||
|
await handleDisable(interaction);
|
||||||
|
break;
|
||||||
|
case "grant":
|
||||||
|
await handleGrant(interaction);
|
||||||
|
break;
|
||||||
|
case "revoke":
|
||||||
|
await handleRevoke(interaction);
|
||||||
|
break;
|
||||||
|
case "access":
|
||||||
|
await handleAccess(interaction);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleList(interaction: ChatInputCommandInteraction) {
|
||||||
|
const flags = await featureFlagsService.listFlags();
|
||||||
|
|
||||||
|
if (flags.length === 0) {
|
||||||
|
await interaction.editReply({ embeds: [createBaseEmbed("Feature Flags", "No feature flags have been created yet.", Colors.Blue)] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = createBaseEmbed("Feature Flags", undefined, Colors.Blue)
|
||||||
|
.addFields(
|
||||||
|
flags.map(f => ({
|
||||||
|
name: f.name,
|
||||||
|
value: `${f.enabled ? "✅ Enabled" : "❌ Disabled"}\n${f.description || "*No description*"}`,
|
||||||
|
inline: false,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate(interaction: ChatInputCommandInteraction) {
|
||||||
|
const name = interaction.options.getString("name", true);
|
||||||
|
const description = interaction.options.getString("description");
|
||||||
|
|
||||||
|
const flag = await featureFlagsService.createFlag(name, description ?? undefined);
|
||||||
|
|
||||||
|
if (!flag) {
|
||||||
|
await interaction.editReply({ embeds: [createErrorEmbed("Failed to create feature flag.")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" created successfully. Use \`/featureflags enable\` to enable it.`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(interaction: ChatInputCommandInteraction) {
|
||||||
|
const name = interaction.options.getString("name", true);
|
||||||
|
|
||||||
|
const flag = await featureFlagsService.deleteFlag(name);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been deleted.`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEnable(interaction: ChatInputCommandInteraction) {
|
||||||
|
const name = interaction.options.getString("name", true);
|
||||||
|
|
||||||
|
const flag = await featureFlagsService.setFlagEnabled(name, true);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been enabled.`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDisable(interaction: ChatInputCommandInteraction) {
|
||||||
|
const name = interaction.options.getString("name", true);
|
||||||
|
|
||||||
|
const flag = await featureFlagsService.setFlagEnabled(name, false);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been disabled.`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGrant(interaction: ChatInputCommandInteraction) {
|
||||||
|
const name = interaction.options.getString("name", true);
|
||||||
|
const user = interaction.options.getUser("user");
|
||||||
|
const role = interaction.options.getRole("role");
|
||||||
|
|
||||||
|
if (!user && !role) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("You must specify either a user or a role to grant access to.")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await featureFlagsService.grantAccess(name, {
|
||||||
|
userId: user?.id,
|
||||||
|
roleId: role?.id,
|
||||||
|
guildId: interaction.guildId!,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!access) {
|
||||||
|
await interaction.editReply({ embeds: [createErrorEmbed("Failed to grant access.")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target: string;
|
||||||
|
if (user) {
|
||||||
|
target = userMention(user.id);
|
||||||
|
} else if (role) {
|
||||||
|
target = roleMention(role.id);
|
||||||
|
} else {
|
||||||
|
target = "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Access to "**${name}**" granted to ${target} (ID: ${access.id})`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevoke(interaction: ChatInputCommandInteraction) {
|
||||||
|
const id = interaction.options.getInteger("id", true);
|
||||||
|
|
||||||
|
const access = await featureFlagsService.revokeAccess(id);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Access record #${access.id} has been revoked.`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAccess(interaction: ChatInputCommandInteraction) {
|
||||||
|
const name = interaction.options.getString("name", true);
|
||||||
|
|
||||||
|
const accessRecords = await featureFlagsService.listAccess(name);
|
||||||
|
|
||||||
|
if (accessRecords.length === 0) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createBaseEmbed("Feature Flag Access", `No access records for "**${name}**".`, Colors.Blue)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = accessRecords.map(a => {
|
||||||
|
let target = "Unknown";
|
||||||
|
if (a.userId) target = `User: ${userMention(a.userId.toString())}`;
|
||||||
|
else if (a.roleId) target = `Role: ${roleMention(a.roleId.toString())}`;
|
||||||
|
else if (a.guildId) target = `Guild: ${a.guildId.toString()}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: `ID: ${a.id}`,
|
||||||
|
value: target,
|
||||||
|
inline: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const embed = createBaseEmbed(`Feature Flag Access: ${name}`, undefined, Colors.Blue)
|
||||||
|
.addFields(fields);
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
}
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
|
||||||
import { createBaseEmbed } from "@lib/embeds";
|
|
||||||
import { config, reloadConfig, toggleCommand } from "@shared/lib/config";
|
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
|
||||||
|
|
||||||
export const features = createCommand({
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName("features")
|
|
||||||
.setDescription("Manage bot features and commands")
|
|
||||||
.addSubcommand(sub =>
|
|
||||||
sub.setName("list")
|
|
||||||
.setDescription("List all commands and their status")
|
|
||||||
)
|
|
||||||
.addSubcommand(sub =>
|
|
||||||
sub.setName("toggle")
|
|
||||||
.setDescription("Enable or disable a command")
|
|
||||||
.addStringOption(option =>
|
|
||||||
option.setName("command")
|
|
||||||
.setDescription("The name of the command")
|
|
||||||
.setRequired(true)
|
|
||||||
)
|
|
||||||
.addBooleanOption(option =>
|
|
||||||
option.setName("enabled")
|
|
||||||
.setDescription("Whether the command should be enabled")
|
|
||||||
.setRequired(true)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
|
||||||
execute: async (interaction) => {
|
|
||||||
const subcommand = interaction.options.getSubcommand();
|
|
||||||
|
|
||||||
if (subcommand === "list") {
|
|
||||||
const activeCommands = AuroraClient.commands;
|
|
||||||
const categories = new Map<string, string[]>();
|
|
||||||
|
|
||||||
// Group active commands
|
|
||||||
activeCommands.forEach(cmd => {
|
|
||||||
const cat = cmd.category || 'Uncategorized';
|
|
||||||
if (!categories.has(cat)) categories.set(cat, []);
|
|
||||||
categories.get(cat)!.push(cmd.data.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Config overrides
|
|
||||||
const overrides = Object.entries(config.commands)
|
|
||||||
.map(([name, enabled]) => `• **${name}**: ${enabled ? "✅ Enabled (Override)" : "❌ Disabled"}`);
|
|
||||||
|
|
||||||
const embed = createBaseEmbed("Command Features", undefined, "Blue");
|
|
||||||
|
|
||||||
// Add fields for each category
|
|
||||||
const sortedCategories = [...categories.keys()].sort();
|
|
||||||
for (const cat of sortedCategories) {
|
|
||||||
const cmds = categories.get(cat)!.sort();
|
|
||||||
const cmdList = cmds.map(name => {
|
|
||||||
const isOverride = config.commands[name] !== undefined;
|
|
||||||
return isOverride ? `**${name}** (See Overrides)` : `**${name}**`;
|
|
||||||
}).join(", ");
|
|
||||||
|
|
||||||
embed.addFields({ name: `📂 ${cat.toUpperCase()}`, value: cmdList || "None" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overrides.length > 0) {
|
|
||||||
embed.addFields({ name: "⚙️ Configuration Overrides", value: overrides.join("\n") });
|
|
||||||
} else {
|
|
||||||
embed.addFields({ name: "⚙️ Configuration Overrides", value: "No overrides set." });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check permissions manually as a fallback (though defaultMemberPermissions handles it at the API level)
|
|
||||||
if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) {
|
|
||||||
await interaction.reply({ content: "❌ You need Administrator permissions to use this command.", flags: MessageFlags.Ephemeral });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
|
|
||||||
} else if (subcommand === "toggle") {
|
|
||||||
const commandName = interaction.options.getString("command", true);
|
|
||||||
const enabled = interaction.options.getBoolean("enabled", true);
|
|
||||||
|
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
|
||||||
|
|
||||||
toggleCommand(commandName, enabled);
|
|
||||||
|
|
||||||
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Reloading configuration...` });
|
|
||||||
|
|
||||||
// Reload config from disk (which was updated by toggleCommand)
|
|
||||||
reloadConfig();
|
|
||||||
|
|
||||||
await AuroraClient.loadCommands(true);
|
|
||||||
await AuroraClient.deployCommands();
|
|
||||||
|
|
||||||
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Commands reloaded!` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,20 +1,18 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import {
|
import {
|
||||||
SlashCommandBuilder,
|
SlashCommandBuilder,
|
||||||
ActionRowBuilder,
|
|
||||||
ButtonBuilder,
|
|
||||||
ButtonStyle,
|
|
||||||
type BaseGuildTextChannel,
|
type BaseGuildTextChannel,
|
||||||
PermissionFlagsBits,
|
PermissionFlagsBits,
|
||||||
MessageFlags
|
MessageFlags
|
||||||
} from "discord.js";
|
} from "discord.js";
|
||||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds";
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
import { UserError } from "@/lib/errors";
|
|
||||||
import { items } from "@db/schema";
|
import { items } from "@db/schema";
|
||||||
import { ilike, isNotNull, and } from "drizzle-orm";
|
import { ilike, isNotNull, and, inArray } from "drizzle-orm";
|
||||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
import { getShopListingMessage } from "@/modules/economy/shop.view";
|
import { getShopListingMessage } from "@/modules/economy/shop.view";
|
||||||
|
import { EffectType, LootType } from "@shared/lib/constants";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const listing = createCommand({
|
export const listing = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -33,44 +31,67 @@ export const listing = createCommand({
|
|||||||
)
|
)
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
execute: async (interaction) => {
|
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);
|
if (!targetChannel || !targetChannel.isSendable()) {
|
||||||
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
|
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!targetChannel || !targetChannel.isSendable()) {
|
const item = await inventoryService.getItem(itemId);
|
||||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] });
|
if (!item) {
|
||||||
return;
|
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] });
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const item = await inventoryService.getItem(itemId);
|
if (!item.price) {
|
||||||
if (!item) {
|
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item "${item.name}" is not for sale (no price set).`)] });
|
||||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] });
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!item.price) {
|
// Prepare context for lootboxes
|
||||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item "${item.name}" is not for sale (no price set).`)] });
|
const context: { referencedItems: Map<number, { name: string; rarity: string }> } = { referencedItems: new Map() };
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const listingMessage = getShopListingMessage({
|
const usageData = item.usageData as any;
|
||||||
...item,
|
const lootboxEffect = usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX);
|
||||||
formattedPrice: `${item.price} 🪙`,
|
|
||||||
price: item.price
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
if (lootboxEffect && lootboxEffect.pool) {
|
||||||
await targetChannel.send(listingMessage);
|
const itemIds = lootboxEffect.pool
|
||||||
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
|
.filter((drop: any) => drop.type === LootType.ITEM && drop.itemId)
|
||||||
} catch (error: any) {
|
.map((drop: any) => drop.itemId);
|
||||||
if (error instanceof UserError) {
|
|
||||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
if (itemIds.length > 0) {
|
||||||
} else {
|
// Remove duplicates
|
||||||
console.error("Error creating listing:", error);
|
const uniqueIds = [...new Set(itemIds)] as number[];
|
||||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
|
||||||
}
|
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) => {
|
autocomplete: async (interaction) => {
|
||||||
const focusedValue = interaction.options.getFocused();
|
const focusedValue = interaction.options.getFocused();
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { CaseType } from "@shared/lib/constants";
|
import { CaseType } from "@shared/lib/constants";
|
||||||
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const note = createCommand({
|
export const note = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -24,39 +25,35 @@ export const note = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
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 {
|
// Create the note case
|
||||||
const targetUser = interaction.options.getUser("user", true);
|
const moderationCase = await moderationService.createCase({
|
||||||
const noteText = interaction.options.getString("note", true);
|
type: CaseType.NOTE,
|
||||||
|
userId: targetUser.id,
|
||||||
// Create the note case
|
username: targetUser.username,
|
||||||
const moderationCase = await ModerationService.createCase({
|
moderatorId: interaction.user.id,
|
||||||
type: CaseType.NOTE,
|
moderatorName: interaction.user.username,
|
||||||
userId: targetUser.id,
|
reason: noteText,
|
||||||
username: targetUser.username,
|
|
||||||
moderatorId: interaction.user.id,
|
|
||||||
moderatorName: interaction.user.username,
|
|
||||||
reason: noteText,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!moderationCase) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("Failed to create note.")]
|
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send success message
|
if (!moderationCase) {
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)]
|
embeds: [getModerationErrorEmbed("Failed to create note.")]
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
// Send success message
|
||||||
console.error("Note command error:", error);
|
await interaction.editReply({
|
||||||
await interaction.editReply({
|
embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)]
|
||||||
embeds: [getModerationErrorEmbed("An error occurred while adding the note.")]
|
});
|
||||||
});
|
},
|
||||||
}
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getCasesListEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const notes = createCommand({
|
export const notes = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -16,28 +17,24 @@ export const notes = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const targetUser = interaction.options.getUser("user", true);
|
||||||
|
|
||||||
try {
|
// Get all notes for the user
|
||||||
const targetUser = interaction.options.getUser("user", true);
|
const userNotes = await moderationService.getUserNotes(targetUser.id);
|
||||||
|
|
||||||
// Get all notes for the user
|
// Display the notes
|
||||||
const userNotes = await ModerationService.getUserNotes(targetUser.id);
|
await interaction.editReply({
|
||||||
|
embeds: [getCasesListEmbed(
|
||||||
// Display the notes
|
userNotes,
|
||||||
await interaction.editReply({
|
`📝 Staff Notes for ${targetUser.username}`,
|
||||||
embeds: [getCasesListEmbed(
|
userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**`
|
||||||
userNotes,
|
)]
|
||||||
`📝 Staff Notes for ${targetUser.username}`,
|
});
|
||||||
userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**`
|
},
|
||||||
)]
|
{ ephemeral: true }
|
||||||
});
|
);
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Notes command error:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("An error occurred while fetching notes.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||||
import { config } from "@shared/lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
import { PruneService } from "@shared/modules/moderation/prune.service";
|
import { pruneService } from "@shared/modules/moderation/prune.service";
|
||||||
import {
|
import {
|
||||||
getConfirmationMessage,
|
getConfirmationMessage,
|
||||||
getProgressEmbed,
|
getProgressEmbed,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getPruneWarningEmbed,
|
getPruneWarningEmbed,
|
||||||
getCancelledEmbed
|
getCancelledEmbed
|
||||||
} from "@/modules/moderation/prune.view";
|
} from "@/modules/moderation/prune.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const prune = createCommand({
|
export const prune = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -38,142 +39,126 @@ export const prune = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
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 {
|
// Validate inputs
|
||||||
const amount = interaction.options.getInteger("amount");
|
if (!amount && !all) {
|
||||||
const user = interaction.options.getUser("user");
|
// Default to 10 messages
|
||||||
const all = interaction.options.getBoolean("all") || false;
|
} else if (amount && all) {
|
||||||
|
|
||||||
// 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
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [getSuccessEmbed(result)],
|
embeds: [getPruneErrorEmbed("Cannot specify both `amount` and `all`. Please use one or the other.")]
|
||||||
components: []
|
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.message.includes("time")) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getPruneWarningEmbed("Confirmation timed out. Please try again.")],
|
|
||||||
components: []
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No confirmation needed, proceed directly
|
|
||||||
const result = await PruneService.deleteMessages(
|
|
||||||
interaction.channel!,
|
|
||||||
{
|
|
||||||
amount: finalAmount as number,
|
|
||||||
userId: user?.id,
|
|
||||||
all: false
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if no messages were found
|
|
||||||
if (result.deletedCount === 0) {
|
|
||||||
if (user) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getPruneWarningEmbed(`No messages found from **${user.username}** in the last ${finalAmount} messages.`)]
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getPruneWarningEmbed("No messages found to delete.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.editReply({
|
const finalAmount = all ? 'all' : (amount || 10);
|
||||||
embeds: [getSuccessEmbed(result)]
|
const confirmThreshold = config.moderation.prune.confirmThreshold;
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
// Check if confirmation is needed
|
||||||
console.error("Prune command error:", error);
|
const needsConfirmation = all || (typeof finalAmount === 'number' && finalAmount > confirmThreshold);
|
||||||
|
|
||||||
let errorMessage = "An unexpected error occurred while trying to delete messages.";
|
if (needsConfirmation) {
|
||||||
if (error instanceof Error) {
|
// Estimate message count for confirmation
|
||||||
if (error.message.includes("permission")) {
|
let estimatedCount: number | undefined;
|
||||||
errorMessage = "I don't have permission to delete messages in this channel.";
|
if (all) {
|
||||||
} else if (error.message.includes("channel type")) {
|
try {
|
||||||
errorMessage = "This command cannot be used in this type of channel.";
|
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
|
||||||
|
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 {
|
} 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({
|
// Check if no messages were found
|
||||||
embeds: [getPruneErrorEmbed(errorMessage)]
|
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 { createCommand } from "@shared/lib/utils";
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createSuccessEmbed } from "@lib/embeds";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const refresh = createCommand({
|
export const refresh = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -9,25 +10,24 @@ export const refresh = createCommand({
|
|||||||
.setDescription("Reloads all commands and config without restarting")
|
.setDescription("Reloads all commands and config without restarting")
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
execute: async (interaction) => {
|
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 {
|
// Deploy commands
|
||||||
const start = Date.now();
|
await AuroraClient.deployCommands();
|
||||||
await AuroraClient.loadCommands(true);
|
|
||||||
const duration = Date.now() - start;
|
|
||||||
|
|
||||||
// Deploy commands
|
const embed = createSuccessEmbed(
|
||||||
await AuroraClient.deployCommands();
|
`Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`,
|
||||||
|
"System Refreshed"
|
||||||
|
);
|
||||||
|
|
||||||
const embed = createSuccessEmbed(
|
await interaction.editReply({ embeds: [embed] });
|
||||||
`Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`,
|
},
|
||||||
"System Refreshed"
|
{ ephemeral: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
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")] });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
|
||||||
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
||||||
import { createBaseEmbed, createErrorEmbed } from "@/lib/embeds";
|
import { createErrorEmbed } from "@/lib/embeds";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const terminal = createCommand({
|
export const terminal = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -23,15 +24,14 @@ export const terminal = createCommand({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.reply({ ephemeral: true, content: "Initializing terminal..." });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
try {
|
async () => {
|
||||||
await terminalService.init(channel as TextChannel);
|
await terminalService.init(channel as TextChannel);
|
||||||
await interaction.editReply({ content: "✅ Terminal initialized!" });
|
await interaction.editReply({ content: "✅ Terminal initialized!" });
|
||||||
} catch (error) {
|
},
|
||||||
console.error(error);
|
{ ephemeral: true }
|
||||||
await interaction.editReply({ content: "❌ Failed to initialize terminal." });
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
|
||||||
import { UpdateService } from "@shared/modules/admin/update.service";
|
|
||||||
import {
|
|
||||||
getCheckingEmbed,
|
|
||||||
getNoUpdatesEmbed,
|
|
||||||
getUpdatesAvailableMessage,
|
|
||||||
getPreparingEmbed,
|
|
||||||
getUpdatingEmbed,
|
|
||||||
getCancelledEmbed,
|
|
||||||
getTimeoutEmbed,
|
|
||||||
getErrorEmbed,
|
|
||||||
getRollbackSuccessEmbed,
|
|
||||||
getRollbackFailedEmbed
|
|
||||||
} from "@/modules/admin/update.view";
|
|
||||||
|
|
||||||
export const update = createCommand({
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName("update")
|
|
||||||
.setDescription("Check for updates and restart the bot")
|
|
||||||
.addSubcommand(sub =>
|
|
||||||
sub.setName("check")
|
|
||||||
.setDescription("Check for and apply available updates")
|
|
||||||
.addBooleanOption(option =>
|
|
||||||
option.setName("force")
|
|
||||||
.setDescription("Force update even if no changes detected")
|
|
||||||
.setRequired(false)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.addSubcommand(sub =>
|
|
||||||
sub.setName("rollback")
|
|
||||||
.setDescription("Rollback to the previous version")
|
|
||||||
)
|
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
|
||||||
|
|
||||||
execute: async (interaction) => {
|
|
||||||
const subcommand = interaction.options.getSubcommand();
|
|
||||||
|
|
||||||
if (subcommand === "rollback") {
|
|
||||||
await handleRollback(interaction);
|
|
||||||
} else {
|
|
||||||
await handleUpdate(interaction);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleUpdate(interaction: any) {
|
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
|
||||||
const force = interaction.options.getBoolean("force") || false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Check for updates
|
|
||||||
await interaction.editReply({ embeds: [getCheckingEmbed()] });
|
|
||||||
const updateInfo = await UpdateService.checkForUpdates();
|
|
||||||
|
|
||||||
if (!updateInfo.hasUpdates && !force) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getNoUpdatesEmbed(updateInfo.currentCommit)]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Analyze requirements
|
|
||||||
const requirements = await UpdateService.checkUpdateRequirements(updateInfo.branch);
|
|
||||||
const categories = UpdateService.categorizeChanges(requirements.changedFiles);
|
|
||||||
|
|
||||||
// 3. Show confirmation with details
|
|
||||||
const { embeds, components } = getUpdatesAvailableMessage(
|
|
||||||
updateInfo,
|
|
||||||
requirements,
|
|
||||||
categories,
|
|
||||||
force
|
|
||||||
);
|
|
||||||
const response = await interaction.editReply({ embeds, components });
|
|
||||||
|
|
||||||
// 4. Wait for confirmation
|
|
||||||
try {
|
|
||||||
const confirmation = await response.awaitMessageComponent({
|
|
||||||
filter: (i: any) => i.user.id === interaction.user.id,
|
|
||||||
componentType: ComponentType.Button,
|
|
||||||
time: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
if (confirmation.customId === "confirm_update") {
|
|
||||||
await confirmation.update({
|
|
||||||
embeds: [getPreparingEmbed()],
|
|
||||||
components: []
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Save rollback point
|
|
||||||
const previousCommit = await UpdateService.saveRollbackPoint();
|
|
||||||
|
|
||||||
// 6. Prepare restart context
|
|
||||||
await UpdateService.prepareRestartContext({
|
|
||||||
channelId: interaction.channelId,
|
|
||||||
userId: interaction.user.id,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
runMigrations: requirements.needsMigrations,
|
|
||||||
installDependencies: requirements.needsRootInstall || requirements.needsWebInstall,
|
|
||||||
previousCommit: previousCommit.substring(0, 7),
|
|
||||||
newCommit: updateInfo.latestCommit
|
|
||||||
});
|
|
||||||
|
|
||||||
// 7. Show updating status
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getUpdatingEmbed(requirements)]
|
|
||||||
});
|
|
||||||
|
|
||||||
// 8. Perform update
|
|
||||||
await UpdateService.performUpdate(updateInfo.branch);
|
|
||||||
|
|
||||||
// 9. Trigger restart
|
|
||||||
await UpdateService.triggerRestart();
|
|
||||||
|
|
||||||
} else {
|
|
||||||
await confirmation.update({
|
|
||||||
embeds: [getCancelledEmbed()],
|
|
||||||
components: []
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Error && e.message.includes("time")) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getTimeoutEmbed()],
|
|
||||||
components: []
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Update failed:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getErrorEmbed(error)],
|
|
||||||
components: []
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRollback(interaction: any) {
|
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const hasRollback = await UpdateService.hasRollbackPoint();
|
|
||||||
|
|
||||||
if (!hasRollback) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getRollbackFailedEmbed("No rollback point available. Rollback is only possible after a recent update.")]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await UpdateService.rollback();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getRollbackSuccessEmbed(result.message.split(" ").pop() || "unknown")]
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restart after rollback
|
|
||||||
setTimeout(() => UpdateService.triggerRestart(), 1000);
|
|
||||||
} else {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getRollbackFailedEmbed(result.message)]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Rollback failed:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getErrorEmbed(error)]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import {
|
import {
|
||||||
getWarnSuccessEmbed,
|
getWarnSuccessEmbed,
|
||||||
getModerationErrorEmbed,
|
getModerationErrorEmbed,
|
||||||
getUserWarningEmbed
|
|
||||||
} from "@/modules/moderation/moderation.view";
|
} 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({
|
export const warn = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -28,60 +28,63 @@ export const warn = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
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 {
|
// Don't allow warning bots
|
||||||
const targetUser = interaction.options.getUser("user", true);
|
if (targetUser.bot) {
|
||||||
const reason = interaction.options.getString("reason", true);
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed("You cannot warn bots.")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Don't allow warning bots
|
// Don't allow self-warnings
|
||||||
if (targetUser.bot) {
|
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({
|
await interaction.editReply({
|
||||||
embeds: [getModerationErrorEmbed("You cannot warn bots.")]
|
embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't allow self-warnings
|
// Follow up if auto-timeout was issued
|
||||||
if (targetUser.id === interaction.user.id) {
|
if (autoTimeoutIssued) {
|
||||||
await interaction.editReply({
|
await interaction.followUp({
|
||||||
embeds: [getModerationErrorEmbed("You cannot warn yourself.")]
|
embeds: [getModerationErrorEmbed(
|
||||||
});
|
`⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.`
|
||||||
return;
|
)],
|
||||||
}
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
// Issue the warning via service
|
}
|
||||||
const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({
|
},
|
||||||
userId: targetUser.id,
|
{ ephemeral: true }
|
||||||
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.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getWarningsEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const warnings = createCommand({
|
export const warnings = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -16,24 +17,20 @@ export const warnings = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const targetUser = interaction.options.getUser("user", true);
|
||||||
|
|
||||||
try {
|
// Get active warnings for the user
|
||||||
const targetUser = interaction.options.getUser("user", true);
|
const activeWarnings = await moderationService.getUserWarnings(targetUser.id);
|
||||||
|
|
||||||
// Get active warnings for the user
|
// Display the warnings
|
||||||
const activeWarnings = await ModerationService.getUserWarnings(targetUser.id);
|
await interaction.editReply({
|
||||||
|
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)]
|
||||||
// Display the warnings
|
});
|
||||||
await interaction.editReply({
|
},
|
||||||
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)]
|
{ ephemeral: true }
|
||||||
});
|
);
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Warnings command error:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("An error occurred while fetching warnings.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createCommand } from "@shared/lib/utils";
|
|||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { createErrorEmbed } from "@/lib/embeds";
|
import { createErrorEmbed } from "@/lib/embeds";
|
||||||
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const webhook = createCommand({
|
export const webhook = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -14,43 +15,40 @@ export const webhook = createCommand({
|
|||||||
.setRequired(true)
|
.setRequired(true)
|
||||||
),
|
),
|
||||||
execute: async (interaction) => {
|
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);
|
try {
|
||||||
let payload;
|
payload = JSON.parse(payloadString);
|
||||||
|
} catch (error) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("The provided payload is not valid JSON.", "Invalid JSON")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const channel = interaction.channel;
|
||||||
payload = JSON.parse(payloadString);
|
|
||||||
} catch (error) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [createErrorEmbed("The provided payload is not valid JSON.", "Invalid JSON")]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const channel = interaction.channel;
|
if (!channel || !('createWebhook' in channel)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("This channel does not support webhooks.", "Unsupported Channel")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!channel || !('createWebhook' in channel)) {
|
await sendWebhookMessage(
|
||||||
await interaction.editReply({
|
channel,
|
||||||
embeds: [createErrorEmbed("This channel does not support webhooks.", "Unsupported Channel")]
|
payload,
|
||||||
});
|
interaction.client.user,
|
||||||
return;
|
`Proxy message requested by ${interaction.user.tag}`
|
||||||
}
|
);
|
||||||
|
|
||||||
try {
|
await interaction.editReply({ content: "Message sent successfully!" });
|
||||||
await sendWebhookMessage(
|
},
|
||||||
channel,
|
{ ephemeral: true }
|
||||||
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")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,34 +2,29 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { economyService } from "@shared/modules/economy/economy.service";
|
import { economyService } from "@shared/modules/economy/economy.service";
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createSuccessEmbed } from "@lib/embeds";
|
||||||
import { UserError } from "@/lib/errors";
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const daily = createCommand({
|
export const daily = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
.setName("daily")
|
.setName("daily")
|
||||||
.setDescription("Claim your daily reward"),
|
.setDescription("Claim your daily reward"),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
try {
|
await withCommandErrorHandling(
|
||||||
const result = await economyService.claimDaily(interaction.user.id);
|
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!")
|
const embed = createSuccessEmbed(`You claimed ** ${result.amount}** Astral Units!${result.isWeekly ? `\n🎉 **Weekly Bonus!** +${result.weeklyBonus} extra!` : ''}`, "💰 Daily Reward Claimed!")
|
||||||
.addFields(
|
.addFields(
|
||||||
{ name: "Streak", value: `🔥 ${result.streak} days`, inline: true },
|
{ 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: "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 }
|
{ name: "Next Reward", value: `<t:${Math.floor(result.nextReadyAt.getTime() / 1000)}:R> `, inline: true }
|
||||||
)
|
)
|
||||||
.setColor("Gold");
|
.setColor("Gold");
|
||||||
|
|
||||||
await interaction.reply({ embeds: [embed] });
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error instanceof UserError) {
|
|
||||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
|
||||||
} else {
|
|
||||||
console.error("Error claiming daily:", error);
|
|
||||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1,21 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { userService } from "@shared/modules/user/user.service";
|
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
import { UserError } from "@/lib/errors";
|
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
|
||||||
import { userTimers, users } from "@db/schema";
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
import { eq, and, sql } from "drizzle-orm";
|
|
||||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
|
||||||
import { config } from "@shared/lib/config";
|
|
||||||
import { TimerType } from "@shared/lib/constants";
|
|
||||||
|
|
||||||
const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
|
|
||||||
const EXAM_TIMER_KEY = 'default';
|
|
||||||
|
|
||||||
interface ExamMetadata {
|
|
||||||
examDay: number;
|
|
||||||
lastXp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||||
|
|
||||||
@@ -24,182 +11,62 @@ export const exam = createCommand({
|
|||||||
.setName("exam")
|
.setName("exam")
|
||||||
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
|
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply();
|
await withCommandErrorHandling(
|
||||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
interaction,
|
||||||
if (!user) {
|
async () => {
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to retrieve user data.")] });
|
// First, try to take the exam or check status
|
||||||
return;
|
const result = await examService.takeExam(interaction.user.id);
|
||||||
}
|
|
||||||
const now = new Date();
|
|
||||||
const currentDay = now.getDay();
|
|
||||||
|
|
||||||
try {
|
if (result.status === ExamStatus.NOT_REGISTERED) {
|
||||||
// 1. Fetch existing timer/exam data
|
// Register the user
|
||||||
const timer = await DrizzleClient.query.userTimers.findFirst({
|
const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username);
|
||||||
where: and(
|
const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000);
|
||||||
eq(userTimers.userId, user.id),
|
|
||||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
|
||||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. First Run Logic
|
await interaction.editReply({
|
||||||
if (!timer) {
|
embeds: [createSuccessEmbed(
|
||||||
// Set exam day to today
|
`You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` +
|
||||||
const nextExamDate = new Date(now);
|
`Come back on <t:${nextRegTimestamp}:D> (<t:${nextRegTimestamp}:R>) to take your first exam!`,
|
||||||
nextExamDate.setDate(now.getDate() + 7);
|
"Exam Registration Successful"
|
||||||
nextExamDate.setHours(0, 0, 0, 0);
|
)]
|
||||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const metadata: ExamMetadata = {
|
const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000);
|
||||||
examDay: currentDay,
|
|
||||||
lastXp: (user.xp ?? 0n).toString()
|
|
||||||
};
|
|
||||||
|
|
||||||
await DrizzleClient.insert(userTimers).values({
|
if (result.status === ExamStatus.COOLDOWN) {
|
||||||
userId: user.id,
|
await interaction.editReply({
|
||||||
type: EXAM_TIMER_TYPE,
|
embeds: [createErrorEmbed(
|
||||||
key: EXAM_TIMER_KEY,
|
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
||||||
expiresAt: nextExamDate,
|
`Next exam available: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`
|
||||||
metadata: metadata
|
)]
|
||||||
});
|
});
|
||||||
|
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({
|
await interaction.editReply({
|
||||||
embeds: [createSuccessEmbed(
|
embeds: [createSuccessEmbed(
|
||||||
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}** (Server Time).\n` +
|
`**XP Gained:** ${result.xpDiff?.toString()}\n` +
|
||||||
`Come back on <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>) to take your first exam!`,
|
`**Multiplier:** x${result.multiplier?.toFixed(2)}\n` +
|
||||||
"Exam Registration Successful"
|
`**Reward:** ${result.reward?.toString()} Currency\n\n` +
|
||||||
|
`See you next week: <t:${nextExamTimestamp}:D>`,
|
||||||
|
"Exam Passed!"
|
||||||
)]
|
)]
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
const metadata = timer.metadata as unknown as ExamMetadata;
|
|
||||||
const examDay = metadata.examDay;
|
|
||||||
|
|
||||||
// 3. Cooldown Check
|
|
||||||
const expiresAt = new Date(timer.expiresAt);
|
|
||||||
expiresAt.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
if (now < expiresAt) {
|
|
||||||
// Calculate time remaining
|
|
||||||
const timestamp = Math.floor(expiresAt.getTime() / 1000);
|
|
||||||
|
|
||||||
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:${timestamp}:D> (<t:${timestamp}:R>)`
|
|
||||||
)]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Day Check
|
|
||||||
if (currentDay !== examDay) {
|
|
||||||
// Calculate next correct exam day to correct the schedule
|
|
||||||
let daysUntil = (examDay - currentDay + 7) % 7;
|
|
||||||
if (daysUntil === 0) daysUntil = 7;
|
|
||||||
|
|
||||||
const nextExamDate = new Date(now);
|
|
||||||
nextExamDate.setDate(now.getDate() + daysUntil);
|
|
||||||
nextExamDate.setHours(0, 0, 0, 0);
|
|
||||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
|
||||||
|
|
||||||
const newMetadata: ExamMetadata = {
|
|
||||||
examDay: examDay,
|
|
||||||
lastXp: (user.xp ?? 0n).toString()
|
|
||||||
};
|
|
||||||
|
|
||||||
await DrizzleClient.update(userTimers)
|
|
||||||
.set({
|
|
||||||
expiresAt: nextExamDate,
|
|
||||||
metadata: newMetadata
|
|
||||||
})
|
|
||||||
.where(and(
|
|
||||||
eq(userTimers.userId, user.id),
|
|
||||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
|
||||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
|
||||||
));
|
|
||||||
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [createErrorEmbed(
|
|
||||||
`You missed your exam day! Your exam day is **${DAYS[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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Reward Calculation
|
|
||||||
const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case
|
|
||||||
const currentXp = user.xp ?? 0n;
|
|
||||||
const diff = currentXp - lastXp;
|
|
||||||
|
|
||||||
// Calculate Reward
|
|
||||||
const multMin = config.economy.exam.multMin;
|
|
||||||
const multMax = config.economy.exam.multMax;
|
|
||||||
const multiplier = Math.random() * (multMax - multMin) + multMin;
|
|
||||||
|
|
||||||
// Allow negative reward? existing description implies "difference", usually gain.
|
|
||||||
// If diff is negative (lost XP?), reward might be 0.
|
|
||||||
let reward = 0n;
|
|
||||||
if (diff > 0n) {
|
|
||||||
reward = BigInt(Math.floor(Number(diff) * multiplier));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Update State
|
|
||||||
const nextExamDate = new Date(now);
|
|
||||||
nextExamDate.setDate(now.getDate() + 7);
|
|
||||||
nextExamDate.setHours(0, 0, 0, 0);
|
|
||||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
|
||||||
|
|
||||||
const newMetadata: ExamMetadata = {
|
|
||||||
examDay: examDay,
|
|
||||||
lastXp: currentXp.toString()
|
|
||||||
};
|
|
||||||
|
|
||||||
await DrizzleClient.transaction(async (tx) => {
|
|
||||||
// Update Timer
|
|
||||||
await tx.update(userTimers)
|
|
||||||
.set({
|
|
||||||
expiresAt: nextExamDate,
|
|
||||||
metadata: newMetadata
|
|
||||||
})
|
|
||||||
.where(and(
|
|
||||||
eq(userTimers.userId, user.id),
|
|
||||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
|
||||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
|
||||||
));
|
|
||||||
|
|
||||||
// Add Currency
|
|
||||||
if (reward > 0n) {
|
|
||||||
await tx.update(users)
|
|
||||||
.set({
|
|
||||||
balance: sql`${users.balance} + ${reward}`
|
|
||||||
})
|
|
||||||
.where(eq(users.id, user.id));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [createSuccessEmbed(
|
|
||||||
`**XP Gained:** ${diff.toString()}\n` +
|
|
||||||
`**Multiplier:** x${multiplier.toFixed(2)}\n` +
|
|
||||||
`**Reward:** ${reward.toString()} Currency\n\n` +
|
|
||||||
`See you next week: <t:${nextExamTimestamp}:D>`,
|
|
||||||
"Exam Passed!"
|
|
||||||
)]
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error instanceof UserError) {
|
|
||||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
|
||||||
} else {
|
|
||||||
console.error("Error in exam command:", error);
|
|
||||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { economyService } from "@shared/modules/economy/economy.service";
|
|||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { config } from "@shared/lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
import { UserError } from "@/lib/errors";
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const pay = createCommand({
|
export const pay = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -50,20 +50,14 @@ export const pay = createCommand({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await withCommandErrorHandling(
|
||||||
await interaction.deferReply();
|
interaction,
|
||||||
await economyService.transfer(senderId, receiverId.toString(), amount);
|
async () => {
|
||||||
|
await economyService.transfer(senderId, receiverId.toString(), amount);
|
||||||
|
|
||||||
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
|
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
|
||||||
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
|
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.")] });
|
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
108
bot/commands/economy/trivia.ts
Normal file
108
bot/commands/economy/trivia.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { createCommand } from "@shared/lib/utils";
|
||||||
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
|
import { triviaService } from "@shared/modules/trivia/trivia.service";
|
||||||
|
import { getTriviaQuestionView } from "@/modules/trivia/trivia.view";
|
||||||
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
import { config } from "@shared/lib/config";
|
||||||
|
import { TriviaCategory } from "@shared/lib/constants";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
|
export const trivia = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("trivia")
|
||||||
|
.setDescription("Play trivia to win currency! Answer correctly within the time limit.")
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('category')
|
||||||
|
.setDescription('Select a specific category')
|
||||||
|
.setRequired(false)
|
||||||
|
.addChoices(
|
||||||
|
{ name: 'General Knowledge', value: String(TriviaCategory.GENERAL_KNOWLEDGE) },
|
||||||
|
{ name: 'Books', value: String(TriviaCategory.BOOKS) },
|
||||||
|
{ name: 'Film', value: String(TriviaCategory.FILM) },
|
||||||
|
{ name: 'Music', value: String(TriviaCategory.MUSIC) },
|
||||||
|
{ name: 'Video Games', value: String(TriviaCategory.VIDEO_GAMES) },
|
||||||
|
{ name: 'Science & Nature', value: String(TriviaCategory.SCIENCE_NATURE) },
|
||||||
|
{ name: 'Computers', value: String(TriviaCategory.COMPUTERS) },
|
||||||
|
{ name: 'Mathematics', value: String(TriviaCategory.MATHEMATICS) },
|
||||||
|
{ name: 'Mythology', value: String(TriviaCategory.MYTHOLOGY) },
|
||||||
|
{ name: 'Sports', value: String(TriviaCategory.SPORTS) },
|
||||||
|
{ name: 'Geography', value: String(TriviaCategory.GEOGRAPHY) },
|
||||||
|
{ name: 'History', value: String(TriviaCategory.HISTORY) },
|
||||||
|
{ name: 'Politics', value: String(TriviaCategory.POLITICS) },
|
||||||
|
{ name: 'Art', value: String(TriviaCategory.ART) },
|
||||||
|
{ name: 'Animals', value: String(TriviaCategory.ANIMALS) },
|
||||||
|
{ name: 'Anime & Manga', value: String(TriviaCategory.ANIME_MANGA) },
|
||||||
|
)
|
||||||
|
),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
try {
|
||||||
|
const categoryId = interaction.options.getString('category');
|
||||||
|
|
||||||
|
// Check if user can play BEFORE deferring
|
||||||
|
const canPlay = await triviaService.canPlayTrivia(interaction.user.id);
|
||||||
|
|
||||||
|
if (!canPlay.canPlay) {
|
||||||
|
// Cooldown error - ephemeral
|
||||||
|
const timestamp = Math.floor(canPlay.nextAvailable!.getTime() / 1000);
|
||||||
|
await interaction.reply({
|
||||||
|
embeds: [createErrorEmbed(
|
||||||
|
`You're on cooldown! Try again <t:${timestamp}:R>.`
|
||||||
|
)],
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User can play - use standardized error handling for the main operation
|
||||||
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
// Start trivia session (deducts entry fee)
|
||||||
|
const session = await triviaService.startTrivia(
|
||||||
|
interaction.user.id,
|
||||||
|
interaction.user.username,
|
||||||
|
categoryId ? parseInt(categoryId) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate Components v2 message
|
||||||
|
const { components, flags } = getTriviaQuestionView(session, interaction.user.username);
|
||||||
|
|
||||||
|
// Reply with Components v2 question
|
||||||
|
await interaction.editReply({
|
||||||
|
components,
|
||||||
|
flags
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up automatic timeout cleanup
|
||||||
|
setTimeout(async () => {
|
||||||
|
const stillActive = triviaService.getSession(session.sessionId);
|
||||||
|
if (stillActive) {
|
||||||
|
// User didn't answer - clean up session with no reward
|
||||||
|
try {
|
||||||
|
await triviaService.submitAnswer(session.sessionId, interaction.user.id, false);
|
||||||
|
} catch (error) {
|
||||||
|
// Session already cleaned up, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, config.trivia.timeoutSeconds * 1000 + 5000); // 5 seconds grace period
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
// Handle errors from the pre-defer canPlayTrivia check
|
||||||
|
if (error instanceof UserError) {
|
||||||
|
await interaction.reply({
|
||||||
|
embeds: [createErrorEmbed(error.message)],
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error("Error in trivia command:", error);
|
||||||
|
await interaction.reply({
|
||||||
|
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { config } from "@shared/lib/config";
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
import { createErrorEmbed } from "@/lib/embeds";
|
import { createErrorEmbed } from "@/lib/embeds";
|
||||||
import { getFeedbackTypeMenu } from "@/modules/feedback/feedback.view";
|
import { getFeedbackTypeMenu } from "@/modules/feedback/feedback.view";
|
||||||
|
|
||||||
@@ -9,8 +9,10 @@ export const feedback = createCommand({
|
|||||||
.setName("feedback")
|
.setName("feedback")
|
||||||
.setDescription("Submit feedback, feature requests, or bug reports"),
|
.setDescription("Submit feedback, feature requests, or bug reports"),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
|
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||||
|
|
||||||
// Check if feedback channel is configured
|
// Check if feedback channel is configured
|
||||||
if (!config.feedbackChannelId) {
|
if (!guildConfig.feedbackChannelId) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
embeds: [createErrorEmbed("Feedback system is not configured. Please contact an administrator.")],
|
embeds: [createErrorEmbed("Feedback system is not configured. Please contact an administrator.")],
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
|||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { createErrorEmbed } from "@lib/embeds";
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
||||||
import type { ItemUsageData } from "@shared/lib/types";
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
import { UserError } from "@/lib/errors";
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
import { config } from "@shared/lib/config";
|
|
||||||
|
|
||||||
export const use = createCommand({
|
export const use = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -19,54 +18,50 @@ export const use = createCommand({
|
|||||||
.setAutocomplete(true)
|
.setAutocomplete(true)
|
||||||
),
|
),
|
||||||
execute: async (interaction) => {
|
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 itemId = interaction.options.getNumber("item", true);
|
||||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] });
|
await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const result = await inventoryService.useItem(user.id.toString(), itemId);
|
||||||
const result = await inventoryService.useItem(user.id.toString(), itemId);
|
|
||||||
|
|
||||||
const usageData = result.usageData;
|
const usageData = result.usageData;
|
||||||
if (usageData) {
|
if (usageData) {
|
||||||
for (const effect of usageData.effects) {
|
for (const effect of usageData.effects) {
|
||||||
if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') {
|
if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') {
|
||||||
try {
|
try {
|
||||||
const member = await interaction.guild?.members.fetch(user.id.toString());
|
const member = await interaction.guild?.members.fetch(user.id.toString());
|
||||||
if (member) {
|
if (member) {
|
||||||
if (effect.type === 'TEMP_ROLE') {
|
if (effect.type === 'TEMP_ROLE') {
|
||||||
await member.roles.add(effect.roleId);
|
await member.roles.add(effect.roleId);
|
||||||
} else if (effect.type === 'COLOR_ROLE') {
|
} else if (effect.type === 'COLOR_ROLE') {
|
||||||
// Remove existing color roles
|
// Remove existing color roles
|
||||||
const rolesToRemove = config.colorRoles.filter(r => member.roles.cache.has(r));
|
const rolesToRemove = colorRoles.filter(r => member.roles.cache.has(r));
|
||||||
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
|
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
|
||||||
await member.roles.add(effect.roleId);
|
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 { embed, files } = getItemUseResultEmbed(result.results, result.item);
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed], files });
|
||||||
}
|
}
|
||||||
|
);
|
||||||
const embed = getItemUseResultEmbed(result.results, result.item);
|
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error instanceof UserError) {
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
|
||||||
} else {
|
|
||||||
console.error("Error using item:", error);
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred while using the item.")] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
autocomplete: async (interaction) => {
|
autocomplete: async (interaction) => {
|
||||||
const focusedValue = interaction.options.getFocused();
|
const focusedValue = interaction.options.getFocused();
|
||||||
|
|||||||
@@ -1,25 +1,83 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
||||||
import { questService } from "@shared/modules/quest/quest.service";
|
import { questService } from "@shared/modules/quest/quest.service";
|
||||||
import { createWarningEmbed } from "@lib/embeds";
|
import { createSuccessEmbed } from "@lib/embeds";
|
||||||
import { getQuestListEmbed } from "@/modules/quest/quest.view";
|
import {
|
||||||
|
getQuestListComponents,
|
||||||
|
getAvailableQuestsComponents,
|
||||||
|
getQuestActionRows
|
||||||
|
} from "@/modules/quest/quest.view";
|
||||||
|
|
||||||
export const quests = createCommand({
|
export const quests = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
.setName("quests")
|
.setName("quests")
|
||||||
.setDescription("View your active quests"),
|
.setDescription("View your active and available quests"),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
const response = await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
const userQuests = await questService.getUserQuests(interaction.user.id);
|
const userId = interaction.user.id;
|
||||||
|
|
||||||
if (!userQuests || userQuests.length === 0) {
|
const updateView = async (viewType: 'active' | 'available') => {
|
||||||
await interaction.editReply({ embeds: [createWarningEmbed("You have no active quests.", "Quest Log")] });
|
const userQuests = await questService.getUserQuests(userId);
|
||||||
return;
|
const availableQuests = await questService.getAvailableQuests(userId);
|
||||||
}
|
|
||||||
|
|
||||||
const embed = getQuestListEmbed(userQuests);
|
const containers = viewType === 'active'
|
||||||
|
? getQuestListComponents(userQuests)
|
||||||
|
: getAvailableQuestsComponents(availableQuests);
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
const actionRows = getQuestActionRows(viewType);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: null,
|
||||||
|
embeds: null as any,
|
||||||
|
components: [...containers, ...actionRows] as any,
|
||||||
|
flags: MessageFlags.IsComponentsV2,
|
||||||
|
allowedMentions: { parse: [] }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial view
|
||||||
|
await updateView('active');
|
||||||
|
|
||||||
|
const collector = response.createMessageComponentCollector({
|
||||||
|
time: 120000, // 2 minutes
|
||||||
|
componentType: undefined // Allow buttons
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('collect', async (i) => {
|
||||||
|
if (i.user.id !== interaction.user.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (i.customId === "quest_view_active") {
|
||||||
|
await i.deferUpdate();
|
||||||
|
await updateView('active');
|
||||||
|
} else if (i.customId === "quest_view_available") {
|
||||||
|
await i.deferUpdate();
|
||||||
|
await updateView('available');
|
||||||
|
} else if (i.customId.startsWith("quest_accept:")) {
|
||||||
|
const questIdStr = i.customId.split(":")[1];
|
||||||
|
if (!questIdStr) return;
|
||||||
|
const questId = parseInt(questIdStr);
|
||||||
|
await questService.assignQuest(userId, questId);
|
||||||
|
|
||||||
|
await i.reply({
|
||||||
|
embeds: [createSuccessEmbed(`You have accepted a new quest!`, "Quest Accepted")],
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateView('active');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Quest interaction error:", error);
|
||||||
|
await i.followUp({
|
||||||
|
content: "Something went wrong while processing your quest interaction.",
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('end', () => {
|
||||||
|
interaction.editReply({ components: [] }).catch(() => {});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
import { Events } from "discord.js";
|
import { Events } from "discord.js";
|
||||||
import type { Event } from "@shared/lib/types";
|
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";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
|
|
||||||
// Visitor role
|
|
||||||
const event: Event<Events.GuildMemberAdd> = {
|
const event: Event<Events.GuildMemberAdd> = {
|
||||||
name: Events.GuildMemberAdd,
|
name: Events.GuildMemberAdd,
|
||||||
execute: async (member) => {
|
execute: async (member) => {
|
||||||
console.log(`👤 New member joined: ${member.user.tag} (${member.id})`);
|
console.log(`👤 New member joined: ${member.user.tag} (${member.id})`);
|
||||||
|
|
||||||
|
const guildConfig = await getGuildConfig(member.guild.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await userService.getUserById(member.id);
|
const user = await userService.getUserById(member.id);
|
||||||
|
|
||||||
if (user && user.class) {
|
if (user && user.class) {
|
||||||
console.log(`🔄 Returning student detected: ${member.user.tag}`);
|
console.log(`🔄 Returning student detected: ${member.user.tag}`);
|
||||||
await member.roles.remove(config.visitorRole);
|
if (guildConfig.visitorRole) {
|
||||||
await member.roles.add(config.studentRole);
|
await member.roles.remove(guildConfig.visitorRole);
|
||||||
|
}
|
||||||
|
if (guildConfig.studentRole) {
|
||||||
|
await member.roles.add(guildConfig.studentRole);
|
||||||
|
}
|
||||||
|
|
||||||
if (user.class.roleId) {
|
if (user.class.roleId) {
|
||||||
await member.roles.add(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}`);
|
console.log(`Restored student role to ${member.user.tag}`);
|
||||||
} else {
|
} else {
|
||||||
await member.roles.add(config.visitorRole);
|
if (guildConfig.visitorRole) {
|
||||||
console.log(`Assigned visitor role to ${member.user.tag}`);
|
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(", ")}`);
|
console.log(`User Roles: ${member.roles.cache.map(role => role.name).join(", ")}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ const event: Event<Events.ClientReady> = {
|
|||||||
console.log(`Ready! Logged in as ${c.user.tag}`);
|
console.log(`Ready! Logged in as ${c.user.tag}`);
|
||||||
schedulerService.start();
|
schedulerService.start();
|
||||||
|
|
||||||
// Handle post-update tasks
|
|
||||||
const { UpdateService } = await import("@shared/modules/admin/update.service");
|
|
||||||
await UpdateService.handlePostRestart(c);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { env } from "@shared/lib/env";
|
import { env } from "@shared/lib/env";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { initializeConfig } from "@shared/lib/config";
|
||||||
|
|
||||||
import { startWebServerFromRoot } from "../web/src/server";
|
import { startWebServerFromRoot } from "../api/src/server";
|
||||||
|
|
||||||
|
// Initialize config from database
|
||||||
|
await initializeConfig();
|
||||||
|
|
||||||
// Load commands & events
|
// Load commands & events
|
||||||
await AuroraClient.loadCommands();
|
await AuroraClient.loadCommands();
|
||||||
@@ -14,7 +18,7 @@ console.log("🌐 Starting web server...");
|
|||||||
|
|
||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
|
|
||||||
const webProjectPath = join(import.meta.dir, "../web");
|
const webProjectPath = join(import.meta.dir, "../api");
|
||||||
const webPort = Number(process.env.WEB_PORT) || 3000;
|
const webPort = Number(process.env.WEB_PORT) || 3000;
|
||||||
const webHost = process.env.HOST || "0.0.0.0";
|
const webHost = process.env.HOST || "0.0.0.0";
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ mock.module("discord.js", () => ({
|
|||||||
Routes: {
|
Routes: {
|
||||||
applicationGuildCommands: () => 'guild_route',
|
applicationGuildCommands: () => 'guild_route',
|
||||||
applicationCommands: () => 'global_route'
|
applicationCommands: () => 'global_route'
|
||||||
}
|
},
|
||||||
|
MessageFlags: {}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock loaders to avoid filesystem access during client init
|
// Mock loaders to avoid filesystem access during client init
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js";
|
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes, MessageFlags } from "discord.js";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { Command } from "@shared/lib/types";
|
import type { Command } from "@shared/lib/types";
|
||||||
import { env } from "@shared/lib/env";
|
import { env } from "@shared/lib/env";
|
||||||
@@ -8,6 +8,7 @@ import { EventLoader } from "@lib/loaders/EventLoader";
|
|||||||
export class Client extends DiscordClient {
|
export class Client extends DiscordClient {
|
||||||
|
|
||||||
commands: Collection<string, Command>;
|
commands: Collection<string, Command>;
|
||||||
|
knownCommands: Map<string, string>;
|
||||||
lastCommandTimestamp: number | null = null;
|
lastCommandTimestamp: number | null = null;
|
||||||
maintenanceMode: boolean = false;
|
maintenanceMode: boolean = false;
|
||||||
private commandLoader: CommandLoader;
|
private commandLoader: CommandLoader;
|
||||||
@@ -16,6 +17,7 @@ export class Client extends DiscordClient {
|
|||||||
constructor({ intents }: { intents: number[] }) {
|
constructor({ intents }: { intents: number[] }) {
|
||||||
super({ intents });
|
super({ intents });
|
||||||
this.commands = new Collection<string, Command>();
|
this.commands = new Collection<string, Command>();
|
||||||
|
this.knownCommands = new Map<string, string>();
|
||||||
this.commandLoader = new CommandLoader(this);
|
this.commandLoader = new CommandLoader(this);
|
||||||
this.eventLoader = new EventLoader(this);
|
this.eventLoader = new EventLoader(this);
|
||||||
}
|
}
|
||||||
@@ -72,11 +74,33 @@ export class Client extends DiscordClient {
|
|||||||
console.log(`🛠️ System Action: Maintenance mode ${enabled ? "ON" : "OFF"}${reason ? ` (${reason})` : ""}`);
|
console.log(`🛠️ System Action: Maintenance mode ${enabled ? "ON" : "OFF"}${reason ? ` (${reason})` : ""}`);
|
||||||
this.maintenanceMode = enabled;
|
this.maintenanceMode = enabled;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
systemEvents.on(EVENTS.QUEST.COMPLETED, async (data: { userId: string, quest: any, rewards: any }) => {
|
||||||
|
const { userId, quest, rewards } = data;
|
||||||
|
try {
|
||||||
|
const user = await this.users.fetch(userId);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const { getQuestCompletionComponents } = await import("@/modules/quest/quest.view");
|
||||||
|
const components = getQuestCompletionComponents(quest, rewards);
|
||||||
|
|
||||||
|
// Try to send to the user's DM
|
||||||
|
await user.send({
|
||||||
|
components: components as any,
|
||||||
|
flags: [MessageFlags.IsComponentsV2]
|
||||||
|
}).catch(async () => {
|
||||||
|
console.warn(`Could not DM user ${userId} quest completion message. User might have DMs disabled.`);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send quest completion notification:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadCommands(reload: boolean = false) {
|
async loadCommands(reload: boolean = false) {
|
||||||
if (reload) {
|
if (reload) {
|
||||||
this.commands.clear();
|
this.commands.clear();
|
||||||
|
this.knownCommands.clear();
|
||||||
console.log("♻️ Reloading commands...");
|
console.log("♻️ Reloading commands...");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,4 +197,4 @@ export class Client extends DiscordClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers] });
|
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers, GatewayIntentBits.DirectMessages] });
|
||||||
@@ -20,6 +20,9 @@ mock.module("./BotClient", () => ({
|
|||||||
commands: {
|
commands: {
|
||||||
size: 20,
|
size: 20,
|
||||||
},
|
},
|
||||||
|
knownCommands: {
|
||||||
|
size: 20,
|
||||||
|
},
|
||||||
lastCommandTimestamp: 1641481200000,
|
lastCommandTimestamp: 1641481200000,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -23,11 +23,13 @@ export function getClientStats(): ClientStats {
|
|||||||
bot: {
|
bot: {
|
||||||
name: AuroraClient.user?.username || "Aurora",
|
name: AuroraClient.user?.username || "Aurora",
|
||||||
avatarUrl: AuroraClient.user?.displayAvatarURL() || null,
|
avatarUrl: AuroraClient.user?.displayAvatarURL() || null,
|
||||||
|
status: AuroraClient.user?.presence.activities[0]?.state || AuroraClient.user?.presence.activities[0]?.name || null,
|
||||||
},
|
},
|
||||||
guilds: AuroraClient.guilds.cache.size,
|
guilds: AuroraClient.guilds.cache.size,
|
||||||
ping: AuroraClient.ws.ping,
|
ping: AuroraClient.ws.ping,
|
||||||
cachedUsers: AuroraClient.users.cache.size,
|
cachedUsers: AuroraClient.users.cache.size,
|
||||||
commandsRegistered: AuroraClient.commands.size,
|
commandsRegistered: AuroraClient.commands.size,
|
||||||
|
commandsKnown: AuroraClient.knownCommands.size,
|
||||||
uptime: process.uptime(),
|
uptime: process.uptime(),
|
||||||
lastCommandTimestamp: AuroraClient.lastCommandTimestamp,
|
lastCommandTimestamp: AuroraClient.lastCommandTimestamp,
|
||||||
};
|
};
|
||||||
|
|||||||
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";
|
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||||
|
|
||||||
// Mock DrizzleClient
|
// Mock DrizzleClient — must match the import path used in db.ts
|
||||||
mock.module("./DrizzleClient", () => ({
|
mock.module("@shared/db/DrizzleClient", () => ({
|
||||||
DrizzleClient: {
|
DrizzleClient: {
|
||||||
transaction: async (cb: any) => cb("MOCK_TX")
|
transaction: async (cb: any) => cb("MOCK_TX")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js";
|
import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js";
|
||||||
|
import { BRANDING } from "@shared/lib/constants";
|
||||||
|
import pkg from "../../package.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies standard branding to an embed.
|
||||||
|
*/
|
||||||
|
function applyBranding(embed: EmbedBuilder): EmbedBuilder {
|
||||||
|
return embed.setFooter({
|
||||||
|
text: `${BRANDING.FOOTER_TEXT} v${pkg.version}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a standardized error embed.
|
* Creates a standardized error embed.
|
||||||
@@ -7,11 +18,13 @@ import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js";
|
|||||||
* @returns An EmbedBuilder instance configured as an error.
|
* @returns An EmbedBuilder instance configured as an error.
|
||||||
*/
|
*/
|
||||||
export function createErrorEmbed(message: string, title: string = "Error"): EmbedBuilder {
|
export function createErrorEmbed(message: string, title: string = "Error"): EmbedBuilder {
|
||||||
return new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(`❌ ${title}`)
|
.setTitle(`❌ ${title}`)
|
||||||
.setDescription(message)
|
.setDescription(message)
|
||||||
.setColor(Colors.Red)
|
.setColor(Colors.Red)
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
|
||||||
|
return applyBranding(embed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,11 +34,13 @@ export function createErrorEmbed(message: string, title: string = "Error"): Embe
|
|||||||
* @returns An EmbedBuilder instance configured as a warning.
|
* @returns An EmbedBuilder instance configured as a warning.
|
||||||
*/
|
*/
|
||||||
export function createWarningEmbed(message: string, title: string = "Warning"): EmbedBuilder {
|
export function createWarningEmbed(message: string, title: string = "Warning"): EmbedBuilder {
|
||||||
return new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(`⚠️ ${title}`)
|
.setTitle(`⚠️ ${title}`)
|
||||||
.setDescription(message)
|
.setDescription(message)
|
||||||
.setColor(Colors.Yellow)
|
.setColor(Colors.Yellow)
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
|
||||||
|
return applyBranding(embed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,11 +50,13 @@ export function createWarningEmbed(message: string, title: string = "Warning"):
|
|||||||
* @returns An EmbedBuilder instance configured as a success.
|
* @returns An EmbedBuilder instance configured as a success.
|
||||||
*/
|
*/
|
||||||
export function createSuccessEmbed(message: string, title: string = "Success"): EmbedBuilder {
|
export function createSuccessEmbed(message: string, title: string = "Success"): EmbedBuilder {
|
||||||
return new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(`✅ ${title}`)
|
.setTitle(`✅ ${title}`)
|
||||||
.setDescription(message)
|
.setDescription(message)
|
||||||
.setColor(Colors.Green)
|
.setColor(Colors.Green)
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
|
||||||
|
return applyBranding(embed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,11 +66,13 @@ export function createSuccessEmbed(message: string, title: string = "Success"):
|
|||||||
* @returns An EmbedBuilder instance configured as info.
|
* @returns An EmbedBuilder instance configured as info.
|
||||||
*/
|
*/
|
||||||
export function createInfoEmbed(message: string, title: string = "Info"): EmbedBuilder {
|
export function createInfoEmbed(message: string, title: string = "Info"): EmbedBuilder {
|
||||||
return new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(`ℹ️ ${title}`)
|
.setTitle(`ℹ️ ${title}`)
|
||||||
.setDescription(message)
|
.setDescription(message)
|
||||||
.setColor(Colors.Blue)
|
.setColor(Colors.Blue)
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
|
||||||
|
return applyBranding(embed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,11 +84,12 @@ export function createInfoEmbed(message: string, title: string = "Info"): EmbedB
|
|||||||
*/
|
*/
|
||||||
export function createBaseEmbed(title?: string, description?: string, color?: ColorResolvable): EmbedBuilder {
|
export function createBaseEmbed(title?: string, description?: string, color?: ColorResolvable): EmbedBuilder {
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTimestamp();
|
.setTimestamp()
|
||||||
|
.setColor(color ?? BRANDING.COLOR);
|
||||||
|
|
||||||
if (title) embed.setTitle(title);
|
if (title) embed.setTitle(title);
|
||||||
if (description) embed.setDescription(description);
|
if (description) embed.setDescription(description);
|
||||||
if (color) embed.setColor(color);
|
|
||||||
|
|
||||||
return embed;
|
return applyBranding(embed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
export class ApplicationError extends Error {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(message);
|
|
||||||
this.name = this.constructor.name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UserError extends ApplicationError {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SystemError extends ApplicationError {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AutocompleteInteraction } from "discord.js";
|
import { AutocompleteInteraction } from "discord.js";
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
|
import { logger } from "@shared/lib/logger";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,7 +17,7 @@ export class AutocompleteHandler {
|
|||||||
try {
|
try {
|
||||||
await command.autocomplete(interaction);
|
await command.autocomplete(interaction);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error handling autocomplete for ${interaction.commandName}:`, error);
|
logger.error("bot", `Error handling autocomplete for ${interaction.commandName}`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
|
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
|
import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service";
|
||||||
import { createErrorEmbed } from "@lib/embeds";
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
|
import { logger } from "@shared/lib/logger";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,7 +15,7 @@ export class CommandHandler {
|
|||||||
const command = AuroraClient.commands.get(interaction.commandName);
|
const command = AuroraClient.commands.get(interaction.commandName);
|
||||||
|
|
||||||
if (!command) {
|
if (!command) {
|
||||||
console.error(`No command matching ${interaction.commandName} was found.`);
|
logger.error("bot", `No command matching ${interaction.commandName} was found.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,18 +26,49 @@ export class CommandHandler {
|
|||||||
return;
|
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
|
// Ensure user exists in database
|
||||||
try {
|
try {
|
||||||
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to ensure user exists:", error);
|
logger.error("bot", "Failed to ensure user exists", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await command.execute(interaction);
|
await command.execute(interaction);
|
||||||
AuroraClient.lastCommandTimestamp = Date.now();
|
AuroraClient.lastCommandTimestamp = Date.now();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(String(error));
|
logger.error("bot", `Error executing command ${interaction.commandName}`, error);
|
||||||
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
|
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
|
||||||
|
|
||||||
if (interaction.replied || interaction.deferred) {
|
if (interaction.replied || interaction.deferred) {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js";
|
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js";
|
||||||
|
|
||||||
import { UserError } from "@lib/errors";
|
import { UserError } from "@shared/lib/errors";
|
||||||
import { createErrorEmbed } from "@lib/embeds";
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
|
import { logger } from "@shared/lib/logger";
|
||||||
|
|
||||||
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ export class ComponentInteractionHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error(`Handler method ${route.method} not found in module`);
|
logger.error("bot", `Handler method ${route.method} not found in module`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,7 +53,7 @@ export class ComponentInteractionHandler {
|
|||||||
|
|
||||||
// Log system errors (non-user errors) for debugging
|
// Log system errors (non-user errors) for debugging
|
||||||
if (!isUserError) {
|
if (!isUserError) {
|
||||||
console.error(`Error in ${handlerName}:`, error);
|
logger.error("bot", `Error in ${handlerName}`, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorEmbed = createErrorEmbed(errorMessage);
|
const errorEmbed = createErrorEmbed(errorMessage);
|
||||||
@@ -72,7 +73,7 @@ export class ComponentInteractionHandler {
|
|||||||
}
|
}
|
||||||
} catch (replyError) {
|
} catch (replyError) {
|
||||||
// If we can't send a reply, log it
|
// If we can't send a reply, log it
|
||||||
console.error(`Failed to send error response in ${handlerName}:`, replyError);
|
logger.error("bot", `Failed to send error response in ${handlerName}`, replyError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ export const interactionRoutes: InteractionRoute[] = [
|
|||||||
handler: () => import("@/modules/economy/lootdrop.interaction"),
|
handler: () => import("@/modules/economy/lootdrop.interaction"),
|
||||||
method: 'handleLootdropInteraction'
|
method: 'handleLootdropInteraction'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
predicate: (i) => i.isButton() && i.customId.startsWith("trivia_"),
|
||||||
|
handler: () => import("@/modules/trivia/trivia.interaction"),
|
||||||
|
method: 'handleTriviaInteraction'
|
||||||
|
},
|
||||||
|
|
||||||
// --- ADMIN MODULE ---
|
// --- ADMIN MODULE ---
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { Command } from "@shared/lib/types";
|
|||||||
import { config } from "@shared/lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
import type { LoadResult, LoadError } from "./types";
|
import type { LoadResult, LoadError } from "./types";
|
||||||
import type { Client } from "../BotClient";
|
import type { Client } from "../BotClient";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles loading commands from the file system
|
* Handles loading commands from the file system
|
||||||
@@ -71,6 +71,9 @@ export class CommandLoader {
|
|||||||
if (this.isValidCommand(command)) {
|
if (this.isValidCommand(command)) {
|
||||||
command.category = category;
|
command.category = category;
|
||||||
|
|
||||||
|
// Track all known commands regardless of enabled status
|
||||||
|
this.client.knownCommands.set(command.data.name, category);
|
||||||
|
|
||||||
const isEnabled = config.commands[command.data.name] !== false;
|
const isEnabled = config.commands[command.data.name] !== false;
|
||||||
|
|
||||||
if (!isEnabled) {
|
if (!isEnabled) {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const renderWizard = (userId: string, isDraft = true) => {
|
|||||||
draft = {
|
draft = {
|
||||||
name: "New Item",
|
name: "New Item",
|
||||||
description: "No description",
|
description: "No description",
|
||||||
rarity: "Common",
|
rarity: "C",
|
||||||
type: ItemType.MATERIAL,
|
type: ItemType.MATERIAL,
|
||||||
price: null,
|
price: null,
|
||||||
iconUrl: "",
|
iconUrl: "",
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const getDetailsModal = (current: DraftItem) => {
|
|||||||
modal.addComponents(
|
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("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("desc").setLabel("Description").setValue(current.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
|
||||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("Common, Rare, Legendary...").setRequired(true))
|
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("C, R, SR, SSR").setRequired(true))
|
||||||
);
|
);
|
||||||
return modal;
|
return modal;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
|
|
||||||
export interface RestartContext {
|
|
||||||
channelId: string;
|
|
||||||
userId: string;
|
|
||||||
timestamp: number;
|
|
||||||
runMigrations: boolean;
|
|
||||||
installDependencies: boolean;
|
|
||||||
previousCommit: string;
|
|
||||||
newCommit: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateCheckResult {
|
|
||||||
needsRootInstall: boolean;
|
|
||||||
needsWebInstall: boolean;
|
|
||||||
needsMigrations: boolean;
|
|
||||||
changedFiles: string[];
|
|
||||||
error?: Error;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateInfo {
|
|
||||||
hasUpdates: boolean;
|
|
||||||
branch: string;
|
|
||||||
currentCommit: string;
|
|
||||||
latestCommit: string;
|
|
||||||
commitCount: number;
|
|
||||||
commits: CommitInfo[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommitInfo {
|
|
||||||
hash: string;
|
|
||||||
message: string;
|
|
||||||
author: string;
|
|
||||||
}
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js";
|
|
||||||
import { createInfoEmbed, createSuccessEmbed, createWarningEmbed, createErrorEmbed } from "@lib/embeds";
|
|
||||||
import type { UpdateInfo, UpdateCheckResult } from "./update.types";
|
|
||||||
|
|
||||||
// Constants for UI
|
|
||||||
const LOG_TRUNCATE_LENGTH = 800;
|
|
||||||
const OUTPUT_TRUNCATE_LENGTH = 400;
|
|
||||||
|
|
||||||
function truncate(text: string, maxLength: number): string {
|
|
||||||
if (!text) return "";
|
|
||||||
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Pre-Update Embeds ============
|
|
||||||
|
|
||||||
export function getCheckingEmbed() {
|
|
||||||
return createInfoEmbed("🔍 Fetching latest changes from remote...", "Checking for Updates");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getNoUpdatesEmbed(currentCommit: string) {
|
|
||||||
return createSuccessEmbed(
|
|
||||||
`You're running the latest version.\n\n**Current:** \`${currentCommit}\``,
|
|
||||||
"✅ Already Up to Date"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getUpdatesAvailableMessage(
|
|
||||||
updateInfo: UpdateInfo,
|
|
||||||
requirements: UpdateCheckResult,
|
|
||||||
changeCategories: Record<string, number>,
|
|
||||||
force: boolean
|
|
||||||
) {
|
|
||||||
const { branch, currentCommit, latestCommit, commitCount, commits } = updateInfo;
|
|
||||||
const { needsRootInstall, needsWebInstall, needsMigrations } = requirements;
|
|
||||||
|
|
||||||
// Build commit list (max 5)
|
|
||||||
const commitList = commits
|
|
||||||
.slice(0, 5)
|
|
||||||
.map(c => `\`${c.hash}\` ${truncate(c.message, 50)}`)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
const moreCommits = commitCount > 5 ? `\n*...and ${commitCount - 5} more*` : "";
|
|
||||||
|
|
||||||
// Build change categories
|
|
||||||
const categoryList = Object.entries(changeCategories)
|
|
||||||
.map(([cat, count]) => `• ${cat}: ${count} file${count > 1 ? "s" : ""}`)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
// Build requirements list
|
|
||||||
const reqs: string[] = [];
|
|
||||||
if (needsRootInstall) reqs.push("📦 Install root dependencies");
|
|
||||||
if (needsWebInstall) reqs.push("🌐 Install web dependencies");
|
|
||||||
if (needsMigrations) reqs.push("🗃️ Run database migrations");
|
|
||||||
if (reqs.length === 0) reqs.push("⚡ Quick update (no extra steps)");
|
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setTitle("📥 Updates Available")
|
|
||||||
.setColor(force ? 0xFF6B6B : 0x5865F2)
|
|
||||||
.addFields(
|
|
||||||
{
|
|
||||||
name: "Version",
|
|
||||||
value: `\`${currentCommit}\` → \`${latestCommit}\``,
|
|
||||||
inline: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Branch",
|
|
||||||
value: `\`${branch}\``,
|
|
||||||
inline: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Commits",
|
|
||||||
value: `${commitCount} new commit${commitCount > 1 ? "s" : ""}`,
|
|
||||||
inline: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Recent Changes",
|
|
||||||
value: commitList + moreCommits || "No commits",
|
|
||||||
inline: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Files Changed",
|
|
||||||
value: categoryList || "Unknown",
|
|
||||||
inline: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Update Actions",
|
|
||||||
value: reqs.join("\n"),
|
|
||||||
inline: true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.setFooter({ text: force ? "⚠️ Force mode enabled" : "This will restart the bot" })
|
|
||||||
.setTimestamp();
|
|
||||||
|
|
||||||
const confirmButton = new ButtonBuilder()
|
|
||||||
.setCustomId("confirm_update")
|
|
||||||
.setLabel(force ? "Force Update" : "Update Now")
|
|
||||||
.setEmoji(force ? "⚠️" : "🚀")
|
|
||||||
.setStyle(force ? ButtonStyle.Danger : ButtonStyle.Success);
|
|
||||||
|
|
||||||
const cancelButton = new ButtonBuilder()
|
|
||||||
.setCustomId("cancel_update")
|
|
||||||
.setLabel("Cancel")
|
|
||||||
.setStyle(ButtonStyle.Secondary);
|
|
||||||
|
|
||||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(confirmButton, cancelButton);
|
|
||||||
|
|
||||||
return { embeds: [embed], components: [row] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Update Progress Embeds ============
|
|
||||||
|
|
||||||
export function getPreparingEmbed() {
|
|
||||||
return createInfoEmbed(
|
|
||||||
"🔒 Saving rollback point...\n📥 Preparing to download updates...",
|
|
||||||
"⏳ Preparing Update"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getUpdatingEmbed(requirements: UpdateCheckResult) {
|
|
||||||
const steps: string[] = ["✅ Rollback point saved"];
|
|
||||||
|
|
||||||
steps.push("📥 Downloading updates...");
|
|
||||||
|
|
||||||
if (requirements.needsRootInstall || requirements.needsWebInstall) {
|
|
||||||
steps.push("📦 Dependencies will be installed after restart");
|
|
||||||
}
|
|
||||||
if (requirements.needsMigrations) {
|
|
||||||
steps.push("🗃️ Migrations will run after restart");
|
|
||||||
}
|
|
||||||
|
|
||||||
steps.push("\n🔄 **Restarting now...**");
|
|
||||||
|
|
||||||
return createWarningEmbed(steps.join("\n"), "🚀 Updating");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCancelledEmbed() {
|
|
||||||
return createInfoEmbed("Update cancelled. No changes were made.", "❌ Cancelled");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTimeoutEmbed() {
|
|
||||||
return createWarningEmbed(
|
|
||||||
"No response received within 30 seconds.\nRun `/update` again when ready.",
|
|
||||||
"⏰ Timed Out"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getErrorEmbed(error: unknown) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
return createErrorEmbed(
|
|
||||||
`The update could not be completed:\n\`\`\`\n${truncate(message, 500)}\n\`\`\``,
|
|
||||||
"❌ Update Failed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Post-Restart Embeds ============
|
|
||||||
|
|
||||||
export interface PostRestartResult {
|
|
||||||
installSuccess: boolean;
|
|
||||||
installOutput: string;
|
|
||||||
migrationSuccess: boolean;
|
|
||||||
migrationOutput: string;
|
|
||||||
ranInstall: boolean;
|
|
||||||
ranMigrations: boolean;
|
|
||||||
previousCommit?: string;
|
|
||||||
newCommit?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPostRestartEmbed(result: PostRestartResult, hasRollback: boolean) {
|
|
||||||
const isSuccess = result.installSuccess && result.migrationSuccess;
|
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setTitle(isSuccess ? "✅ Update Complete" : "⚠️ Update Completed with Issues")
|
|
||||||
.setColor(isSuccess ? 0x57F287 : 0xFEE75C)
|
|
||||||
.setTimestamp();
|
|
||||||
|
|
||||||
// Version info
|
|
||||||
if (result.previousCommit && result.newCommit) {
|
|
||||||
embed.addFields({
|
|
||||||
name: "Version",
|
|
||||||
value: `\`${result.previousCommit}\` → \`${result.newCommit}\``,
|
|
||||||
inline: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Results summary
|
|
||||||
const results: string[] = [];
|
|
||||||
|
|
||||||
if (result.ranInstall) {
|
|
||||||
results.push(result.installSuccess
|
|
||||||
? "✅ Dependencies installed"
|
|
||||||
: "❌ Dependency installation failed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.ranMigrations) {
|
|
||||||
results.push(result.migrationSuccess
|
|
||||||
? "✅ Migrations applied"
|
|
||||||
: "❌ Migration failed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (results.length > 0) {
|
|
||||||
embed.addFields({
|
|
||||||
name: "Actions Performed",
|
|
||||||
value: results.join("\n"),
|
|
||||||
inline: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output details (collapsed if too long)
|
|
||||||
if (result.installOutput && !result.installSuccess) {
|
|
||||||
embed.addFields({
|
|
||||||
name: "Install Output",
|
|
||||||
value: `\`\`\`\n${truncate(result.installOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
|
|
||||||
inline: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.migrationOutput && !result.migrationSuccess) {
|
|
||||||
embed.addFields({
|
|
||||||
name: "Migration Output",
|
|
||||||
value: `\`\`\`\n${truncate(result.migrationOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
|
|
||||||
inline: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Footer with rollback hint
|
|
||||||
if (!isSuccess && hasRollback) {
|
|
||||||
embed.setFooter({ text: "💡 Use /update rollback to revert if needed" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build components
|
|
||||||
const components: ActionRowBuilder<ButtonBuilder>[] = [];
|
|
||||||
|
|
||||||
if (!isSuccess && hasRollback) {
|
|
||||||
const rollbackButton = new ButtonBuilder()
|
|
||||||
.setCustomId("rollback_update")
|
|
||||||
.setLabel("Rollback")
|
|
||||||
.setEmoji("↩️")
|
|
||||||
.setStyle(ButtonStyle.Danger);
|
|
||||||
|
|
||||||
components.push(new ActionRowBuilder<ButtonBuilder>().addComponents(rollbackButton));
|
|
||||||
}
|
|
||||||
|
|
||||||
return { embeds: [embed], components };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getInstallingDependenciesEmbed() {
|
|
||||||
return createInfoEmbed(
|
|
||||||
"📦 Installing dependencies for root and web projects...\nThis may take a moment.",
|
|
||||||
"⏳ Installing Dependencies"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRunningMigrationsEmbed() {
|
|
||||||
return createInfoEmbed(
|
|
||||||
"🗃️ Applying database migrations...",
|
|
||||||
"⏳ Running Migrations"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRollbackSuccessEmbed(commit: string) {
|
|
||||||
return createSuccessEmbed(
|
|
||||||
`Successfully rolled back to commit \`${commit}\`.\nThe bot will restart now.`,
|
|
||||||
"↩️ Rollback Complete"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRollbackFailedEmbed(error: string) {
|
|
||||||
return createErrorEmbed(
|
|
||||||
`Could not rollback:\n\`\`\`\n${error}\n\`\`\``,
|
|
||||||
"❌ Rollback Failed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ButtonInteraction } from "discord.js";
|
import { ButtonInteraction } from "discord.js";
|
||||||
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
|
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
|
||||||
import { UserError } from "@/lib/errors";
|
import { UserError } from "@shared/lib/errors";
|
||||||
import { getLootdropClaimedMessage } from "./lootdrop.view";
|
import { getLootdropClaimedMessage } from "./lootdrop.view";
|
||||||
|
|
||||||
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
|
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ButtonInteraction, MessageFlags } from "discord.js";
|
import { ButtonInteraction, MessageFlags } from "discord.js";
|
||||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { UserError } from "@/lib/errors";
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
|
||||||
export async function handleShopInteraction(interaction: ButtonInteraction) {
|
export async function handleShopInteraction(interaction: ButtonInteraction) {
|
||||||
if (!interaction.customId.startsWith("shop_buy_")) return;
|
if (!interaction.customId.startsWith("shop_buy_")) return;
|
||||||
|
|||||||
@@ -1,20 +1,208 @@
|
|||||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
|
import {
|
||||||
import { createBaseEmbed } from "@/lib/embeds";
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
AttachmentBuilder,
|
||||||
|
Colors,
|
||||||
|
ContainerBuilder,
|
||||||
|
SectionBuilder,
|
||||||
|
TextDisplayBuilder,
|
||||||
|
MediaGalleryBuilder,
|
||||||
|
MediaGalleryItemBuilder,
|
||||||
|
ThumbnailBuilder,
|
||||||
|
SeparatorBuilder,
|
||||||
|
SeparatorSpacingSize,
|
||||||
|
MessageFlags
|
||||||
|
} from "discord.js";
|
||||||
|
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
|
||||||
|
import { join } from "path";
|
||||||
|
import { existsSync } from "fs";
|
||||||
|
import { LootType, EffectType } from "@shared/lib/constants";
|
||||||
|
import type { LootTableItem } from "@shared/lib/types";
|
||||||
|
|
||||||
export function getShopListingMessage(item: { id: number; name: string; description: string | null; formattedPrice: string; iconUrl: string | null; imageUrl: string | null; price: number | bigint }) {
|
// Rarity Color Map
|
||||||
const embed = createBaseEmbed(`Shop: ${item.name}`, item.description || "No description available.", "Green")
|
const RarityColors: Record<string, number> = {
|
||||||
.addFields({ name: "Price", value: item.formattedPrice, inline: true })
|
"C": Colors.LightGrey,
|
||||||
.setThumbnail(item.iconUrl || null)
|
"R": Colors.Blue,
|
||||||
.setImage(item.imageUrl || null)
|
"SR": Colors.Purple,
|
||||||
.setFooter({ text: "Click the button below to purchase instantly." });
|
"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"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getShopListingMessage(
|
||||||
|
item: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
formattedPrice: string;
|
||||||
|
iconUrl: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
price: number | bigint;
|
||||||
|
usageData?: any;
|
||||||
|
rarity?: string;
|
||||||
|
},
|
||||||
|
context?: { referencedItems: Map<number, { name: string; rarity: string }> }
|
||||||
|
) {
|
||||||
|
const files: AttachmentBuilder[] = [];
|
||||||
|
let thumbnailUrl = resolveAssetUrl(item.iconUrl);
|
||||||
|
let displayImageUrl = resolveAssetUrl(item.imageUrl);
|
||||||
|
|
||||||
|
// Handle local icon
|
||||||
|
if (item.iconUrl && isLocalAssetUrl(item.iconUrl)) {
|
||||||
|
const iconPath = join(process.cwd(), "bot/assets/graphics", item.iconUrl.replace(/^\/?assets\//, ""));
|
||||||
|
if (existsSync(iconPath)) {
|
||||||
|
const iconName = defaultName(item.iconUrl);
|
||||||
|
files.push(new AttachmentBuilder(iconPath, { name: iconName }));
|
||||||
|
thumbnailUrl = `attachment://${iconName}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle local image
|
||||||
|
if (item.imageUrl && isLocalAssetUrl(item.imageUrl)) {
|
||||||
|
if (item.imageUrl === item.iconUrl && thumbnailUrl?.startsWith("attachment://")) {
|
||||||
|
displayImageUrl = thumbnailUrl;
|
||||||
|
} else {
|
||||||
|
const imagePath = join(process.cwd(), "bot/assets/graphics", item.imageUrl.replace(/^\/?assets\//, ""));
|
||||||
|
if (existsSync(imagePath)) {
|
||||||
|
const imageName = defaultName(item.imageUrl);
|
||||||
|
if (!files.find(f => f.name === imageName)) {
|
||||||
|
files.push(new AttachmentBuilder(imagePath, { name: imageName }));
|
||||||
|
}
|
||||||
|
displayImageUrl = `attachment://${imageName}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const containers: ContainerBuilder[] = [];
|
||||||
|
|
||||||
|
// 1. Main Container
|
||||||
|
const mainContainer = new ContainerBuilder()
|
||||||
|
.setAccentColor(RarityColors[item.rarity || "C"] || Colors.Green);
|
||||||
|
|
||||||
|
// Header Section
|
||||||
|
const infoSection = new SectionBuilder()
|
||||||
|
.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(`# ${item.name}`),
|
||||||
|
new TextDisplayBuilder().setContent(item.description || "_No description available._"),
|
||||||
|
new TextDisplayBuilder().setContent(`### 🏷️ Price: ${item.formattedPrice}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set Thumbnail Accessory if we have an icon
|
||||||
|
if (thumbnailUrl) {
|
||||||
|
infoSection.setThumbnailAccessory(new ThumbnailBuilder().setURL(thumbnailUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
mainContainer.addSectionComponents(infoSection);
|
||||||
|
|
||||||
|
// Media Gallery for additional images (if multiple)
|
||||||
|
const mediaSources: string[] = [];
|
||||||
|
if (thumbnailUrl) mediaSources.push(thumbnailUrl);
|
||||||
|
if (displayImageUrl && displayImageUrl !== thumbnailUrl) mediaSources.push(displayImageUrl);
|
||||||
|
|
||||||
|
if (mediaSources.length > 1) {
|
||||||
|
mainContainer.addMediaGalleryComponents(
|
||||||
|
new MediaGalleryBuilder().addItems(
|
||||||
|
...mediaSources.map(src => new MediaGalleryItemBuilder().setURL(src))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
const buyButton = new ButtonBuilder()
|
const buyButton = new ButtonBuilder()
|
||||||
.setCustomId(`shop_buy_${item.id}`)
|
.setCustomId(`shop_buy_${item.id}`)
|
||||||
.setLabel(`Buy for ${item.price} 🪙`)
|
.setLabel(`Purchase for ${item.price} 🪙`)
|
||||||
.setStyle(ButtonStyle.Success)
|
.setStyle(ButtonStyle.Success)
|
||||||
.setEmoji("🛒");
|
.setEmoji("🛒");
|
||||||
|
|
||||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton);
|
mainContainer.addActionRowComponents(
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton)
|
||||||
|
);
|
||||||
|
|
||||||
return { embeds: [embed], components: [row] };
|
containers.push(mainContainer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
components: containers as any,
|
||||||
|
files,
|
||||||
|
flags: MessageFlags.IsComponentsV2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultName(path: string): string {
|
||||||
|
return path.split("/").pop() || "image.png";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { Interaction } from "discord.js";
|
import type { Interaction } from "discord.js";
|
||||||
import { TextChannel, MessageFlags } 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 { AuroraClient } from "@/lib/BotClient";
|
||||||
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
|
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
|
||||||
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
|
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
|
||||||
import { UserError } from "@/lib/errors";
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
|
||||||
export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
||||||
// Handle select menu for choosing feedback type
|
// Handle select menu for choosing feedback type
|
||||||
@@ -33,7 +33,13 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
|||||||
throw new UserError("An error occurred processing your feedback. Please try again.");
|
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.");
|
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
|
// 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) {
|
if (!channel) {
|
||||||
throw new UserError("Feedback channel not found. Please contact an administrator.");
|
throw new UserError("Feedback channel not found. Please contact an administrator.");
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { levelingService } from "@shared/modules/leveling/leveling.service";
|
import { levelingService } from "@shared/modules/leveling/leveling.service";
|
||||||
import { economyService } from "@shared/modules/economy/economy.service";
|
import { economyService } from "@shared/modules/economy/economy.service";
|
||||||
import { userTimers } from "@db/schema";
|
import { userTimers } from "@db/schema";
|
||||||
import type { EffectHandler } from "./types";
|
import type { EffectHandler, ValidatedEffectPayload } from "./effect.types";
|
||||||
|
import { EffectType } from "@shared/lib/constants";
|
||||||
import type { LootTableItem } from "@shared/lib/types";
|
import type { LootTableItem } from "@shared/lib/types";
|
||||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { inventory, items } from "@db/schema";
|
import { inventory, items } from "@db/schema";
|
||||||
@@ -15,21 +16,21 @@ const getDuration = (effect: any): number => {
|
|||||||
return effect.durationSeconds || 60; // Default to 60s if nothing provided
|
return effect.durationSeconds || 60; // Default to 60s if nothing provided
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleAddXp: EffectHandler = async (userId, effect, txFn) => {
|
export const handleAddXp: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.ADD_XP }>, txFn) => {
|
||||||
await levelingService.addXp(userId, BigInt(effect.amount), txFn);
|
await levelingService.addXp(userId, BigInt(effect.amount), txFn);
|
||||||
return `Gained ${effect.amount} XP`;
|
return `Gained ${effect.amount} XP`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleAddBalance: EffectHandler = async (userId, effect, txFn) => {
|
export const handleAddBalance: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.ADD_BALANCE }>, txFn) => {
|
||||||
await economyService.modifyUserBalance(userId, BigInt(effect.amount), TransactionType.ITEM_USE, `Used Item`, null, txFn);
|
await economyService.modifyUserBalance(userId, BigInt(effect.amount), TransactionType.ITEM_USE, `Used Item`, null, txFn);
|
||||||
return `Gained ${effect.amount} 🪙`;
|
return `Gained ${effect.amount} 🪙`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleReplyMessage: EffectHandler = async (_userId, effect, _txFn) => {
|
export const handleReplyMessage: EffectHandler = async (_userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.REPLY_MESSAGE }>, _txFn) => {
|
||||||
return effect.message;
|
return effect.message;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
|
export const handleXpBoost: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.XP_BOOST }>, txFn) => {
|
||||||
const boostDuration = getDuration(effect);
|
const boostDuration = getDuration(effect);
|
||||||
const expiresAt = new Date(Date.now() + boostDuration * 1000);
|
const expiresAt = new Date(Date.now() + boostDuration * 1000);
|
||||||
await txFn.insert(userTimers).values({
|
await txFn.insert(userTimers).values({
|
||||||
@@ -45,7 +46,7 @@ export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
return `XP Boost (${effect.multiplier}x) active for ${Math.floor(boostDuration / 60)}m`;
|
return `XP Boost (${effect.multiplier}x) active for ${Math.floor(boostDuration / 60)}m`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
|
export const handleTempRole: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.TEMP_ROLE }>, txFn) => {
|
||||||
const roleDuration = getDuration(effect);
|
const roleDuration = getDuration(effect);
|
||||||
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
|
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
|
||||||
await txFn.insert(userTimers).values({
|
await txFn.insert(userTimers).values({
|
||||||
@@ -62,11 +63,11 @@ export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
return `Temporary Role granted for ${Math.floor(roleDuration / 60)}m`;
|
return `Temporary Role granted for ${Math.floor(roleDuration / 60)}m`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleColorRole: EffectHandler = async (_userId, _effect, _txFn) => {
|
export const handleColorRole: EffectHandler = async (_userId, _effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.COLOR_ROLE }>, _txFn) => {
|
||||||
return "Color Role Equipped";
|
return "Color Role Equipped";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
export const handleLootbox: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.LOOTBOX }>, txFn) => {
|
||||||
const pool = effect.pool as LootTableItem[];
|
const pool = effect.pool as LootTableItem[];
|
||||||
if (!pool || pool.length === 0) return "The box is empty...";
|
if (!pool || pool.length === 0) return "The box is empty...";
|
||||||
|
|
||||||
@@ -86,7 +87,11 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
|
|
||||||
// Process Winner
|
// Process Winner
|
||||||
if (winner.type === LootType.NOTHING) {
|
if (winner.type === LootType.NOTHING) {
|
||||||
return winner.message || "You found nothing inside.";
|
return {
|
||||||
|
type: 'LOOTBOX_RESULT',
|
||||||
|
rewardType: 'NOTHING',
|
||||||
|
message: winner.message || "You found nothing inside."
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (winner.type === LootType.CURRENCY) {
|
if (winner.type === LootType.CURRENCY) {
|
||||||
@@ -96,7 +101,12 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
}
|
}
|
||||||
if (amount > 0) {
|
if (amount > 0) {
|
||||||
await economyService.modifyUserBalance(userId, BigInt(amount), TransactionType.LOOTBOX, 'Lootbox Reward', null, txFn);
|
await economyService.modifyUserBalance(userId, BigInt(amount), TransactionType.LOOTBOX, 'Lootbox Reward', null, txFn);
|
||||||
return winner.message || `You found ${amount} 🪙!`;
|
return {
|
||||||
|
type: 'LOOTBOX_RESULT',
|
||||||
|
rewardType: 'CURRENCY',
|
||||||
|
amount: amount,
|
||||||
|
message: winner.message || `You found ${amount} 🪙!`
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +117,12 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
}
|
}
|
||||||
if (amount > 0) {
|
if (amount > 0) {
|
||||||
await levelingService.addXp(userId, BigInt(amount), txFn);
|
await levelingService.addXp(userId, BigInt(amount), txFn);
|
||||||
return winner.message || `You gained ${amount} XP!`;
|
return {
|
||||||
|
type: 'LOOTBOX_RESULT',
|
||||||
|
rewardType: 'XP',
|
||||||
|
amount: amount,
|
||||||
|
message: winner.message || `You gained ${amount} XP!`
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +138,18 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
where: (items: any, { eq }: any) => eq(items.id, winner.itemId!)
|
where: (items: any, { eq }: any) => eq(items.id, winner.itemId!)
|
||||||
});
|
});
|
||||||
if (item) {
|
if (item) {
|
||||||
return winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`;
|
return {
|
||||||
|
type: 'LOOTBOX_RESULT',
|
||||||
|
rewardType: 'ITEM',
|
||||||
|
amount: Number(quantity),
|
||||||
|
item: {
|
||||||
|
name: item.name,
|
||||||
|
rarity: item.rarity,
|
||||||
|
description: item.description,
|
||||||
|
image: item.imageUrl || item.iconUrl
|
||||||
|
},
|
||||||
|
message: winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch item name for lootbox message", e);
|
console.error("Failed to fetch item name for lootbox message", e);
|
||||||
41
bot/modules/inventory/effect.registry.ts
Normal file
41
bot/modules/inventory/effect.registry.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
handleAddXp,
|
||||||
|
handleAddBalance,
|
||||||
|
handleReplyMessage,
|
||||||
|
handleXpBoost,
|
||||||
|
handleTempRole,
|
||||||
|
handleColorRole,
|
||||||
|
handleLootbox
|
||||||
|
} from "./effect.handlers";
|
||||||
|
import type { EffectHandler, ValidatedEffectPayload } from "./effect.types";
|
||||||
|
import { EffectPayloadSchema } from "./effect.types";
|
||||||
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
import type { Transaction } from "@shared/lib/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
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function validateAndExecuteEffect(
|
||||||
|
effect: unknown,
|
||||||
|
userId: string,
|
||||||
|
tx: Transaction
|
||||||
|
) {
|
||||||
|
const result = EffectPayloadSchema.safeParse(effect);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new UserError(`Invalid effect configuration: ${result.error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = effectHandlers[result.data.type];
|
||||||
|
if (!handler) {
|
||||||
|
throw new UserError(`Unknown effect type: ${result.data.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler(userId, result.data, tx);
|
||||||
|
}
|
||||||
71
bot/modules/inventory/effect.types.ts
Normal file
71
bot/modules/inventory/effect.types.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { Transaction } from "@shared/lib/types";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { EffectType, LootType } from "@shared/lib/constants";
|
||||||
|
|
||||||
|
// Helper Schemas
|
||||||
|
const LootTableItemSchema = z.object({
|
||||||
|
type: z.nativeEnum(LootType),
|
||||||
|
weight: z.number(),
|
||||||
|
amount: z.number().optional(),
|
||||||
|
itemId: z.number().optional(),
|
||||||
|
minAmount: z.number().optional(),
|
||||||
|
maxAmount: z.number().optional(),
|
||||||
|
message: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const DurationSchema = z.object({
|
||||||
|
durationSeconds: z.number().optional(),
|
||||||
|
durationMinutes: z.number().optional(),
|
||||||
|
durationHours: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Effect Schemas
|
||||||
|
const AddXpSchema = z.object({
|
||||||
|
type: z.literal(EffectType.ADD_XP),
|
||||||
|
amount: z.number().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const AddBalanceSchema = z.object({
|
||||||
|
type: z.literal(EffectType.ADD_BALANCE),
|
||||||
|
amount: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ReplyMessageSchema = z.object({
|
||||||
|
type: z.literal(EffectType.REPLY_MESSAGE),
|
||||||
|
message: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const XpBoostSchema = DurationSchema.extend({
|
||||||
|
type: z.literal(EffectType.XP_BOOST),
|
||||||
|
multiplier: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const TempRoleSchema = DurationSchema.extend({
|
||||||
|
type: z.literal(EffectType.TEMP_ROLE),
|
||||||
|
roleId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ColorRoleSchema = z.object({
|
||||||
|
type: z.literal(EffectType.COLOR_ROLE),
|
||||||
|
roleId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const LootboxSchema = z.object({
|
||||||
|
type: z.literal(EffectType.LOOTBOX),
|
||||||
|
pool: z.array(LootTableItemSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Union Schema
|
||||||
|
export const EffectPayloadSchema = z.discriminatedUnion('type', [
|
||||||
|
AddXpSchema,
|
||||||
|
AddBalanceSchema,
|
||||||
|
ReplyMessageSchema,
|
||||||
|
XpBoostSchema,
|
||||||
|
TempRoleSchema,
|
||||||
|
ColorRoleSchema,
|
||||||
|
LootboxSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type ValidatedEffectPayload = z.infer<typeof EffectPayloadSchema>;
|
||||||
|
|
||||||
|
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<any>;
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import {
|
|
||||||
handleAddXp,
|
|
||||||
handleAddBalance,
|
|
||||||
handleReplyMessage,
|
|
||||||
handleXpBoost,
|
|
||||||
handleTempRole,
|
|
||||||
handleColorRole,
|
|
||||||
handleLootbox
|
|
||||||
} from "./handlers";
|
|
||||||
import type { EffectHandler } from "./types";
|
|
||||||
|
|
||||||
export const effectHandlers: Record<string, EffectHandler> = {
|
|
||||||
'ADD_XP': handleAddXp,
|
|
||||||
'ADD_BALANCE': handleAddBalance,
|
|
||||||
'REPLY_MESSAGE': handleReplyMessage,
|
|
||||||
'XP_BOOST': handleXpBoost,
|
|
||||||
'TEMP_ROLE': handleTempRole,
|
|
||||||
'COLOR_ROLE': handleColorRole,
|
|
||||||
'LOOTBOX': handleLootbox
|
|
||||||
};
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
|
|
||||||
import type { Transaction } from "@shared/lib/types";
|
|
||||||
|
|
||||||
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<string>;
|
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { EmbedBuilder } from "discord.js";
|
import { EmbedBuilder, AttachmentBuilder } from "discord.js";
|
||||||
import type { ItemUsageData } from "@shared/lib/types";
|
import type { ItemUsageData } from "@shared/lib/types";
|
||||||
import { EffectType } from "@shared/lib/constants";
|
import { EffectType } from "@shared/lib/constants";
|
||||||
|
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
|
||||||
|
import { join } from "path";
|
||||||
|
import { existsSync } from "fs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inventory entry with item details
|
* Inventory entry with item details
|
||||||
@@ -31,24 +34,107 @@ export function getInventoryEmbed(items: InventoryEntry[], username: string): Em
|
|||||||
/**
|
/**
|
||||||
* Creates an embed showing the results of using an item
|
* Creates an embed showing the results of using an item
|
||||||
*/
|
*/
|
||||||
export function getItemUseResultEmbed(results: string[], item?: { name: string, iconUrl: string | null, usageData: any }): EmbedBuilder {
|
export function getItemUseResultEmbed(results: any[], item?: { name: string, iconUrl: string | null, usageData: any }): { embed: EmbedBuilder, files: AttachmentBuilder[] } {
|
||||||
const description = results.map(r => `• ${r}`).join("\n");
|
const embed = new EmbedBuilder();
|
||||||
|
const files: AttachmentBuilder[] = [];
|
||||||
|
const otherMessages: string[] = [];
|
||||||
|
let lootResult: any = null;
|
||||||
|
|
||||||
// Check if it was a lootbox
|
for (const res of results) {
|
||||||
const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === EffectType.LOOTBOX);
|
if (typeof res === 'object' && res.type === 'LOOTBOX_RESULT') {
|
||||||
|
lootResult = res;
|
||||||
const embed = new EmbedBuilder()
|
} else {
|
||||||
.setDescription(description)
|
otherMessages.push(typeof res === 'string' ? `• ${res}` : `• ${JSON.stringify(res)}`);
|
||||||
.setColor(isLootbox ? 0xFFD700 : 0x2ecc71); // Gold for lootbox, Green otherwise
|
|
||||||
|
|
||||||
if (isLootbox && item) {
|
|
||||||
embed.setTitle(`🎁 ${item.name} Opened!`);
|
|
||||||
if (item.iconUrl) {
|
|
||||||
embed.setThumbnail(item.iconUrl);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return embed;
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Standard item usage
|
||||||
|
embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!");
|
||||||
|
embed.setDescription(otherMessages.join("\n") || "Effect applied.");
|
||||||
|
|
||||||
|
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 }));
|
||||||
|
}
|
||||||
|
embed.setThumbnail(`attachment://${iconName}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const resolvedIconUrl = resolveAssetUrl(item.iconUrl);
|
||||||
|
if (resolvedIconUrl) embed.setThumbnail(resolvedIconUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (otherMessages.length > 0 && lootResult) {
|
||||||
|
embed.addFields({ name: "Other Effects", value: otherMessages.join("\n") });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { embed, files };
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultName(path: string): string {
|
||||||
|
return path.split("/").pop() || "image.png";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { EmbedBuilder } from "discord.js";
|
import {
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
ContainerBuilder,
|
||||||
|
TextDisplayBuilder,
|
||||||
|
SeparatorBuilder,
|
||||||
|
SeparatorSpacingSize,
|
||||||
|
MessageFlags
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Quest entry with quest details and progress
|
* Quest entry with quest details and progress
|
||||||
@@ -7,12 +16,33 @@ interface QuestEntry {
|
|||||||
progress: number | null;
|
progress: number | null;
|
||||||
completedAt: Date | null;
|
completedAt: Date | null;
|
||||||
quest: {
|
quest: {
|
||||||
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
triggerEvent: string;
|
||||||
|
requirements: any;
|
||||||
rewards: any;
|
rewards: any;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available quest interface
|
||||||
|
*/
|
||||||
|
interface AvailableQuest {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
rewards: any;
|
||||||
|
requirements: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color palette for containers
|
||||||
|
const COLORS = {
|
||||||
|
ACTIVE: 0x3498db, // Blue - in progress
|
||||||
|
AVAILABLE: 0x2ecc71, // Green - available
|
||||||
|
COMPLETED: 0xf1c40f // Gold - completed
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats quest rewards object into a human-readable string
|
* Formats quest rewards object into a human-readable string
|
||||||
*/
|
*/
|
||||||
@@ -20,35 +50,169 @@ function formatQuestRewards(rewards: { xp?: number, balance?: number }): string
|
|||||||
const rewardStr: string[] = [];
|
const rewardStr: string[] = [];
|
||||||
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
|
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
|
||||||
if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`);
|
if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`);
|
||||||
return rewardStr.join(", ");
|
return rewardStr.join(" • ") || "None";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the quest status display string
|
* Renders a simple progress bar
|
||||||
*/
|
*/
|
||||||
function getQuestStatus(completedAt: Date | null): string {
|
function renderProgressBar(current: number, total: number, size: number = 10): string {
|
||||||
return completedAt ? "✅ Completed" : "📝 In Progress";
|
const percentage = Math.min(current / total, 1);
|
||||||
|
const progress = Math.round(size * percentage);
|
||||||
|
const empty = size - progress;
|
||||||
|
|
||||||
|
const progressText = "▰".repeat(progress);
|
||||||
|
const emptyText = "▱".repeat(empty);
|
||||||
|
|
||||||
|
return `${progressText}${emptyText} ${Math.round(percentage * 100)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an embed displaying a user's quest log
|
* Creates Components v2 containers for the quest list (active quests only)
|
||||||
*/
|
*/
|
||||||
export function getQuestListEmbed(userQuests: QuestEntry[]): EmbedBuilder {
|
export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuilder[] {
|
||||||
const embed = new EmbedBuilder()
|
// Filter to only show in-progress quests (not completed)
|
||||||
.setTitle("📜 Quest Log")
|
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
|
||||||
.setColor(0x3498db); // Blue
|
|
||||||
|
const container = new ContainerBuilder()
|
||||||
|
.setAccentColor(COLORS.ACTIVE)
|
||||||
|
.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent("# 📜 Quest Log"),
|
||||||
|
new TextDisplayBuilder().setContent("-# Your active quests")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (activeQuests.length === 0) {
|
||||||
|
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||||
|
container.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent("*You have no active quests. Check available quests!*")
|
||||||
|
);
|
||||||
|
return [container];
|
||||||
|
}
|
||||||
|
|
||||||
|
activeQuests.forEach((entry) => {
|
||||||
|
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||||
|
|
||||||
userQuests.forEach(entry => {
|
|
||||||
const status = getQuestStatus(entry.completedAt);
|
|
||||||
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
||||||
const rewardsText = formatQuestRewards(rewards);
|
const rewardsText = formatQuestRewards(rewards);
|
||||||
|
|
||||||
embed.addFields({
|
const requirements = entry.quest.requirements as { target?: number };
|
||||||
name: `${entry.quest.name} (${status})`,
|
const target = requirements?.target || 1;
|
||||||
value: `${entry.quest.description}\n**Rewards:** ${rewardsText}\n**Progress:** ${entry.progress}%`,
|
const progress = entry.progress || 0;
|
||||||
inline: false
|
const progressBar = renderProgressBar(progress, target);
|
||||||
});
|
|
||||||
|
container.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(`**${entry.quest.name}**`),
|
||||||
|
new TextDisplayBuilder().setContent(entry.quest.description || "*No description*"),
|
||||||
|
new TextDisplayBuilder().setContent(`📊 ${progressBar} \`${progress}/${target}\` • 🎁 ${rewardsText}`)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return embed;
|
return [container];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates Components v2 containers for available quests with inline accept buttons
|
||||||
|
*/
|
||||||
|
export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]): ContainerBuilder[] {
|
||||||
|
const container = new ContainerBuilder()
|
||||||
|
.setAccentColor(COLORS.AVAILABLE)
|
||||||
|
.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent("# 🗺️ Available Quests"),
|
||||||
|
new TextDisplayBuilder().setContent("-# Quests you can accept")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (availableQuests.length === 0) {
|
||||||
|
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||||
|
container.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent("*No new quests available at the moment.*")
|
||||||
|
);
|
||||||
|
return [container];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit to 10 quests (5 action rows max with 2 added for navigation)
|
||||||
|
const questsToShow = availableQuests.slice(0, 10);
|
||||||
|
|
||||||
|
questsToShow.forEach((quest) => {
|
||||||
|
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||||
|
|
||||||
|
const rewards = quest.rewards as { xp?: number, balance?: number };
|
||||||
|
const rewardsText = formatQuestRewards(rewards);
|
||||||
|
|
||||||
|
const requirements = quest.requirements as { target?: number };
|
||||||
|
const target = requirements?.target || 1;
|
||||||
|
|
||||||
|
container.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(`**${quest.name}**`),
|
||||||
|
new TextDisplayBuilder().setContent(quest.description || "*No description*"),
|
||||||
|
new TextDisplayBuilder().setContent(`🎯 Goal: \`${target}\` • 🎁 ${rewardsText}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add accept button inline within the container
|
||||||
|
container.addActionRowComponents(
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`quest_accept:${quest.id}`)
|
||||||
|
.setLabel("Accept Quest")
|
||||||
|
.setStyle(ButtonStyle.Success)
|
||||||
|
.setEmoji("✅")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [container];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns action rows for navigation only
|
||||||
|
*/
|
||||||
|
export function getQuestActionRows(viewType: 'active' | 'available'): ActionRowBuilder<ButtonBuilder>[] {
|
||||||
|
// Navigation row
|
||||||
|
const navRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId("quest_view_active")
|
||||||
|
.setLabel("📜 Active")
|
||||||
|
.setStyle(viewType === 'active' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
||||||
|
.setDisabled(viewType === 'active'),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId("quest_view_available")
|
||||||
|
.setLabel("🗺️ Available")
|
||||||
|
.setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
||||||
|
.setDisabled(viewType === 'available')
|
||||||
|
);
|
||||||
|
|
||||||
|
return [navRow];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates Components v2 celebratory message for quest completion
|
||||||
|
*/
|
||||||
|
export function getQuestCompletionComponents(quest: any, rewards: { xp: bigint, balance: bigint }): ContainerBuilder[] {
|
||||||
|
const rewardsText = formatQuestRewards({
|
||||||
|
xp: Number(rewards.xp),
|
||||||
|
balance: Number(rewards.balance)
|
||||||
|
});
|
||||||
|
|
||||||
|
const container = new ContainerBuilder()
|
||||||
|
.setAccentColor(COLORS.COMPLETED)
|
||||||
|
.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent("# 🎉 Quest Completed!"),
|
||||||
|
new TextDisplayBuilder().setContent(`Congratulations! You've completed **${quest.name}**`)
|
||||||
|
)
|
||||||
|
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small))
|
||||||
|
.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(`📝 ${quest.description || "No description provided."}`),
|
||||||
|
new TextDisplayBuilder().setContent(`🎁 **Rewards Earned:** ${rewardsText}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
return [container];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets MessageFlags and allowedMentions for Components v2 messages
|
||||||
|
*/
|
||||||
|
export function getComponentsV2MessageFlags() {
|
||||||
|
return {
|
||||||
|
flags: MessageFlags.IsComponentsV2,
|
||||||
|
allowedMentions: { parse: [] as const }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { temporaryRoleService } from "@shared/modules/system/temp-role.service";
|
import { temporaryRoleService } from "@shared/modules/system/temp-role.service";
|
||||||
|
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
||||||
|
|
||||||
export const schedulerService = {
|
export const schedulerService = {
|
||||||
start: () => {
|
start: () => {
|
||||||
@@ -10,7 +11,6 @@ export const schedulerService = {
|
|||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|
||||||
// 2. Terminal Update Loop (every 60s)
|
// 2. Terminal Update Loop (every 60s)
|
||||||
const { terminalService } = require("@shared/modules/terminal/terminal.service");
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
terminalService.update();
|
terminalService.update();
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user