Compare commits
128 Commits
feat/dashb
...
bf20c61190
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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.
|
||||
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
|
||||
18
.env.example
18
.env.example
@@ -1,12 +1,26 @@
|
||||
# =============================================================================
|
||||
# Aurora Environment Configuration
|
||||
# =============================================================================
|
||||
# Copy this file to .env and update with your values
|
||||
# For production, see .env.prod.example with security recommendations
|
||||
# =============================================================================
|
||||
|
||||
# Database
|
||||
# For production: use a strong password (openssl rand -base64 32)
|
||||
DB_USER=aurora
|
||||
DB_PASSWORD=aurora
|
||||
DB_NAME=aurora
|
||||
DB_PORT=5432
|
||||
DB_HOST=db
|
||||
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
|
||||
|
||||
# Discord
|
||||
# Get from: https://discord.com/developers/applications
|
||||
DISCORD_BOT_TOKEN=your-discord-bot-token
|
||||
DISCORD_CLIENT_ID=your-discord-client-id
|
||||
DISCORD_GUILD_ID=your-discord-guild-id
|
||||
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
|
||||
|
||||
VPS_USER=your-vps-user
|
||||
# Server (for remote access scripts)
|
||||
# Use a non-root user (see shared/scripts/setup-server.sh)
|
||||
VPS_USER=deploy
|
||||
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
|
||||
env:
|
||||
NODE_ENV: test
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
.env
|
||||
node_modules
|
||||
docker-compose.override.yml
|
||||
shared/db-logs
|
||||
shared/db/data
|
||||
shared/db/loga
|
||||
@@ -44,4 +45,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
src/db/data
|
||||
src/db/log
|
||||
scratchpad/
|
||||
scratchpad/
|
||||
bot/assets/graphics/items
|
||||
tickets/
|
||||
|
||||
242
AGENTS.md
Normal file
242
AGENTS.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# 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");
|
||||
```
|
||||
|
||||
### Standard Error Pattern
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const result = await service.method();
|
||||
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
||||
} catch (error) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Unexpected error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("An unexpected error occurred.")],
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Database Patterns
|
||||
|
||||
### Transaction Usage
|
||||
|
||||
```typescript
|
||||
import { withTransaction } from "@/lib/db";
|
||||
|
||||
return await withTransaction(async (tx) => {
|
||||
const user = await tx.query.users.findFirst({
|
||||
where: eq(users.id, discordId),
|
||||
});
|
||||
|
||||
await tx
|
||||
.update(users)
|
||||
.set({ coins: newBalance })
|
||||
.where(eq(users.id, discordId));
|
||||
await tx.insert(transactions).values({ userId: discordId, amount, type });
|
||||
|
||||
return user;
|
||||
}, existingTx); // Pass existing tx if in nested transaction
|
||||
```
|
||||
|
||||
### Schema Notes
|
||||
|
||||
- Use `bigint` mode for Discord IDs and currency amounts
|
||||
- Relations defined separately from table definitions
|
||||
- Schema modules: `shared/db/schema/*.ts` (users, inventory, economy, quests, moderation)
|
||||
|
||||
## 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` |
|
||||
31
Dockerfile
31
Dockerfile
@@ -1,21 +1,34 @@
|
||||
# ============================================
|
||||
# Base stage - shared configuration
|
||||
# ============================================
|
||||
FROM oven/bun:latest AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
||||
# Install system dependencies with cleanup in same layer
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends git && \
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# Install root project dependencies
|
||||
# ============================================
|
||||
# Dependencies stage - installs all deps
|
||||
# ============================================
|
||||
FROM base AS deps
|
||||
|
||||
# Copy only package files first (better layer caching)
|
||||
COPY package.json bun.lock ./
|
||||
|
||||
# Install dependencies
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Install web project dependencies
|
||||
COPY web/package.json web/bun.lock ./web/
|
||||
RUN cd web && bun install --frozen-lockfile
|
||||
# ============================================
|
||||
# Development stage - for local dev with volume mounts
|
||||
# ============================================
|
||||
FROM base AS development
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
# Copy dependencies from deps stage
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
# Expose ports (3000 for web dashboard)
|
||||
# Expose ports
|
||||
EXPOSE 3000
|
||||
|
||||
# Default command
|
||||
|
||||
48
Dockerfile.prod
Normal file
48
Dockerfile.prod
Normal file
@@ -0,0 +1,48 @@
|
||||
# =============================================================================
|
||||
# Stage 1: Dependencies & Build
|
||||
# =============================================================================
|
||||
FROM oven/bun:latest AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies needed for build
|
||||
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install root project dependencies
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# =============================================================================
|
||||
# Stage 2: Production Runtime
|
||||
# =============================================================================
|
||||
FROM oven/bun:latest AS production
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user for security (bun user already exists with 1000:1000)
|
||||
# No need to create user/group
|
||||
|
||||
|
||||
|
||||
# Copy only what's needed for production
|
||||
COPY --from=builder --chown=bun:bun /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=bun:bun /app/web/src ./web/src
|
||||
COPY --from=builder --chown=bun:bun /app/bot ./bot
|
||||
COPY --from=builder --chown=bun:bun /app/shared ./shared
|
||||
COPY --from=builder --chown=bun:bun /app/package.json .
|
||||
COPY --from=builder --chown=bun:bun /app/drizzle.config.ts .
|
||||
COPY --from=builder --chown=bun:bun /app/tsconfig.json .
|
||||
|
||||
# Switch to non-root user
|
||||
USER bun
|
||||
|
||||
# Expose web dashboard port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD bun -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
|
||||
|
||||
# Run in production mode
|
||||
CMD ["bun", "run", "bot/index.ts"]
|
||||
67
README.md
67
README.md
@@ -7,24 +7,42 @@
|
||||

|
||||

|
||||

|
||||
|
||||
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
|
||||
|
||||
### Discord Bot
|
||||
* **Class System**: Users can join different classes.
|
||||
* **Economy**: Complete economy system with balance, transactions, and daily rewards.
|
||||
* **Inventory & Items**: sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management.
|
||||
* **Inventory & Items**: Sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management.
|
||||
* **Leveling**: XP-based leveling system to track user activity and progress.
|
||||
* **Quests**: Quest system with requirements and rewards.
|
||||
* **Trading**: Secure trading system between users.
|
||||
* **Lootdrops**: Random loot drops in channels to engage users.
|
||||
* **Admin Tools**: Administrative commands for server management.
|
||||
|
||||
### 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
|
||||
|
||||
* **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/)
|
||||
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
||||
* **Validation**: [Zod](https://zod.dev/)
|
||||
@@ -74,12 +92,14 @@ Aurora is a powerful Discord bot designed to facilitate RPG-like elements within
|
||||
bun run db:push
|
||||
```
|
||||
|
||||
### Running the Bot
|
||||
### Running the Bot & API
|
||||
|
||||
**Development Mode** (with hot reload):
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
* Bot: Online in Discord
|
||||
* API: http://localhost:3000
|
||||
|
||||
**Production Mode**:
|
||||
Build and run with Docker (recommended):
|
||||
@@ -87,27 +107,46 @@ Build and run with Docker (recommended):
|
||||
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
|
||||
|
||||
* `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 migrate`: Apply migrations (via Docker).
|
||||
* `bun run db:push`: Push, schema to DB (via Docker).
|
||||
* `bun run db:studio`: Open Drizzle Studio to inspect the database.
|
||||
* `bun test`: Run tests.
|
||||
|
||||
## 📂 Project Structure
|
||||
|
||||
```
|
||||
├── src
|
||||
│ ├── commands # Slash commands
|
||||
│ ├── events # Discord event handlers
|
||||
│ ├── modules # Feature modules (Economy, Inventory, etc.)
|
||||
│ ├── db # Database schema and connection
|
||||
│ └── lib # Shared utilities
|
||||
├── bot # Discord Bot logic & entry point
|
||||
├── web # REST API Server
|
||||
├── shared # Shared code (Database, Config, Types)
|
||||
├── drizzle # Drizzle migration files
|
||||
├── config # Configuration files
|
||||
└── scripts # Utility scripts
|
||||
├── scripts # Utility scripts
|
||||
├── docker-compose.yml
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
0
bot/assets/graphics/items/.gitkeep
Normal file
0
bot/assets/graphics/items/.gitkeep
Normal file
@@ -1,6 +1,6 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
export const moderationCase = createCommand({
|
||||
@@ -30,7 +30,7 @@ export const moderationCase = createCommand({
|
||||
}
|
||||
|
||||
// Get the case
|
||||
const moderationCase = await ModerationService.getCaseById(caseId);
|
||||
const moderationCase = await moderationService.getCaseById(caseId);
|
||||
|
||||
if (!moderationCase) {
|
||||
await interaction.editReply({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
export const cases = createCommand({
|
||||
@@ -29,7 +29,7 @@ export const cases = createCommand({
|
||||
const activeOnly = interaction.options.getBoolean("active_only") || false;
|
||||
|
||||
// Get cases for the user
|
||||
const userCases = await ModerationService.getUserCases(targetUser.id, activeOnly);
|
||||
const userCases = await moderationService.getUserCases(targetUser.id, activeOnly);
|
||||
|
||||
const title = activeOnly
|
||||
? `⚠️ Active Cases for ${targetUser.username}`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
export const clearwarning = createCommand({
|
||||
@@ -38,7 +38,7 @@ export const clearwarning = createCommand({
|
||||
}
|
||||
|
||||
// Check if case exists and is active
|
||||
const existingCase = await ModerationService.getCaseById(caseId);
|
||||
const existingCase = await moderationService.getCaseById(caseId);
|
||||
|
||||
if (!existingCase) {
|
||||
await interaction.editReply({
|
||||
@@ -62,7 +62,7 @@ export const clearwarning = createCommand({
|
||||
}
|
||||
|
||||
// Clear the warning
|
||||
await ModerationService.clearCase({
|
||||
await moderationService.clearCase({
|
||||
caseId,
|
||||
clearedBy: interaction.user.id,
|
||||
clearedByName: interaction.user.username,
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } 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 { items } from "@db/schema";
|
||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
||||
@@ -49,7 +50,7 @@ export const createColor = createCommand({
|
||||
// 2. Create Role
|
||||
const role = await interaction.guild?.roles.create({
|
||||
name: name,
|
||||
color: colorInput as any, // Discord.js types are a bit strict on ColorResolvable, but string generally works or needs parsing
|
||||
color: colorInput as any,
|
||||
reason: `Created via /createcolor by ${interaction.user.tag}`
|
||||
});
|
||||
|
||||
@@ -57,11 +58,9 @@ export const createColor = createCommand({
|
||||
throw new Error("Failed to create role.");
|
||||
}
|
||||
|
||||
// 3. Update Config
|
||||
if (!config.colorRoles.includes(role.id)) {
|
||||
config.colorRoles.push(role.id);
|
||||
saveConfig(config);
|
||||
}
|
||||
// 3. Add to guild settings
|
||||
await guildSettingsService.addColorRole(interaction.guildId!, role.id);
|
||||
invalidateGuildConfigCache(interaction.guildId!);
|
||||
|
||||
// 4. Create Item
|
||||
await DrizzleClient.insert(items).values({
|
||||
|
||||
297
bot/commands/admin/featureflags.ts
Normal file
297
bot/commands/admin/featureflags.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
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 { UserError } from "@shared/lib/errors";
|
||||
|
||||
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 interaction.deferReply();
|
||||
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
try {
|
||||
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;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function handleList(interaction: ChatInputCommandInteraction) {
|
||||
const flags = await featureFlagsService.listFlags();
|
||||
|
||||
if (flags.length === 0) {
|
||||
await interaction.editReply({ embeds: [createBaseEmbed("Feature Flags", "No feature flags have been created yet.", Colors.Blue)] });
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = createBaseEmbed("Feature Flags", undefined, Colors.Blue)
|
||||
.addFields(
|
||||
flags.map(f => ({
|
||||
name: f.name,
|
||||
value: `${f.enabled ? "✅ Enabled" : "❌ Disabled"}\n${f.description || "*No description*"}`,
|
||||
inline: false,
|
||||
}))
|
||||
);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
|
||||
async function handleCreate(interaction: ChatInputCommandInteraction) {
|
||||
const name = interaction.options.getString("name", true);
|
||||
const description = interaction.options.getString("description");
|
||||
|
||||
const flag = await featureFlagsService.createFlag(name, description ?? undefined);
|
||||
|
||||
if (!flag) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to create feature flag.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" created successfully. Use \`/featureflags enable\` to enable it.`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDelete(interaction: ChatInputCommandInteraction) {
|
||||
const name = interaction.options.getString("name", true);
|
||||
|
||||
const flag = await featureFlagsService.deleteFlag(name);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been deleted.`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleEnable(interaction: ChatInputCommandInteraction) {
|
||||
const name = interaction.options.getString("name", true);
|
||||
|
||||
const flag = await featureFlagsService.setFlagEnabled(name, true);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been enabled.`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDisable(interaction: ChatInputCommandInteraction) {
|
||||
const name = interaction.options.getString("name", true);
|
||||
|
||||
const flag = await featureFlagsService.setFlagEnabled(name, false);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been disabled.`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleGrant(interaction: ChatInputCommandInteraction) {
|
||||
const name = interaction.options.getString("name", true);
|
||||
const user = interaction.options.getUser("user");
|
||||
const role = interaction.options.getRole("role");
|
||||
|
||||
if (!user && !role) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("You must specify either a user or a role to grant access to.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const access = await featureFlagsService.grantAccess(name, {
|
||||
userId: user?.id,
|
||||
roleId: role?.id,
|
||||
guildId: interaction.guildId!,
|
||||
});
|
||||
|
||||
if (!access) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to grant access.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
let target: string;
|
||||
if (user) {
|
||||
target = userMention(user.id);
|
||||
} else if (role) {
|
||||
target = roleMention(role.id);
|
||||
} else {
|
||||
target = "Unknown";
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Access to "**${name}**" granted to ${target} (ID: ${access.id})`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRevoke(interaction: ChatInputCommandInteraction) {
|
||||
const id = interaction.options.getInteger("id", true);
|
||||
|
||||
const access = await featureFlagsService.revokeAccess(id);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Access record #${access.id} has been revoked.`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAccess(interaction: ChatInputCommandInteraction) {
|
||||
const name = interaction.options.getString("name", true);
|
||||
|
||||
const accessRecords = await featureFlagsService.listAccess(name);
|
||||
|
||||
if (accessRecords.length === 0) {
|
||||
await interaction.editReply({
|
||||
embeds: [createBaseEmbed("Feature Flag Access", `No access records for "**${name}**".`, Colors.Blue)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = accessRecords.map(a => {
|
||||
let target = "Unknown";
|
||||
if (a.userId) target = `User: ${userMention(a.userId.toString())}`;
|
||||
else if (a.roleId) target = `Role: ${roleMention(a.roleId.toString())}`;
|
||||
else if (a.guildId) target = `Guild: ${a.guildId.toString()}`;
|
||||
|
||||
return {
|
||||
name: `ID: ${a.id}`,
|
||||
value: target,
|
||||
inline: true,
|
||||
};
|
||||
});
|
||||
|
||||
const embed = createBaseEmbed(`Feature Flag Access: ${name}`, undefined, Colors.Blue)
|
||||
.addFields(fields);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createBaseEmbed } from "@lib/embeds";
|
||||
import { config, reloadConfig, toggleCommand } from "@shared/lib/config";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
|
||||
export const features = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("features")
|
||||
.setDescription("Manage bot features and commands")
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("list")
|
||||
.setDescription("List all commands and their status")
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("toggle")
|
||||
.setDescription("Enable or disable a command")
|
||||
.addStringOption(option =>
|
||||
option.setName("command")
|
||||
.setDescription("The name of the command")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addBooleanOption(option =>
|
||||
option.setName("enabled")
|
||||
.setDescription("Whether the command should be enabled")
|
||||
.setRequired(true)
|
||||
)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
if (subcommand === "list") {
|
||||
const activeCommands = AuroraClient.commands;
|
||||
const categories = new Map<string, string[]>();
|
||||
|
||||
// Group active commands
|
||||
activeCommands.forEach(cmd => {
|
||||
const cat = cmd.category || 'Uncategorized';
|
||||
if (!categories.has(cat)) categories.set(cat, []);
|
||||
categories.get(cat)!.push(cmd.data.name);
|
||||
});
|
||||
|
||||
// Config overrides
|
||||
const overrides = Object.entries(config.commands)
|
||||
.map(([name, enabled]) => `• **${name}**: ${enabled ? "✅ Enabled (Override)" : "❌ Disabled"}`);
|
||||
|
||||
const embed = createBaseEmbed("Command Features", undefined, "Blue");
|
||||
|
||||
// Add fields for each category
|
||||
const sortedCategories = [...categories.keys()].sort();
|
||||
for (const cat of sortedCategories) {
|
||||
const cmds = categories.get(cat)!.sort();
|
||||
const cmdList = cmds.map(name => {
|
||||
const isOverride = config.commands[name] !== undefined;
|
||||
return isOverride ? `**${name}** (See Overrides)` : `**${name}**`;
|
||||
}).join(", ");
|
||||
|
||||
embed.addFields({ name: `📂 ${cat.toUpperCase()}`, value: cmdList || "None" });
|
||||
}
|
||||
|
||||
if (overrides.length > 0) {
|
||||
embed.addFields({ name: "⚙️ Configuration Overrides", value: overrides.join("\n") });
|
||||
} else {
|
||||
embed.addFields({ name: "⚙️ Configuration Overrides", value: "No overrides set." });
|
||||
}
|
||||
|
||||
// Check permissions manually as a fallback (though defaultMemberPermissions handles it at the API level)
|
||||
if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) {
|
||||
await interaction.reply({ content: "❌ You need Administrator permissions to use this command.", flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
|
||||
} else if (subcommand === "toggle") {
|
||||
const commandName = interaction.options.getString("command", true);
|
||||
const enabled = interaction.options.getBoolean("enabled", true);
|
||||
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
toggleCommand(commandName, enabled);
|
||||
|
||||
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Reloading configuration...` });
|
||||
|
||||
// Reload config from disk (which was updated by toggleCommand)
|
||||
reloadConfig();
|
||||
|
||||
await AuroraClient.loadCommands(true);
|
||||
await AuroraClient.deployCommands();
|
||||
|
||||
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Commands reloaded!` });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,20 +1,18 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import {
|
||||
SlashCommandBuilder,
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
type BaseGuildTextChannel,
|
||||
PermissionFlagsBits,
|
||||
MessageFlags
|
||||
} from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { items } from "@db/schema";
|
||||
import { ilike, isNotNull, and } from "drizzle-orm";
|
||||
import { ilike, isNotNull, and, inArray } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { getShopListingMessage } from "@/modules/economy/shop.view";
|
||||
import { EffectType, LootType } from "@shared/lib/constants";
|
||||
|
||||
export const listing = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -54,21 +52,49 @@ export const listing = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare context for lootboxes
|
||||
const context: { referencedItems: Map<number, { name: string; rarity: string }> } = { referencedItems: new Map() };
|
||||
|
||||
const usageData = item.usageData as any;
|
||||
const lootboxEffect = usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX);
|
||||
|
||||
if (lootboxEffect && lootboxEffect.pool) {
|
||||
const itemIds = lootboxEffect.pool
|
||||
.filter((drop: any) => drop.type === LootType.ITEM && drop.itemId)
|
||||
.map((drop: any) => drop.itemId);
|
||||
|
||||
if (itemIds.length > 0) {
|
||||
// Remove duplicates
|
||||
const uniqueIds = [...new Set(itemIds)] as number[];
|
||||
|
||||
const referencedItems = await DrizzleClient.select({
|
||||
id: items.id,
|
||||
name: items.name,
|
||||
rarity: items.rarity
|
||||
}).from(items).where(inArray(items.id, uniqueIds));
|
||||
|
||||
for (const ref of referencedItems) {
|
||||
context.referencedItems.set(ref.id, { name: ref.name, rarity: ref.rarity || 'C' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listingMessage = getShopListingMessage({
|
||||
...item,
|
||||
rarity: item.rarity || undefined,
|
||||
formattedPrice: `${item.price} 🪙`,
|
||||
price: item.price
|
||||
});
|
||||
}, context);
|
||||
|
||||
try {
|
||||
await targetChannel.send(listingMessage);
|
||||
await targetChannel.send(listingMessage as any);
|
||||
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error creating listing:", error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { CaseType } from "@shared/lib/constants";
|
||||
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
@@ -31,7 +31,7 @@ export const note = createCommand({
|
||||
const noteText = interaction.options.getString("note", true);
|
||||
|
||||
// Create the note case
|
||||
const moderationCase = await ModerationService.createCase({
|
||||
const moderationCase = await moderationService.createCase({
|
||||
type: CaseType.NOTE,
|
||||
userId: targetUser.id,
|
||||
username: targetUser.username,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
export const notes = createCommand({
|
||||
@@ -22,7 +22,7 @@ export const notes = createCommand({
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
|
||||
// Get all notes for the user
|
||||
const userNotes = await ModerationService.getUserNotes(targetUser.id);
|
||||
const userNotes = await moderationService.getUserNotes(targetUser.id);
|
||||
|
||||
// Display the notes
|
||||
await interaction.editReply({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { PruneService } from "@shared/modules/moderation/prune.service";
|
||||
import { pruneService } from "@shared/modules/moderation/prune.service";
|
||||
import {
|
||||
getConfirmationMessage,
|
||||
getProgressEmbed,
|
||||
@@ -66,7 +66,7 @@ export const prune = createCommand({
|
||||
let estimatedCount: number | undefined;
|
||||
if (all) {
|
||||
try {
|
||||
estimatedCount = await PruneService.estimateMessageCount(interaction.channel!);
|
||||
estimatedCount = await pruneService.estimateMessageCount(interaction.channel!);
|
||||
} catch {
|
||||
estimatedCount = undefined;
|
||||
}
|
||||
@@ -97,7 +97,7 @@ export const prune = createCommand({
|
||||
});
|
||||
|
||||
// Execute deletion with progress callback for 'all' mode
|
||||
const result = await PruneService.deleteMessages(
|
||||
const result = await pruneService.deleteMessages(
|
||||
interaction.channel!,
|
||||
{
|
||||
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
|
||||
@@ -129,7 +129,7 @@ export const prune = createCommand({
|
||||
}
|
||||
} else {
|
||||
// No confirmation needed, proceed directly
|
||||
const result = await PruneService.deleteMessages(
|
||||
const result = await pruneService.deleteMessages(
|
||||
interaction.channel!,
|
||||
{
|
||||
amount: finalAmount as number,
|
||||
|
||||
247
bot/commands/admin/settings.ts
Normal file
247
bot/commands/admin/settings.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
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 { UserError } from "@shared/lib/errors";
|
||||
|
||||
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 interaction.deferReply({ ephemeral: true });
|
||||
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
const guildId = interaction.guildId!;
|
||||
|
||||
try {
|
||||
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;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function handleShow(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||
const settings = await getGuildConfig(guildId);
|
||||
|
||||
const colorRolesDisplay = settings.colorRoles?.length
|
||||
? settings.colorRoles.map(id => `<@&${id}>`).join(", ")
|
||||
: "None";
|
||||
|
||||
const embed = createBaseEmbed("Guild Settings", undefined, Colors.Blue)
|
||||
.addFields(
|
||||
{ name: "Student Role", value: settings.studentRole ? `<@&${settings.studentRole}>` : "Not set", inline: true },
|
||||
{ name: "Visitor Role", value: settings.visitorRole ? `<@&${settings.visitorRole}>` : "Not set", inline: true },
|
||||
{ name: "\u200b", value: "\u200b", inline: true },
|
||||
{ name: "Welcome Channel", value: settings.welcomeChannelId ? `<#${settings.welcomeChannelId}>` : "Not set", inline: true },
|
||||
{ name: "Feedback Channel", value: settings.feedbackChannelId ? `<#${settings.feedbackChannelId}>` : "Not set", inline: true },
|
||||
{ name: "Moderation Log", value: settings.moderation?.cases?.logChannelId ? `<#${settings.moderation.cases.logChannelId}>` : "Not set", inline: true },
|
||||
{ name: "Terminal Channel", value: settings.terminal?.channelId ? `<#${settings.terminal.channelId}>` : "Not set", inline: true },
|
||||
{ name: "DM on Warn", value: settings.moderation?.cases?.dmOnWarn !== false ? "Enabled" : "Disabled", inline: true },
|
||||
{ name: "Auto Timeout", value: settings.moderation?.cases?.autoTimeoutThreshold ? `${settings.moderation.cases.autoTimeoutThreshold} warnings` : "Disabled", inline: true },
|
||||
{ name: "Color Roles", value: colorRolesDisplay, inline: false },
|
||||
);
|
||||
|
||||
if (settings.welcomeMessage) {
|
||||
embed.addFields({ name: "Welcome Message", value: settings.welcomeMessage.substring(0, 1024), inline: false });
|
||||
}
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
|
||||
async function handleSet(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||
const key = interaction.options.getString("key", true);
|
||||
const role = interaction.options.getRole("role");
|
||||
const channel = interaction.options.getChannel("channel");
|
||||
const text = interaction.options.getString("text");
|
||||
const number = interaction.options.getInteger("number");
|
||||
const boolean = interaction.options.getBoolean("boolean");
|
||||
|
||||
let value: string | number | boolean | null = null;
|
||||
|
||||
if (role) value = role.id;
|
||||
else if (channel) value = channel.id;
|
||||
else if (text) value = text;
|
||||
else if (number !== null) value = number;
|
||||
else if (boolean !== null) value = boolean;
|
||||
|
||||
if (value === null) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("Please provide a role, channel, text, number, or boolean value")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await guildSettingsService.updateSetting(guildId, key, value);
|
||||
invalidateGuildConfigCache(guildId);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Setting "${key}" updated`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleReset(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||
const key = interaction.options.getString("key", true);
|
||||
|
||||
await guildSettingsService.updateSetting(guildId, key, null);
|
||||
invalidateGuildConfigCache(guildId);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Setting "${key}" reset to default`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleColors(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||
const action = interaction.options.getString("action", true);
|
||||
const role = interaction.options.getRole("role");
|
||||
|
||||
switch (action) {
|
||||
case "list": {
|
||||
const settings = await getGuildConfig(guildId);
|
||||
const colorRoles = settings.colorRoles ?? [];
|
||||
|
||||
if (colorRoles.length === 0) {
|
||||
await interaction.editReply({
|
||||
embeds: [createBaseEmbed("Color Roles", "No color roles configured.", Colors.Blue)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = createBaseEmbed("Color Roles", undefined, Colors.Blue)
|
||||
.addFields({
|
||||
name: `Configured Roles (${colorRoles.length})`,
|
||||
value: colorRoles.map(id => `<@&${id}>`).join("\n"),
|
||||
});
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
break;
|
||||
}
|
||||
|
||||
case "add": {
|
||||
if (!role) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("Please specify a role to add.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await guildSettingsService.addColorRole(guildId, role.id);
|
||||
invalidateGuildConfigCache(guildId);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Added <@&${role.id}> to color roles.`)]
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "remove": {
|
||||
if (!role) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("Please specify a role to remove.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await guildSettingsService.removeColorRole(guildId, role.id);
|
||||
invalidateGuildConfigCache(guildId);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Removed <@&${role.id}> from color roles.`)]
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,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 { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import {
|
||||
getWarnSuccessEmbed,
|
||||
getModerationErrorEmbed,
|
||||
getUserWarningEmbed
|
||||
} from "@/modules/moderation/moderation.view";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
|
||||
export const warn = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -50,8 +50,11 @@ export const warn = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch guild config for moderation settings
|
||||
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||
|
||||
// Issue the warning via service
|
||||
const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({
|
||||
const { moderationCase, warningCount, autoTimeoutIssued } = await moderationService.issueWarning({
|
||||
userId: targetUser.id,
|
||||
username: targetUser.username,
|
||||
moderatorId: interaction.user.id,
|
||||
@@ -59,7 +62,11 @@ export const warn = createCommand({
|
||||
reason,
|
||||
guildName: interaction.guild?.name || undefined,
|
||||
dmTarget: targetUser,
|
||||
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id)
|
||||
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id),
|
||||
config: {
|
||||
dmOnWarn: guildConfig.moderation?.cases?.dmOnWarn,
|
||||
autoTimeoutThreshold: guildConfig.moderation?.cases?.autoTimeoutThreshold,
|
||||
},
|
||||
});
|
||||
|
||||
// Send success message to moderator
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
export const warnings = createCommand({
|
||||
@@ -22,7 +22,7 @@ export const warnings = createCommand({
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
|
||||
// Get active warnings for the user
|
||||
const activeWarnings = await ModerationService.getUserWarnings(targetUser.id);
|
||||
const activeWarnings = await moderationService.getUserWarnings(targetUser.id);
|
||||
|
||||
// Display the warnings
|
||||
await interaction.editReply({
|
||||
|
||||
@@ -3,13 +3,14 @@ import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
export const daily = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("daily")
|
||||
.setDescription("Claim your daily reward"),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
try {
|
||||
const result = await economyService.claimDaily(interaction.user.id);
|
||||
|
||||
@@ -21,14 +22,14 @@ export const daily = createCommand({
|
||||
)
|
||||
.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 });
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error claiming daily:", error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,7 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { userTimers, users } from "@db/schema";
|
||||
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;
|
||||
}
|
||||
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
@@ -25,105 +11,42 @@ export const exam = createCommand({
|
||||
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
if (!user) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to retrieve user data.")] });
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
const currentDay = now.getDay();
|
||||
|
||||
try {
|
||||
// 1. Fetch existing timer/exam data
|
||||
const timer = await DrizzleClient.query.userTimers.findFirst({
|
||||
where: and(
|
||||
eq(userTimers.userId, user.id),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
)
|
||||
});
|
||||
// First, try to take the exam or check status
|
||||
const result = await examService.takeExam(interaction.user.id);
|
||||
|
||||
// 2. First Run Logic
|
||||
if (!timer) {
|
||||
// Set exam day to today
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + 7);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||
|
||||
const metadata: ExamMetadata = {
|
||||
examDay: currentDay,
|
||||
lastXp: (user.xp ?? 0n).toString()
|
||||
};
|
||||
|
||||
await DrizzleClient.insert(userTimers).values({
|
||||
userId: user.id,
|
||||
type: EXAM_TIMER_TYPE,
|
||||
key: EXAM_TIMER_KEY,
|
||||
expiresAt: nextExamDate,
|
||||
metadata: metadata
|
||||
});
|
||||
if (result.status === ExamStatus.NOT_REGISTERED) {
|
||||
// Register the user
|
||||
const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username);
|
||||
const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}** (Server Time).\n` +
|
||||
`Come back on <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>) to take your first exam!`,
|
||||
`You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` +
|
||||
`Come back on <t:${nextRegTimestamp}:D> (<t:${nextRegTimestamp}:R>) to take your first exam!`,
|
||||
"Exam Registration Successful"
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const 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);
|
||||
const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000);
|
||||
|
||||
if (result.status === ExamStatus.COOLDOWN) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
||||
`Next exam available: <t:${timestamp}:D> (<t:${timestamp}:R>)`
|
||||
`Next exam available: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}: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)
|
||||
));
|
||||
|
||||
if (result.status === ExamStatus.MISSED) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You missed your exam day! Your exam day is **${DAYS[examDay]}** (Server Time).\n` +
|
||||
`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"
|
||||
@@ -132,74 +55,21 @@ export const exam = createCommand({
|
||||
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));
|
||||
}
|
||||
});
|
||||
|
||||
// If it reached here with AVAILABLE, it means they passed
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`**XP Gained:** ${diff.toString()}\n` +
|
||||
`**Multiplier:** x${multiplier.toFixed(2)}\n` +
|
||||
`**Reward:** ${reward.toString()} Currency\n\n` +
|
||||
`**XP Gained:** ${result.xpDiff?.toString()}\n` +
|
||||
`**Multiplier:** x${result.multiplier?.toFixed(2)}\n` +
|
||||
`**Reward:** ${result.reward?.toString()} Currency\n\n` +
|
||||
`See you next week: <t:${nextExamTimestamp}:D>`,
|
||||
"Exam Passed!"
|
||||
)]
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
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 });
|
||||
}
|
||||
console.error("Error in exam command:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An unexpected error occurred.")] });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
export const pay = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
|
||||
117
bot/commands/economy/trivia.ts
Normal file
117
bot/commands/economy/trivia.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
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";
|
||||
|
||||
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 - defer publicly for trivia question
|
||||
await interaction.deferReply();
|
||||
|
||||
// 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) {
|
||||
if (error instanceof UserError) {
|
||||
// Check if we've already deferred
|
||||
if (interaction.deferred) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(error.message)]
|
||||
});
|
||||
} else {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed(error.message)],
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.error("Error in trivia command:", error);
|
||||
// Check if we've already deferred
|
||||
if (interaction.deferred) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")]
|
||||
});
|
||||
} else {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { getFeedbackTypeMenu } from "@/modules/feedback/feedback.view";
|
||||
|
||||
@@ -9,8 +9,10 @@ export const feedback = createCommand({
|
||||
.setName("feedback")
|
||||
.setDescription("Submit feedback, feature requests, or bug reports"),
|
||||
execute: async (interaction) => {
|
||||
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||
|
||||
// Check if feedback channel is configured
|
||||
if (!config.feedbackChannelId) {
|
||||
if (!guildConfig.feedbackChannelId) {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("Feedback system is not configured. Please contact an administrator.")],
|
||||
ephemeral: true
|
||||
|
||||
@@ -5,8 +5,8 @@ import { userService } from "@shared/modules/user/user.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
||||
import type { ItemUsageData } from "@shared/lib/types";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
|
||||
export const use = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -21,6 +21,9 @@ export const use = createCommand({
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
|
||||
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||
const colorRoles = guildConfig.colorRoles ?? [];
|
||||
|
||||
const itemId = interaction.options.getNumber("item", true);
|
||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
if (!user) {
|
||||
@@ -42,7 +45,7 @@ export const use = createCommand({
|
||||
await member.roles.add(effect.roleId);
|
||||
} else if (effect.type === 'COLOR_ROLE') {
|
||||
// Remove existing color roles
|
||||
const rolesToRemove = config.colorRoles.filter(r => member.roles.cache.has(r));
|
||||
const rolesToRemove = colorRoles.filter(r => member.roles.cache.has(r));
|
||||
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
|
||||
await member.roles.add(effect.roleId);
|
||||
}
|
||||
@@ -55,9 +58,9 @@ export const use = createCommand({
|
||||
}
|
||||
}
|
||||
|
||||
const embed = getItemUseResultEmbed(result.results, result.item);
|
||||
const { embed, files } = getItemUseResultEmbed(result.results, result.item);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
await interaction.editReply({ embeds: [embed], files });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
|
||||
@@ -1,25 +1,83 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
||||
import { questService } from "@shared/modules/quest/quest.service";
|
||||
import { createWarningEmbed } from "@lib/embeds";
|
||||
import { getQuestListEmbed } from "@/modules/quest/quest.view";
|
||||
import { createSuccessEmbed } from "@lib/embeds";
|
||||
import {
|
||||
getQuestListComponents,
|
||||
getAvailableQuestsComponents,
|
||||
getQuestActionRows
|
||||
} from "@/modules/quest/quest.view";
|
||||
|
||||
export const quests = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("quests")
|
||||
.setDescription("View your active quests"),
|
||||
.setDescription("View your active and available quests"),
|
||||
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) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("You have no active quests.", "Quest Log")] });
|
||||
return;
|
||||
}
|
||||
const updateView = async (viewType: 'active' | 'available') => {
|
||||
const userQuests = await questService.getUserQuests(userId);
|
||||
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 type { Event } from "@shared/lib/types";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
|
||||
// Visitor role
|
||||
const event: Event<Events.GuildMemberAdd> = {
|
||||
name: Events.GuildMemberAdd,
|
||||
execute: async (member) => {
|
||||
console.log(`👤 New member joined: ${member.user.tag} (${member.id})`);
|
||||
|
||||
const guildConfig = await getGuildConfig(member.guild.id);
|
||||
|
||||
try {
|
||||
const user = await userService.getUserById(member.id);
|
||||
|
||||
if (user && user.class) {
|
||||
console.log(`🔄 Returning student detected: ${member.user.tag}`);
|
||||
await member.roles.remove(config.visitorRole);
|
||||
await member.roles.add(config.studentRole);
|
||||
if (guildConfig.visitorRole) {
|
||||
await member.roles.remove(guildConfig.visitorRole);
|
||||
}
|
||||
if (guildConfig.studentRole) {
|
||||
await member.roles.add(guildConfig.studentRole);
|
||||
}
|
||||
|
||||
if (user.class.roleId) {
|
||||
await member.roles.add(user.class.roleId);
|
||||
@@ -22,8 +28,10 @@ const event: Event<Events.GuildMemberAdd> = {
|
||||
}
|
||||
console.log(`Restored student role to ${member.user.tag}`);
|
||||
} else {
|
||||
await member.roles.add(config.visitorRole);
|
||||
console.log(`Assigned visitor role to ${member.user.tag}`);
|
||||
if (guildConfig.visitorRole) {
|
||||
await member.roles.add(guildConfig.visitorRole);
|
||||
console.log(`Assigned visitor role to ${member.user.tag}`);
|
||||
}
|
||||
}
|
||||
console.log(`User Roles: ${member.roles.cache.map(role => role.name).join(", ")}`);
|
||||
} catch (error) {
|
||||
|
||||
@@ -9,9 +9,7 @@ const event: Event<Events.ClientReady> = {
|
||||
console.log(`Ready! Logged in as ${c.user.tag}`);
|
||||
schedulerService.start();
|
||||
|
||||
// Handle post-update tasks
|
||||
const { UpdateService } = await import("@shared/modules/admin/update.service");
|
||||
await UpdateService.handlePostRestart(c);
|
||||
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { env } from "@shared/lib/env";
|
||||
import { join } from "node:path";
|
||||
import { initializeConfig } from "@shared/lib/config";
|
||||
|
||||
import { startWebServerFromRoot } from "../web/src/server";
|
||||
|
||||
// Initialize config from database
|
||||
await initializeConfig();
|
||||
|
||||
// Load commands & events
|
||||
await AuroraClient.loadCommands();
|
||||
await AuroraClient.loadEvents();
|
||||
|
||||
@@ -20,7 +20,8 @@ mock.module("discord.js", () => ({
|
||||
Routes: {
|
||||
applicationGuildCommands: () => 'guild_route',
|
||||
applicationCommands: () => 'global_route'
|
||||
}
|
||||
},
|
||||
MessageFlags: {}
|
||||
}));
|
||||
|
||||
// Mock loaders to avoid filesystem access during client init
|
||||
|
||||
@@ -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 type { Command } from "@shared/lib/types";
|
||||
import { env } from "@shared/lib/env";
|
||||
@@ -8,6 +8,7 @@ import { EventLoader } from "@lib/loaders/EventLoader";
|
||||
export class Client extends DiscordClient {
|
||||
|
||||
commands: Collection<string, Command>;
|
||||
knownCommands: Map<string, string>;
|
||||
lastCommandTimestamp: number | null = null;
|
||||
maintenanceMode: boolean = false;
|
||||
private commandLoader: CommandLoader;
|
||||
@@ -16,6 +17,7 @@ export class Client extends DiscordClient {
|
||||
constructor({ intents }: { intents: number[] }) {
|
||||
super({ intents });
|
||||
this.commands = new Collection<string, Command>();
|
||||
this.knownCommands = new Map<string, string>();
|
||||
this.commandLoader = new CommandLoader(this);
|
||||
this.eventLoader = new EventLoader(this);
|
||||
}
|
||||
@@ -72,11 +74,33 @@ export class Client extends DiscordClient {
|
||||
console.log(`🛠️ System Action: Maintenance mode ${enabled ? "ON" : "OFF"}${reason ? ` (${reason})` : ""}`);
|
||||
this.maintenanceMode = enabled;
|
||||
});
|
||||
|
||||
systemEvents.on(EVENTS.QUEST.COMPLETED, async (data: { userId: string, quest: any, rewards: any }) => {
|
||||
const { userId, quest, rewards } = data;
|
||||
try {
|
||||
const user = await this.users.fetch(userId);
|
||||
if (!user) return;
|
||||
|
||||
const { getQuestCompletionComponents } = await import("@/modules/quest/quest.view");
|
||||
const components = getQuestCompletionComponents(quest, rewards);
|
||||
|
||||
// Try to send to the user's DM
|
||||
await user.send({
|
||||
components: components as any,
|
||||
flags: [MessageFlags.IsComponentsV2]
|
||||
}).catch(async () => {
|
||||
console.warn(`Could not DM user ${userId} quest completion message. User might have DMs disabled.`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send quest completion notification:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadCommands(reload: boolean = false) {
|
||||
if (reload) {
|
||||
this.commands.clear();
|
||||
this.knownCommands.clear();
|
||||
console.log("♻️ Reloading commands...");
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
size: 20,
|
||||
},
|
||||
knownCommands: {
|
||||
size: 20,
|
||||
},
|
||||
lastCommandTimestamp: 1641481200000,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -23,11 +23,13 @@ export function getClientStats(): ClientStats {
|
||||
bot: {
|
||||
name: AuroraClient.user?.username || "Aurora",
|
||||
avatarUrl: AuroraClient.user?.displayAvatarURL() || null,
|
||||
status: AuroraClient.user?.presence.activities[0]?.state || AuroraClient.user?.presence.activities[0]?.name || null,
|
||||
},
|
||||
guilds: AuroraClient.guilds.cache.size,
|
||||
ping: AuroraClient.ws.ping,
|
||||
cachedUsers: AuroraClient.users.cache.size,
|
||||
commandsRegistered: AuroraClient.commands.size,
|
||||
commandsKnown: AuroraClient.knownCommands.size,
|
||||
uptime: process.uptime(),
|
||||
lastCommandTimestamp: AuroraClient.lastCommandTimestamp,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
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.
|
||||
@@ -7,11 +18,13 @@ import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js";
|
||||
* @returns An EmbedBuilder instance configured as an error.
|
||||
*/
|
||||
export function createErrorEmbed(message: string, title: string = "Error"): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`❌ ${title}`)
|
||||
.setDescription(message)
|
||||
.setColor(Colors.Red)
|
||||
.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.
|
||||
*/
|
||||
export function createWarningEmbed(message: string, title: string = "Warning"): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`⚠️ ${title}`)
|
||||
.setDescription(message)
|
||||
.setColor(Colors.Yellow)
|
||||
.setTimestamp();
|
||||
|
||||
return applyBranding(embed);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,11 +50,13 @@ export function createWarningEmbed(message: string, title: string = "Warning"):
|
||||
* @returns An EmbedBuilder instance configured as a success.
|
||||
*/
|
||||
export function createSuccessEmbed(message: string, title: string = "Success"): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`✅ ${title}`)
|
||||
.setDescription(message)
|
||||
.setColor(Colors.Green)
|
||||
.setTimestamp();
|
||||
|
||||
return applyBranding(embed);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,11 +66,13 @@ export function createSuccessEmbed(message: string, title: string = "Success"):
|
||||
* @returns An EmbedBuilder instance configured as info.
|
||||
*/
|
||||
export function createInfoEmbed(message: string, title: string = "Info"): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`ℹ️ ${title}`)
|
||||
.setDescription(message)
|
||||
.setColor(Colors.Blue)
|
||||
.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 {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTimestamp();
|
||||
.setTimestamp()
|
||||
.setColor(color ?? BRANDING.COLOR);
|
||||
|
||||
if (title) embed.setTitle(title);
|
||||
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 { AuroraClient } from "@/lib/BotClient";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
|
||||
|
||||
/**
|
||||
@@ -16,7 +17,7 @@ export class AutocompleteHandler {
|
||||
try {
|
||||
await command.autocomplete(interaction);
|
||||
} 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 { AuroraClient } from "@/lib/BotClient";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
|
||||
|
||||
/**
|
||||
@@ -13,7 +15,7 @@ export class CommandHandler {
|
||||
const command = AuroraClient.commands.get(interaction.commandName);
|
||||
|
||||
if (!command) {
|
||||
console.error(`No command matching ${interaction.commandName} was found.`);
|
||||
logger.error("bot", `No command matching ${interaction.commandName} was found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,18 +26,49 @@ export class CommandHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check beta feature access
|
||||
if (command.beta) {
|
||||
const flagName = command.featureFlag || interaction.commandName;
|
||||
let memberRoles: string[] = [];
|
||||
|
||||
if (interaction.member && 'roles' in interaction.member) {
|
||||
const roles = interaction.member.roles;
|
||||
if (typeof roles === 'object' && 'cache' in roles) {
|
||||
memberRoles = [...roles.cache.keys()];
|
||||
} else if (Array.isArray(roles)) {
|
||||
memberRoles = roles;
|
||||
}
|
||||
}
|
||||
|
||||
const hasAccess = await featureFlagsService.hasAccess(flagName, {
|
||||
guildId: interaction.guildId!,
|
||||
userId: interaction.user.id,
|
||||
memberRoles,
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
const errorEmbed = createErrorEmbed(
|
||||
"This feature is currently in beta testing and not available to all users. " +
|
||||
"Stay tuned for the official release!",
|
||||
"Beta Feature"
|
||||
);
|
||||
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure user exists in database
|
||||
try {
|
||||
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
} catch (error) {
|
||||
console.error("Failed to ensure user exists:", error);
|
||||
logger.error("bot", "Failed to ensure user exists", error);
|
||||
}
|
||||
|
||||
try {
|
||||
await command.execute(interaction);
|
||||
AuroraClient.lastCommandTimestamp = Date.now();
|
||||
} 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!');
|
||||
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 { logger } from "@shared/lib/logger";
|
||||
|
||||
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
||||
|
||||
@@ -28,7 +29,7 @@ export class ComponentInteractionHandler {
|
||||
return;
|
||||
}
|
||||
} 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
|
||||
if (!isUserError) {
|
||||
console.error(`Error in ${handlerName}:`, error);
|
||||
logger.error("bot", `Error in ${handlerName}`, error);
|
||||
}
|
||||
|
||||
const errorEmbed = createErrorEmbed(errorMessage);
|
||||
@@ -72,7 +73,7 @@ export class ComponentInteractionHandler {
|
||||
}
|
||||
} catch (replyError) {
|
||||
// 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"),
|
||||
method: 'handleLootdropInteraction'
|
||||
},
|
||||
{
|
||||
predicate: (i) => i.isButton() && i.customId.startsWith("trivia_"),
|
||||
handler: () => import("@/modules/trivia/trivia.interaction"),
|
||||
method: 'handleTriviaInteraction'
|
||||
},
|
||||
|
||||
// --- ADMIN MODULE ---
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Command } from "@shared/lib/types";
|
||||
import { config } from "@shared/lib/config";
|
||||
import type { LoadResult, LoadError } from "./types";
|
||||
import type { Client } from "../BotClient";
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Handles loading commands from the file system
|
||||
@@ -71,6 +71,9 @@ export class CommandLoader {
|
||||
if (this.isValidCommand(command)) {
|
||||
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;
|
||||
|
||||
if (!isEnabled) {
|
||||
|
||||
@@ -23,7 +23,7 @@ export const renderWizard = (userId: string, isDraft = true) => {
|
||||
draft = {
|
||||
name: "New Item",
|
||||
description: "No description",
|
||||
rarity: "Common",
|
||||
rarity: "C",
|
||||
type: ItemType.MATERIAL,
|
||||
price: null,
|
||||
iconUrl: "",
|
||||
|
||||
@@ -87,7 +87,7 @@ export const getDetailsModal = (current: DraftItem) => {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("name").setLabel("Name").setValue(current.name).setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("desc").setLabel("Description").setValue(current.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("Common, Rare, Legendary...").setRequired(true))
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("C, R, SR, SSR").setRequired(true))
|
||||
);
|
||||
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 { lootdropService } from "@shared/modules/economy/lootdrop.service";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { getLootdropClaimedMessage } from "./lootdrop.view";
|
||||
|
||||
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ButtonInteraction, MessageFlags } from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
export async function handleShopInteraction(interaction: ButtonInteraction) {
|
||||
if (!interaction.customId.startsWith("shop_buy_")) return;
|
||||
|
||||
@@ -1,20 +1,208 @@
|
||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
|
||||
import { createBaseEmbed } from "@/lib/embeds";
|
||||
import {
|
||||
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 }) {
|
||||
const embed = createBaseEmbed(`Shop: ${item.name}`, item.description || "No description available.", "Green")
|
||||
.addFields({ name: "Price", value: item.formattedPrice, inline: true })
|
||||
.setThumbnail(item.iconUrl || null)
|
||||
.setImage(item.imageUrl || null)
|
||||
.setFooter({ text: "Click the button below to purchase instantly." });
|
||||
// Rarity Color Map
|
||||
const RarityColors: Record<string, number> = {
|
||||
"C": Colors.LightGrey,
|
||||
"R": Colors.Blue,
|
||||
"SR": Colors.Purple,
|
||||
"SSR": Colors.Gold,
|
||||
"CURRENCY": Colors.Green,
|
||||
"XP": Colors.Aqua,
|
||||
"NOTHING": Colors.DarkButNotBlack
|
||||
};
|
||||
|
||||
const TitleMap: Record<string, string> = {
|
||||
"C": "📦 Common Items",
|
||||
"R": "📦 Rare Items",
|
||||
"SR": "✨ Super Rare Items",
|
||||
"SSR": "🌟 SSR Items",
|
||||
"CURRENCY": "💰 Currency",
|
||||
"XP": "🔮 Experience",
|
||||
"NOTHING": "💨 Empty"
|
||||
};
|
||||
|
||||
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()
|
||||
.setCustomId(`shop_buy_${item.id}`)
|
||||
.setLabel(`Buy for ${item.price} 🪙`)
|
||||
.setLabel(`Purchase for ${item.price} 🪙`)
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.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 { TextChannel, MessageFlags } from "discord.js";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
|
||||
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
||||
// 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.");
|
||||
}
|
||||
|
||||
if (!config.feedbackChannelId) {
|
||||
if (!interaction.guildId) {
|
||||
throw new UserError("This action can only be performed in a server.");
|
||||
}
|
||||
|
||||
const guildConfig = await getGuildConfig(interaction.guildId);
|
||||
|
||||
if (!guildConfig.feedbackChannelId) {
|
||||
throw new UserError("Feedback channel is not configured. Please contact an administrator.");
|
||||
}
|
||||
|
||||
@@ -52,7 +58,7 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
||||
};
|
||||
|
||||
// Get feedback channel
|
||||
const channel = await AuroraClient.channels.fetch(config.feedbackChannelId).catch(() => null) as TextChannel | null;
|
||||
const channel = await AuroraClient.channels.fetch(guildConfig.feedbackChannelId).catch(() => null) as TextChannel | null;
|
||||
|
||||
if (!channel) {
|
||||
throw new UserError("Feedback channel not found. Please contact an administrator.");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { levelingService } from "@shared/modules/leveling/leveling.service";
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { userTimers } from "@db/schema";
|
||||
import type { EffectHandler } from "./types";
|
||||
import type { EffectHandler } from "./effect.types";
|
||||
import type { LootTableItem } from "@shared/lib/types";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { inventory, items } from "@db/schema";
|
||||
@@ -86,7 +86,11 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
||||
|
||||
// Process Winner
|
||||
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) {
|
||||
@@ -96,7 +100,12 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
||||
}
|
||||
if (amount > 0) {
|
||||
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 +116,12 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
||||
}
|
||||
if (amount > 0) {
|
||||
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 +137,18 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
||||
where: (items: any, { eq }: any) => eq(items.id, winner.itemId!)
|
||||
});
|
||||
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) {
|
||||
console.error("Failed to fetch item name for lootbox message", e);
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
handleTempRole,
|
||||
handleColorRole,
|
||||
handleLootbox
|
||||
} from "./handlers";
|
||||
import type { EffectHandler } from "./types";
|
||||
} from "./effect.handlers";
|
||||
import type { EffectHandler } from "./effect.types";
|
||||
|
||||
export const effectHandlers: Record<string, EffectHandler> = {
|
||||
'ADD_XP': handleAddXp,
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import type { Transaction } from "@shared/lib/types";
|
||||
|
||||
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<string>;
|
||||
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<any>;
|
||||
@@ -1,6 +1,9 @@
|
||||
import { EmbedBuilder } from "discord.js";
|
||||
import { EmbedBuilder, AttachmentBuilder } from "discord.js";
|
||||
import type { ItemUsageData } from "@shared/lib/types";
|
||||
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
|
||||
@@ -31,24 +34,107 @@ export function getInventoryEmbed(items: InventoryEntry[], username: string): Em
|
||||
/**
|
||||
* Creates an embed showing the results of using an item
|
||||
*/
|
||||
export function getItemUseResultEmbed(results: string[], item?: { name: string, iconUrl: string | null, usageData: any }): EmbedBuilder {
|
||||
const description = results.map(r => `• ${r}`).join("\n");
|
||||
export function getItemUseResultEmbed(results: any[], item?: { name: string, iconUrl: string | null, usageData: any }): { embed: EmbedBuilder, files: AttachmentBuilder[] } {
|
||||
const embed = new EmbedBuilder();
|
||||
const files: AttachmentBuilder[] = [];
|
||||
const otherMessages: string[] = [];
|
||||
let lootResult: any = null;
|
||||
|
||||
// Check if it was a lootbox
|
||||
const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === EffectType.LOOTBOX);
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setDescription(description)
|
||||
.setColor(isLootbox ? 0xFFD700 : 0x2ecc71); // Gold for lootbox, Green otherwise
|
||||
|
||||
if (isLootbox && item) {
|
||||
embed.setTitle(`🎁 ${item.name} Opened!`);
|
||||
if (item.iconUrl) {
|
||||
embed.setThumbnail(item.iconUrl);
|
||||
for (const res of results) {
|
||||
if (typeof res === 'object' && res.type === 'LOOTBOX_RESULT') {
|
||||
lootResult = res;
|
||||
} else {
|
||||
otherMessages.push(typeof res === 'string' ? `• ${res}` : `• ${JSON.stringify(res)}`);
|
||||
}
|
||||
} 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
|
||||
@@ -7,12 +16,33 @@ interface QuestEntry {
|
||||
progress: number | null;
|
||||
completedAt: Date | null;
|
||||
quest: {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
triggerEvent: string;
|
||||
requirements: 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
|
||||
*/
|
||||
@@ -20,35 +50,169 @@ function formatQuestRewards(rewards: { xp?: number, balance?: number }): string
|
||||
const rewardStr: string[] = [];
|
||||
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
|
||||
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 {
|
||||
return completedAt ? "✅ Completed" : "📝 In Progress";
|
||||
function renderProgressBar(current: number, total: number, size: number = 10): string {
|
||||
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 {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("📜 Quest Log")
|
||||
.setColor(0x3498db); // Blue
|
||||
export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuilder[] {
|
||||
// Filter to only show in-progress quests (not completed)
|
||||
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
|
||||
|
||||
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 rewardsText = formatQuestRewards(rewards);
|
||||
|
||||
embed.addFields({
|
||||
name: `${entry.quest.name} (${status})`,
|
||||
value: `${entry.quest.description}\n**Rewards:** ${rewardsText}\n**Progress:** ${entry.progress}%`,
|
||||
inline: false
|
||||
});
|
||||
const requirements = entry.quest.requirements as { target?: number };
|
||||
const target = requirements?.target || 1;
|
||||
const progress = entry.progress || 0;
|
||||
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 { terminalService } from "@shared/modules/terminal/terminal.service";
|
||||
|
||||
export const schedulerService = {
|
||||
start: () => {
|
||||
@@ -10,7 +11,6 @@ export const schedulerService = {
|
||||
}, 60 * 1000);
|
||||
|
||||
// 2. Terminal Update Loop (every 60s)
|
||||
const { terminalService } = require("@shared/modules/terminal/terminal.service");
|
||||
setInterval(() => {
|
||||
terminalService.update();
|
||||
}, 60 * 1000);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { tradeService } from "@shared/modules/trade/trade.service";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view";
|
||||
|
||||
|
||||
|
||||
116
bot/modules/trivia/README.md
Normal file
116
bot/modules/trivia/README.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Trivia - Components v2 Implementation
|
||||
|
||||
This trivia feature uses **Discord Components v2** for a premium visual experience.
|
||||
|
||||
## 🎨 Visual Features
|
||||
|
||||
### **Container with Accent Colors**
|
||||
Each trivia question is displayed in a Container with a colored accent bar that changes based on difficulty:
|
||||
- **🟢 Easy**: Green accent bar (`0x57F287`)
|
||||
- **🟡 Medium**: Yellow accent bar (`0xFEE75C`)
|
||||
- **🔴 Hard**: Red accent bar (`0xED4245`)
|
||||
|
||||
### **Modern Layout Components**
|
||||
- **TextDisplay** - Rich markdown formatting for question text
|
||||
- **Separator** - Visual spacing between sections
|
||||
- **Container** - Groups all content with difficulty-based styling
|
||||
|
||||
### **Interactive Features**
|
||||
✅ **Give Up Button** - Players can forfeit if they're unsure
|
||||
✅ **Disabled Answer Buttons** - After answering, buttons show:
|
||||
- ✅ Green for correct answer
|
||||
- ❌ Red for user's incorrect answer
|
||||
- Gray for other options
|
||||
|
||||
✅ **Time Display** - Shows both relative time (`in 30s`) and seconds remaining
|
||||
✅ **Stakes Preview** - Clear display: `50 AU ➜ 100 AU`
|
||||
|
||||
## 📁 File Structure
|
||||
|
||||
```
|
||||
bot/modules/trivia/
|
||||
├── trivia.view.ts # Components v2 view functions
|
||||
├── trivia.interaction.ts # Button interaction handler
|
||||
└── README.md # This file
|
||||
|
||||
bot/commands/economy/
|
||||
└── trivia.ts # /trivia slash command
|
||||
```
|
||||
|
||||
## 🔧 Technical Details
|
||||
|
||||
### Components v2 Requirements
|
||||
- Uses `MessageFlags.IsComponentsV2` flag
|
||||
- No `embeds` or `content` fields (uses TextDisplay instead)
|
||||
- Numeric component types:
|
||||
- `1` - Action Row
|
||||
- `2` - Button
|
||||
- `10` - Text Display
|
||||
- `14` - Separator
|
||||
- `17` - Container
|
||||
- Max 40 components per message (vs 5 for legacy)
|
||||
|
||||
### Button Styles
|
||||
- **Secondary (2)**: Gray - Used for answer buttons
|
||||
- **Success (3)**: Green - Used for "True" and correct answers
|
||||
- **Danger (4)**: Red - Used for "False", incorrect answers, and "Give Up"
|
||||
|
||||
## 🎮 User Experience Flow
|
||||
|
||||
1. User runs `/trivia`
|
||||
2. Sees question in a Container with difficulty-based accent color
|
||||
3. Can choose to:
|
||||
- Select an answer (A/B/C/D or True/False)
|
||||
- Give up using the 🏳️ button
|
||||
4. After answering, sees result with:
|
||||
- Disabled buttons showing correct/incorrect answers
|
||||
- Container with result-based accent color (green/red/yellow)
|
||||
- Reward or penalty information
|
||||
|
||||
## 🌟 Visual Examples
|
||||
|
||||
### Question Display
|
||||
```
|
||||
┌─[GREEN]─────────────────────────┐
|
||||
│ # 🎯 Trivia Challenge │
|
||||
│ 🟢 Easy • 📚 Geography │
|
||||
│ ─────────────────────────── │
|
||||
│ ### What is the capital of │
|
||||
│ France? │
|
||||
│ │
|
||||
│ ⏱️ Time: in 30s (30s) │
|
||||
│ 💰 Stakes: 50 AU ➜ 100 AU │
|
||||
│ 👤 Player: Username │
|
||||
└─────────────────────────────────┘
|
||||
[🇦 A: Paris] [🇧 B: London]
|
||||
[🇨 C: Berlin] [🇩 D: Madrid]
|
||||
[🏳️ Give Up]
|
||||
```
|
||||
|
||||
### Result Display (Correct)
|
||||
```
|
||||
┌─[GREEN]─────────────────────────┐
|
||||
│ # 🎉 Correct Answer! │
|
||||
│ ### What is the capital of │
|
||||
│ France? │
|
||||
│ ─────────────────────────── │
|
||||
│ ✅ Your answer: Paris │
|
||||
│ │
|
||||
│ 💰 Reward: +100 AU │
|
||||
│ │
|
||||
│ 🏆 Great job! Keep it up! │
|
||||
└─────────────────────────────────┘
|
||||
[✅ A: Paris] [❌ B: London]
|
||||
[❌ C: Berlin] [❌ D: Madrid]
|
||||
(all buttons disabled)
|
||||
```
|
||||
|
||||
## 🚀 Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
- [ ] Thumbnail images based on trivia category
|
||||
- [ ] Progress bar for time remaining
|
||||
- [ ] Streak counter display
|
||||
- [ ] Category-specific accent colors
|
||||
- [ ] Media Gallery for image-based questions
|
||||
- [ ] Leaderboard integration in results
|
||||
129
bot/modules/trivia/trivia.interaction.ts
Normal file
129
bot/modules/trivia/trivia.interaction.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { ButtonInteraction } from "discord.js";
|
||||
import { triviaService } from "@shared/modules/trivia/trivia.service";
|
||||
import { getTriviaResultView, getTriviaTimeoutView } from "./trivia.view";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
export async function handleTriviaInteraction(interaction: ButtonInteraction) {
|
||||
const parts = interaction.customId.split('_');
|
||||
|
||||
// Check for "Give Up" button
|
||||
if (parts.length >= 3 && parts[0] === 'trivia' && parts[1] === 'giveup') {
|
||||
const sessionId = `${parts[2]}_${parts[3]}`;
|
||||
const session = triviaService.getSession(sessionId);
|
||||
|
||||
if (!session) {
|
||||
await interaction.reply({
|
||||
content: '❌ This trivia question has expired or already been answered.',
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate ownership
|
||||
if (session.userId !== interaction.user.id) {
|
||||
await interaction.reply({
|
||||
content: '❌ This isn\'t your trivia question!',
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.deferUpdate();
|
||||
|
||||
// Process as incorrect (user gave up)
|
||||
const result = await triviaService.submitAnswer(sessionId, interaction.user.id, false);
|
||||
|
||||
// Show timeout view (since they gave up)
|
||||
const { components, flags } = getTriviaTimeoutView(
|
||||
session.question.question,
|
||||
session.question.correctAnswer,
|
||||
session.allAnswers,
|
||||
session.entryFee
|
||||
);
|
||||
|
||||
await interaction.editReply({
|
||||
components,
|
||||
flags
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle answer button
|
||||
if (parts.length < 5 || parts[0] !== 'trivia' || parts[1] !== 'answer') {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = `${parts[2]}_${parts[3]}`;
|
||||
const answerIndexStr = parts[4];
|
||||
|
||||
if (!answerIndexStr) {
|
||||
throw new UserError('Invalid answer format.');
|
||||
}
|
||||
|
||||
const answerIndex = parseInt(answerIndexStr);
|
||||
|
||||
// Get session BEFORE deferring to check ownership
|
||||
const session = triviaService.getSession(sessionId);
|
||||
|
||||
if (!session) {
|
||||
// Session doesn't exist or expired
|
||||
await interaction.reply({
|
||||
content: '❌ This trivia question has expired or already been answered.',
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate ownership BEFORE deferring
|
||||
if (session.userId !== interaction.user.id) {
|
||||
// Wrong user trying to answer - send ephemeral error
|
||||
await interaction.reply({
|
||||
content: '❌ This isn\'t your trivia question! Use `/trivia` to start your own game.',
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Only defer if ownership is valid
|
||||
await interaction.deferUpdate();
|
||||
|
||||
// Check timeout
|
||||
if (new Date() > session.expiresAt) {
|
||||
const { components, flags } = getTriviaTimeoutView(
|
||||
session.question.question,
|
||||
session.question.correctAnswer,
|
||||
session.allAnswers,
|
||||
session.entryFee
|
||||
);
|
||||
|
||||
await interaction.editReply({
|
||||
components,
|
||||
flags
|
||||
});
|
||||
|
||||
// Clean up session
|
||||
await triviaService.submitAnswer(sessionId, interaction.user.id, false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if correct
|
||||
const isCorrect = answerIndex === session.correctIndex;
|
||||
const userAnswer = session.allAnswers[answerIndex];
|
||||
|
||||
// Process result
|
||||
const result = await triviaService.submitAnswer(sessionId, interaction.user.id, isCorrect);
|
||||
|
||||
// Update message with enhanced visual feedback
|
||||
const { components, flags } = getTriviaResultView(
|
||||
result,
|
||||
session.question.question,
|
||||
userAnswer,
|
||||
session.allAnswers,
|
||||
session.entryFee
|
||||
);
|
||||
|
||||
await interaction.editReply({
|
||||
components,
|
||||
flags
|
||||
});
|
||||
}
|
||||
336
bot/modules/trivia/trivia.view.ts
Normal file
336
bot/modules/trivia/trivia.view.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import { MessageFlags } from "discord.js";
|
||||
import type { TriviaSession, TriviaResult } from "@shared/modules/trivia/trivia.service";
|
||||
|
||||
/**
|
||||
* Get color based on difficulty level
|
||||
*/
|
||||
function getDifficultyColor(difficulty: string): number {
|
||||
switch (difficulty.toLowerCase()) {
|
||||
case 'easy':
|
||||
return 0x57F287; // Green
|
||||
case 'medium':
|
||||
return 0xFEE75C; // Yellow
|
||||
case 'hard':
|
||||
return 0xED4245; // Red
|
||||
default:
|
||||
return 0x5865F2; // Blurple
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get emoji for difficulty level
|
||||
*/
|
||||
function getDifficultyEmoji(difficulty: string): string {
|
||||
switch (difficulty.toLowerCase()) {
|
||||
case 'easy':
|
||||
return '🟢';
|
||||
case 'medium':
|
||||
return '🟡';
|
||||
case 'hard':
|
||||
return '🔴';
|
||||
default:
|
||||
return '⭐';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Components v2 message for a trivia question
|
||||
*/
|
||||
export function getTriviaQuestionView(session: TriviaSession, username: string): {
|
||||
components: any[];
|
||||
flags: number;
|
||||
} {
|
||||
const { question, allAnswers, entryFee, potentialReward, expiresAt, sessionId } = session;
|
||||
|
||||
// Calculate time remaining
|
||||
const now = Date.now();
|
||||
const timeLeft = Math.max(0, expiresAt.getTime() - now);
|
||||
const secondsLeft = Math.floor(timeLeft / 1000);
|
||||
|
||||
const difficultyEmoji = getDifficultyEmoji(question.difficulty);
|
||||
const difficultyText = question.difficulty.charAt(0).toUpperCase() + question.difficulty.slice(1);
|
||||
const accentColor = getDifficultyColor(question.difficulty);
|
||||
|
||||
const components: any[] = [];
|
||||
|
||||
// Main Container with difficulty accent color
|
||||
components.push({
|
||||
type: 17, // Container
|
||||
accent_color: accentColor,
|
||||
components: [
|
||||
// Title and metadata section
|
||||
{
|
||||
type: 10, // Text Display
|
||||
content: `# 🎯 Trivia Challenge\n**${difficultyEmoji} ${difficultyText}** • 📚 ${question.category}`
|
||||
},
|
||||
// Separator
|
||||
{
|
||||
type: 14, // Separator
|
||||
spacing: 1,
|
||||
divider: true
|
||||
},
|
||||
// Question
|
||||
{
|
||||
type: 10, // Text Display
|
||||
content: `### ${question.question}`
|
||||
},
|
||||
// Stats section
|
||||
{
|
||||
type: 14, // Separator
|
||||
spacing: 1,
|
||||
divider: false
|
||||
},
|
||||
{
|
||||
type: 10, // Text Display
|
||||
content: `⏱️ **Time:** <t:${Math.floor(expiresAt.getTime() / 1000)}:R> (${secondsLeft}s)\n💰 **Stakes:** ${entryFee} AU ➜ ${potentialReward} AU\n👤 **Player:** ${username}`
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Answer buttons
|
||||
if (question.type === 'boolean') {
|
||||
const trueIndex = allAnswers.indexOf('True');
|
||||
const falseIndex = allAnswers.indexOf('False');
|
||||
|
||||
components.push({
|
||||
type: 1, // Action Row
|
||||
components: [
|
||||
{
|
||||
type: 2, // Button
|
||||
custom_id: `trivia_answer_${sessionId}_${trueIndex}`,
|
||||
label: 'True',
|
||||
style: 3, // Success
|
||||
emoji: { name: '✅' }
|
||||
},
|
||||
{
|
||||
type: 2, // Button
|
||||
custom_id: `trivia_answer_${sessionId}_${falseIndex}`,
|
||||
label: 'False',
|
||||
style: 4, // Danger
|
||||
emoji: { name: '❌' }
|
||||
}
|
||||
]
|
||||
});
|
||||
} else {
|
||||
const labels = ['A', 'B', 'C', 'D'];
|
||||
const emojis = ['🇦', '🇧', '🇨', '🇩'];
|
||||
|
||||
const buttonRow: any = {
|
||||
type: 1, // Action Row
|
||||
components: []
|
||||
};
|
||||
|
||||
for (let i = 0; i < allAnswers.length && i < 4; i++) {
|
||||
const label = labels[i];
|
||||
const emoji = emojis[i];
|
||||
const answer = allAnswers[i];
|
||||
|
||||
if (!label || !emoji || !answer) continue;
|
||||
|
||||
buttonRow.components.push({
|
||||
type: 2, // Button
|
||||
custom_id: `trivia_answer_${sessionId}_${i}`,
|
||||
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
|
||||
style: 2, // Secondary
|
||||
emoji: { name: emoji }
|
||||
});
|
||||
}
|
||||
|
||||
components.push(buttonRow);
|
||||
}
|
||||
|
||||
// Give Up button in separate row
|
||||
components.push({
|
||||
type: 1, // Action Row
|
||||
components: [
|
||||
{
|
||||
type: 2, // Button
|
||||
custom_id: `trivia_giveup_${sessionId}`,
|
||||
label: 'Give Up',
|
||||
style: 4, // Danger
|
||||
emoji: { name: '🏳️' }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return {
|
||||
components,
|
||||
flags: MessageFlags.IsComponentsV2
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Components v2 result message
|
||||
*/
|
||||
export function getTriviaResultView(
|
||||
result: TriviaResult,
|
||||
question: string,
|
||||
userAnswer?: string,
|
||||
allAnswers?: string[],
|
||||
entryFee: bigint = 0n
|
||||
): {
|
||||
components: any[];
|
||||
flags: number;
|
||||
} {
|
||||
const { correct, reward, correctAnswer } = result;
|
||||
const components: any[] = [];
|
||||
|
||||
if (correct) {
|
||||
// Success container
|
||||
components.push({
|
||||
type: 17, // Container
|
||||
accent_color: 0x57F287, // Green
|
||||
components: [
|
||||
{
|
||||
type: 10, // Text Display
|
||||
content: `# 🎉 Correct Answer!\n### ${question}`
|
||||
},
|
||||
{
|
||||
type: 14, // Separator
|
||||
spacing: 1,
|
||||
divider: true
|
||||
},
|
||||
{
|
||||
type: 10, // Text Display
|
||||
content: `✅ **Your answer:** ${correctAnswer}\n\n💰 **Reward:** +${reward} AU\n\n🏆 Great job! Keep it up!`
|
||||
}
|
||||
]
|
||||
});
|
||||
} else {
|
||||
const answerDisplay = userAnswer
|
||||
? `❌ **Your answer:** ${userAnswer}\n✅ **Correct answer:** ${correctAnswer}`
|
||||
: `✅ **Correct answer:** ${correctAnswer}`;
|
||||
|
||||
// Error container
|
||||
components.push({
|
||||
type: 17, // Container
|
||||
accent_color: 0xED4245, // Red
|
||||
components: [
|
||||
{
|
||||
type: 10, // Text Display
|
||||
content: `# ❌ Incorrect Answer\n### ${question}`
|
||||
},
|
||||
{
|
||||
type: 14, // Separator
|
||||
spacing: 1,
|
||||
divider: true
|
||||
},
|
||||
{
|
||||
type: 10, // Text Display
|
||||
content: `${answerDisplay}\n\n💸 **Entry fee lost:** ${entryFee} AU\n\n📚 Better luck next time!`
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Show disabled buttons with visual feedback
|
||||
if (allAnswers && allAnswers.length > 0) {
|
||||
const buttonRow: any = {
|
||||
type: 1, // Action Row
|
||||
components: []
|
||||
};
|
||||
|
||||
const labels = ['A', 'B', 'C', 'D'];
|
||||
const emojis = ['🇦', '🇧', '🇨', '🇩'];
|
||||
|
||||
for (let i = 0; i < allAnswers.length && i < 4; i++) {
|
||||
const label = labels[i];
|
||||
const emoji = emojis[i];
|
||||
const answer = allAnswers[i];
|
||||
|
||||
if (!label || !emoji || !answer) continue;
|
||||
|
||||
const isCorrect = answer === correctAnswer;
|
||||
const wasUserAnswer = answer === userAnswer;
|
||||
|
||||
buttonRow.components.push({
|
||||
type: 2, // Button
|
||||
custom_id: `trivia_result_${i}`,
|
||||
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
|
||||
style: isCorrect ? 3 : wasUserAnswer ? 4 : 2, // Success : Danger : Secondary
|
||||
emoji: { name: isCorrect ? '✅' : wasUserAnswer ? '❌' : emoji },
|
||||
disabled: true
|
||||
});
|
||||
}
|
||||
|
||||
components.push(buttonRow);
|
||||
}
|
||||
|
||||
return {
|
||||
components,
|
||||
flags: MessageFlags.IsComponentsV2
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Components v2 timeout message
|
||||
*/
|
||||
export function getTriviaTimeoutView(
|
||||
question: string,
|
||||
correctAnswer: string,
|
||||
allAnswers?: string[],
|
||||
entryFee: bigint = 0n
|
||||
): {
|
||||
components: any[];
|
||||
flags: number;
|
||||
} {
|
||||
const components: any[] = [];
|
||||
|
||||
// Timeout container
|
||||
components.push({
|
||||
type: 17, // Container
|
||||
accent_color: 0xFEE75C, // Yellow
|
||||
components: [
|
||||
{
|
||||
type: 10, // Text Display
|
||||
content: `# ⏱️ Time's Up!\n### ${question}`
|
||||
},
|
||||
{
|
||||
type: 14, // Separator
|
||||
spacing: 1,
|
||||
divider: true
|
||||
},
|
||||
{
|
||||
type: 10, // Text Display
|
||||
content: `⏰ **You ran out of time!**\n✅ **Correct answer:** ${correctAnswer}\n\n💸 **Entry fee lost:** ${entryFee} AU\n\n⚡ Be faster next time!`
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Show disabled buttons with correct answer highlighted
|
||||
if (allAnswers && allAnswers.length > 0) {
|
||||
const buttonRow: any = {
|
||||
type: 1, // Action Row
|
||||
components: []
|
||||
};
|
||||
|
||||
const labels = ['A', 'B', 'C', 'D'];
|
||||
const emojis = ['🇦', '🇧', '🇨', '🇩'];
|
||||
|
||||
for (let i = 0; i < allAnswers.length && i < 4; i++) {
|
||||
const label = labels[i];
|
||||
const emoji = emojis[i];
|
||||
const answer = allAnswers[i];
|
||||
|
||||
if (!label || !emoji || !answer) continue;
|
||||
|
||||
const isCorrect = answer === correctAnswer;
|
||||
|
||||
buttonRow.components.push({
|
||||
type: 2, // Button
|
||||
custom_id: `trivia_timeout_${i}`,
|
||||
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
|
||||
style: isCorrect ? 3 : 2, // Success : Secondary
|
||||
emoji: { name: isCorrect ? '✅' : emoji },
|
||||
disabled: true
|
||||
});
|
||||
}
|
||||
|
||||
components.push(buttonRow);
|
||||
}
|
||||
|
||||
return {
|
||||
components,
|
||||
flags: MessageFlags.IsComponentsV2
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ButtonInteraction, MessageFlags } from "discord.js";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
|
||||
import { getEnrollmentSuccessMessage } from "./enrollment.view";
|
||||
import { classService } from "@shared/modules/class/class.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
||||
|
||||
export async function handleEnrollmentInteraction(interaction: ButtonInteraction) {
|
||||
@@ -11,7 +11,8 @@ export async function handleEnrollmentInteraction(interaction: ButtonInteraction
|
||||
throw new UserError("This action can only be performed in a server.");
|
||||
}
|
||||
|
||||
const { studentRole, visitorRole } = config;
|
||||
const guildConfig = await getGuildConfig(interaction.guildId);
|
||||
const { studentRole, visitorRole, welcomeChannelId, welcomeMessage } = guildConfig;
|
||||
|
||||
if (!studentRole || !visitorRole) {
|
||||
throw new UserError("No student or visitor role configured for enrollment.");
|
||||
@@ -67,10 +68,10 @@ export async function handleEnrollmentInteraction(interaction: ButtonInteraction
|
||||
});
|
||||
|
||||
// 5. Send Welcome Message (if configured)
|
||||
if (config.welcomeChannelId) {
|
||||
const welcomeChannel = interaction.guild.channels.cache.get(config.welcomeChannelId);
|
||||
if (welcomeChannelId) {
|
||||
const welcomeChannel = interaction.guild.channels.cache.get(welcomeChannelId);
|
||||
if (welcomeChannel && welcomeChannel.isTextBased()) {
|
||||
const rawMessage = config.welcomeMessage || "Welcome to Aurora, {user}! You have been enrolled as a **{class}**.";
|
||||
const rawMessage = welcomeMessage || "Welcome to Aurora, {user}! You have been enrolled as a **{class}**.";
|
||||
|
||||
const processedMessage = rawMessage
|
||||
.replace(/{user}/g, member.toString())
|
||||
|
||||
44
bun.lock
44
bun.lock
@@ -5,19 +5,19 @@
|
||||
"": {
|
||||
"name": "app",
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "^0.1.84",
|
||||
"@napi-rs/canvas": "^0.1.89",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"zod": "^4.1.13",
|
||||
"postgres": "^3.4.8",
|
||||
"zod": "^4.3.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"drizzle-kit": "^0.31.7",
|
||||
"postgres": "^3.4.7",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -92,27 +92,29 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.84", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.84", "@napi-rs/canvas-darwin-arm64": "0.1.84", "@napi-rs/canvas-darwin-x64": "0.1.84", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.84", "@napi-rs/canvas-linux-arm64-gnu": "0.1.84", "@napi-rs/canvas-linux-arm64-musl": "0.1.84", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.84", "@napi-rs/canvas-linux-x64-gnu": "0.1.84", "@napi-rs/canvas-linux-x64-musl": "0.1.84", "@napi-rs/canvas-win32-x64-msvc": "0.1.84" } }, "sha512-88FTNFs4uuiFKP0tUrPsEXhpe9dg7za9ILZJE08pGdUveMIDeana1zwfVkqRHJDPJFAmGY3dXmJ99dzsy57YnA=="],
|
||||
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.89", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.89", "@napi-rs/canvas-darwin-arm64": "0.1.89", "@napi-rs/canvas-darwin-x64": "0.1.89", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.89", "@napi-rs/canvas-linux-arm64-gnu": "0.1.89", "@napi-rs/canvas-linux-arm64-musl": "0.1.89", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.89", "@napi-rs/canvas-linux-x64-gnu": "0.1.89", "@napi-rs/canvas-linux-x64-musl": "0.1.89", "@napi-rs/canvas-win32-arm64-msvc": "0.1.89", "@napi-rs/canvas-win32-x64-msvc": "0.1.89" } }, "sha512-7GjmkMirJHejeALCqUnZY3QwID7bbumOiLrqq2LKgxrdjdmxWQBTc6rcASa2u8wuWrH7qo4/4n/VNrOwCoKlKg=="],
|
||||
|
||||
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.84", "", { "os": "android", "cpu": "arm64" }, "sha512-pdvuqvj3qtwVryqgpAGornJLV6Ezpk39V6wT4JCnRVGy8I3Tk1au8qOalFGrx/r0Ig87hWslysPpHBxVpBMIww=="],
|
||||
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.89", "", { "os": "android", "cpu": "arm64" }, "sha512-CXxQTXsjtQqKGENS8Ejv9pZOFJhOPIl2goenS+aU8dY4DygvkyagDhy/I07D1YLqrDtPvLEX5zZHt8qUdnuIpQ=="],
|
||||
|
||||
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.84", "", { "os": "darwin", "cpu": "arm64" }, "sha512-A8IND3Hnv0R6abc6qCcCaOCujTLMmGxtucMTZ5vbQUrEN/scxi378MyTLtyWg+MRr6bwQJ6v/orqMS9datIcww=="],
|
||||
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.89", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k29cR/Zl20WLYM7M8YePevRu2VQRaKcRedYr1V/8FFHkyIQ8kShEV+MPoPGi+znvmd17Eqjy2Pk2F2kpM2umVg=="],
|
||||
|
||||
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.84", "", { "os": "darwin", "cpu": "x64" }, "sha512-AUW45lJhYWwnA74LaNeqhvqYKK/2hNnBBBl03KRdqeCD4tKneUSrxUqIv8d22CBweOvrAASyKN3W87WO2zEr/A=="],
|
||||
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.89", "", { "os": "darwin", "cpu": "x64" }, "sha512-iUragqhBrA5FqU13pkhYBDbUD1WEAIlT8R2+fj6xHICY2nemzwMUI8OENDhRh7zuL06YDcRwENbjAVxOmaX9jg=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.84", "", { "os": "linux", "cpu": "arm" }, "sha512-8zs5ZqOrdgs4FioTxSBrkl/wHZB56bJNBqaIsfPL4ZkEQCinOkrFF7xIcXiHiKp93J3wUtbIzeVrhTIaWwqk+A=="],
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.89", "", { "os": "linux", "cpu": "arm" }, "sha512-y3SM9sfDWasY58ftoaI09YBFm35Ig8tosZqgahLJ2WGqawCusGNPV9P0/4PsrLOCZqGg629WxexQMY25n7zcvA=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.84", "", { "os": "linux", "cpu": "arm64" }, "sha512-i204vtowOglJUpbAFWU5mqsJgH0lVpNk/Ml4mQtB4Lndd86oF+Otr6Mr5KQnZHqYGhlSIKiU2SYnUbhO28zGQA=="],
|
||||
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.89", "", { "os": "linux", "cpu": "arm64" }, "sha512-NEoF9y8xq5fX8HG8aZunBom1ILdTwt7ayBzSBIwrmitk7snj4W6Fz/yN/ZOmlM1iyzHDNX5Xn0n+VgWCF8BEdA=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.84", "", { "os": "linux", "cpu": "arm64" }, "sha512-VyZq0EEw+OILnWk7G3ZgLLPaz1ERaPP++jLjeyLMbFOF+Tr4zHzWKiKDsEV/cT7btLPZbVoR3VX+T9/QubnURQ=="],
|
||||
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.89", "", { "os": "linux", "cpu": "arm64" }, "sha512-UQQkIEzV12/l60j1ziMjZ+mtodICNUbrd205uAhbyTw0t60CrC/EsKb5/aJWGq1wM0agvcgZV72JJCKfLS6+4w=="],
|
||||
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.84", "", { "os": "linux", "cpu": "none" }, "sha512-PSMTh8DiThvLRsbtc/a065I/ceZk17EXAATv9uNvHgkgo7wdEfTh2C3aveNkBMGByVO3tvnvD5v/YFtZL07cIg=="],
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.89", "", { "os": "linux", "cpu": "none" }, "sha512-1/VmEoFaIO6ONeeEMGoWF17wOYZOl5hxDC1ios2Bkz/oQjbJJ8DY/X22vWTmvuUKWWhBVlo63pxLGZbjJU/heA=="],
|
||||
|
||||
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.84", "", { "os": "linux", "cpu": "x64" }, "sha512-N1GY3noO1oqgEo3rYQIwY44kfM11vA0lDbN0orTOHfCSUZTUyiYCY0nZ197QMahZBm1aR/vYgsWpV74MMMDuNA=="],
|
||||
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.89", "", { "os": "linux", "cpu": "x64" }, "sha512-ebLuqkCuaPIkKgKH9q4+pqWi1tkPOfiTk5PM1LKR1tB9iO9sFNVSIgwEp+SJreTSbA2DK5rW8lQXiN78SjtcvA=="],
|
||||
|
||||
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.84", "", { "os": "linux", "cpu": "x64" }, "sha512-vUZmua6ADqTWyHyei81aXIt9wp0yjeNwTH0KdhdeoBb6azHmFR8uKTukZMXfLCC3bnsW0t4lW7K78KNMknmtjg=="],
|
||||
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.89", "", { "os": "linux", "cpu": "x64" }, "sha512-w+5qxHzplvA4BkHhCaizNMLLXiI+CfP84YhpHm/PqMub4u8J0uOAv+aaGv40rYEYra5hHRWr9LUd6cfW32o9/A=="],
|
||||
|
||||
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.84", "", { "os": "win32", "cpu": "x64" }, "sha512-YSs8ncurc1xzegUMNnQUTYrdrAuaXdPMOa+iYYyAxydOtg0ppV386hyYMsy00Yip1NlTgLCseRG4sHSnjQx6og=="],
|
||||
"@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.89", "", { "os": "win32", "cpu": "arm64" }, "sha512-DmyXa5lJHcjOsDC78BM3bnEECqbK3xASVMrKfvtT/7S7Z8NGQOugvu+L7b41V6cexCd34mBWgMOsjoEBceeB1Q=="],
|
||||
|
||||
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.89", "", { "os": "win32", "cpu": "x64" }, "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA=="],
|
||||
|
||||
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
|
||||
|
||||
@@ -120,7 +122,7 @@
|
||||
|
||||
"@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="],
|
||||
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||
|
||||
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
||||
|
||||
@@ -130,7 +132,7 @@
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
|
||||
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
@@ -140,7 +142,7 @@
|
||||
|
||||
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@0.31.7", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-hOzRGSdyKIU4FcTSFYGKdXEjFsncVwHZ43gY3WU5Bz9j5Iadp6Rh6hxLSQ1IWXpKLBKt/d5y1cpSPcV+FcoQ1A=="],
|
||||
"drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="],
|
||||
|
||||
@@ -160,7 +162,7 @@
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
|
||||
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
@@ -180,7 +182,7 @@
|
||||
|
||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="],
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||
|
||||
|
||||
10
docker-compose.override.yml.linux
Normal file
10
docker-compose.override.yml.linux
Normal file
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
db:
|
||||
volumes:
|
||||
# Override the bind mount with a named volume
|
||||
# Docker handles permissions automatically for named volumes
|
||||
- db_data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
name: aurora_db_data
|
||||
113
docker-compose.prod.yml
Normal file
113
docker-compose.prod.yml
Normal file
@@ -0,0 +1,113 @@
|
||||
# Production Docker Compose Configuration
|
||||
# Usage: docker compose -f docker-compose.prod.yml up -d
|
||||
#
|
||||
# IMPORTANT: Database data is preserved in ./shared/db/data volume
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
container_name: aurora_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=${DB_USER}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- POSTGRES_DB=${DB_NAME}
|
||||
volumes:
|
||||
# Database data - persisted across container rebuilds
|
||||
- ./shared/db/data:/var/lib/postgresql/data
|
||||
- ./shared/db/log:/var/log/postgresql
|
||||
networks:
|
||||
- internal
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
# Security: limit resources
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
|
||||
app:
|
||||
container_name: aurora_app
|
||||
restart: unless-stopped
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.prod
|
||||
target: production
|
||||
image: aurora-app:latest
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
|
||||
working_dir: /app
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- HOST=0.0.0.0
|
||||
- DB_USER=${DB_USER}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- DB_NAME=${DB_NAME}
|
||||
- DB_PORT=5432
|
||||
- DB_HOST=db
|
||||
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN}
|
||||
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
|
||||
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
||||
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
||||
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
networks:
|
||||
- internal
|
||||
- web
|
||||
# Security: limit resources
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
# Logging configuration
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
studio:
|
||||
container_name: aurora_studio
|
||||
image: aurora-app:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "127.0.0.1:4983:4983"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DB_USER=${DB_USER}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- DB_NAME=${DB_NAME}
|
||||
- DB_PORT=5432
|
||||
- DB_HOST=db
|
||||
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
||||
networks:
|
||||
- internal
|
||||
- web
|
||||
command: bun run db:studio
|
||||
healthcheck:
|
||||
test: [ "CMD", "bun", "-e", "fetch('http://localhost:4983').then(r => process.exit(0)).catch(() => process.exit(1))" ]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
|
||||
networks:
|
||||
internal:
|
||||
driver: bridge
|
||||
internal: true # No external access - DB isolated
|
||||
web:
|
||||
driver: bridge # App accessible from host (via reverse proxy)
|
||||
@@ -7,13 +7,14 @@ services:
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- POSTGRES_DB=${DB_NAME}
|
||||
# Uncomment to access DB from host (for debugging/drizzle-kit studio)
|
||||
# ports:
|
||||
# - "127.0.0.1:${DB_PORT}:5432"
|
||||
ports:
|
||||
- "127.0.0.1:${DB_PORT}:5432"
|
||||
volumes:
|
||||
# Host-mounted to preserve existing VPS data
|
||||
- ./shared/db/data:/var/lib/postgresql/data
|
||||
- ./shared/db/log:/var/log/postgresql
|
||||
networks:
|
||||
- internal
|
||||
- web
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}" ]
|
||||
interval: 5s
|
||||
@@ -23,17 +24,18 @@ services:
|
||||
app:
|
||||
container_name: aurora_app
|
||||
restart: unless-stopped
|
||||
image: aurora-app
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: development # Use development stage
|
||||
working_dir: /app
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
volumes:
|
||||
# Mount source code for hot reloading
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
- /app/web/node_modules
|
||||
# Use named volumes for node_modules (prevents host overwrite + caches deps)
|
||||
- app_node_modules:/app/node_modules
|
||||
environment:
|
||||
- HOST=0.0.0.0
|
||||
- DB_USER=${DB_USER}
|
||||
@@ -61,30 +63,21 @@ services:
|
||||
|
||||
studio:
|
||||
container_name: aurora_studio
|
||||
image: aurora-app
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
working_dir: /app
|
||||
ports:
|
||||
# Reuse the same built image as app (no duplicate builds!)
|
||||
extends:
|
||||
service: app
|
||||
# Clear inherited ports from app and only expose studio port
|
||||
ports: !override
|
||||
- "127.0.0.1:4983:4983"
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
- /app/web/node_modules
|
||||
environment:
|
||||
- DB_USER=${DB_USER}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- DB_NAME=${DB_NAME}
|
||||
- DB_PORT=5432
|
||||
- DB_HOST=db
|
||||
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- internal
|
||||
- web
|
||||
# Override healthcheck since studio doesn't serve on port 3000
|
||||
healthcheck:
|
||||
test: [ "CMD", "bun", "-e", "fetch('http://localhost:4983').then(r => process.exit(0)).catch(() => process.exit(1))" ]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
# Disable restart for studio (it's an on-demand tool)
|
||||
restart: "no"
|
||||
command: [ "bun", "x", "drizzle-kit", "studio", "--port", "4983", "--host", "0.0.0.0" ]
|
||||
|
||||
networks:
|
||||
@@ -93,3 +86,8 @@ networks:
|
||||
internal: true # No external access
|
||||
web:
|
||||
driver: bridge # Can be accessed from host
|
||||
|
||||
volumes:
|
||||
# Named volumes for node_modules caching
|
||||
app_node_modules:
|
||||
name: aurora_app_node_modules
|
||||
|
||||
492
docs/api.md
Normal file
492
docs/api.md
Normal file
@@ -0,0 +1,492 @@
|
||||
# Aurora API Reference
|
||||
|
||||
REST API server for Aurora bot management. Base URL: `http://localhost:3000`
|
||||
|
||||
## Common Response Formats
|
||||
|
||||
**Success Responses:**
|
||||
- Single resource: `{ ...resource }` or `{ success: true, resource: {...} }`
|
||||
- List operations: `{ items: [...], total: number }`
|
||||
- Mutations: `{ success: true, resource: {...} }`
|
||||
|
||||
**Error Responses:**
|
||||
```json
|
||||
{
|
||||
"error": "Brief error message",
|
||||
"details": "Optional detailed error information"
|
||||
}
|
||||
```
|
||||
|
||||
**HTTP Status Codes:**
|
||||
| Code | Description |
|
||||
|------|-------------|
|
||||
| 200 | Success |
|
||||
| 201 | Created |
|
||||
| 204 | No Content (successful DELETE) |
|
||||
| 400 | Bad Request (validation error) |
|
||||
| 404 | Not Found |
|
||||
| 409 | Conflict (e.g., duplicate name) |
|
||||
| 429 | Too Many Requests |
|
||||
| 500 | Internal Server Error |
|
||||
|
||||
---
|
||||
|
||||
## Health
|
||||
|
||||
### `GET /api/health`
|
||||
Returns server health status.
|
||||
|
||||
**Response:** `{ "status": "ok", "timestamp": 1234567890 }`
|
||||
|
||||
---
|
||||
|
||||
## Items
|
||||
|
||||
### `GET /api/items`
|
||||
List all items with optional filtering.
|
||||
|
||||
| Query Param | Type | Description |
|
||||
|-------------|------|-------------|
|
||||
| `search` | string | Filter by name/description |
|
||||
| `type` | string | Filter by item type |
|
||||
| `rarity` | string | Filter by rarity (C, R, SR, SSR) |
|
||||
| `limit` | number | Max results (default: 100) |
|
||||
| `offset` | number | Pagination offset |
|
||||
|
||||
**Response:** `{ "items": [...], "total": number }`
|
||||
|
||||
### `GET /api/items/:id`
|
||||
Get single item by ID.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Health Potion",
|
||||
"description": "Restores HP",
|
||||
"type": "CONSUMABLE",
|
||||
"rarity": "C",
|
||||
"price": "100",
|
||||
"iconUrl": "/assets/items/1.png",
|
||||
"imageUrl": "/assets/items/1.png",
|
||||
"usageData": { "consume": true, "effects": [] }
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/items`
|
||||
Create new item. Supports JSON or multipart/form-data with image.
|
||||
|
||||
**Body (JSON):**
|
||||
```json
|
||||
{
|
||||
"name": "Health Potion",
|
||||
"description": "Restores HP",
|
||||
"type": "CONSUMABLE",
|
||||
"rarity": "C",
|
||||
"price": "100",
|
||||
"iconUrl": "/assets/items/placeholder.png",
|
||||
"imageUrl": "/assets/items/placeholder.png",
|
||||
"usageData": { "consume": true, "effects": [] }
|
||||
}
|
||||
```
|
||||
|
||||
**Body (Multipart):**
|
||||
- `data`: JSON string with item fields
|
||||
- `image`: Image file (PNG, JPEG, WebP, GIF, max 15MB)
|
||||
|
||||
### `PUT /api/items/:id`
|
||||
Update existing item.
|
||||
|
||||
### `DELETE /api/items/:id`
|
||||
Delete item and associated asset.
|
||||
|
||||
### `POST /api/items/:id/icon`
|
||||
Upload/replace item image. Accepts multipart/form-data with `image` field.
|
||||
|
||||
---
|
||||
|
||||
## Users
|
||||
|
||||
### `GET /api/users`
|
||||
List all users with optional filtering and sorting.
|
||||
|
||||
| Query Param | Type | Description |
|
||||
|-------------|------|-------------|
|
||||
| `search` | string | Filter by username (partial match) |
|
||||
| `sortBy` | string | Sort field: `balance`, `level`, `xp`, `username` (default: `balance`) |
|
||||
| `sortOrder` | string | Sort order: `asc`, `desc` (default: `desc`) |
|
||||
| `limit` | number | Max results (default: 50) |
|
||||
| `offset` | number | Pagination offset |
|
||||
|
||||
**Response:** `{ "users": [...], "total": number }`
|
||||
|
||||
### `GET /api/users/:id`
|
||||
Get single user by Discord ID.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123456789012345678",
|
||||
"username": "Player1",
|
||||
"balance": "1000",
|
||||
"xp": "500",
|
||||
"level": 5,
|
||||
"dailyStreak": 3,
|
||||
"isActive": true,
|
||||
"classId": "1",
|
||||
"class": { "id": "1", "name": "Warrior", "balance": "5000" },
|
||||
"settings": {},
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-15T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### `PUT /api/users/:id`
|
||||
Update user fields.
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"username": "NewName",
|
||||
"balance": "2000",
|
||||
"xp": "750",
|
||||
"level": 10,
|
||||
"dailyStreak": 5,
|
||||
"classId": "1",
|
||||
"isActive": true,
|
||||
"settings": {}
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/users/:id/inventory`
|
||||
Get user's inventory with item details.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"inventory": [
|
||||
{
|
||||
"userId": "123456789012345678",
|
||||
"itemId": 1,
|
||||
"quantity": "5",
|
||||
"item": { "id": 1, "name": "Health Potion", ... }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/users/:id/inventory`
|
||||
Add item to user inventory.
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"itemId": 1,
|
||||
"quantity": "5"
|
||||
}
|
||||
```
|
||||
|
||||
### `DELETE /api/users/:id/inventory/:itemId`
|
||||
Remove item from user inventory. Use query param `amount` to specify quantity (default: 1).
|
||||
|
||||
| Query Param | Type | Description |
|
||||
|-------------|------|-------------|
|
||||
| `amount` | number | Amount to remove (default: 1) |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Classes
|
||||
|
||||
### `GET /api/classes`
|
||||
List all classes.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"classes": [
|
||||
{ "id": "1", "name": "Warrior", "balance": "5000", "roleId": "123456789" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/classes`
|
||||
Create new class.
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"id": "2",
|
||||
"name": "Mage",
|
||||
"balance": "0",
|
||||
"roleId": "987654321"
|
||||
}
|
||||
```
|
||||
|
||||
### `PUT /api/classes/:id`
|
||||
Update class.
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"name": "Updated Name",
|
||||
"balance": "10000",
|
||||
"roleId": "111222333"
|
||||
}
|
||||
```
|
||||
|
||||
### `DELETE /api/classes/:id`
|
||||
Delete class.
|
||||
|
||||
---
|
||||
|
||||
## Moderation
|
||||
|
||||
### `GET /api/moderation`
|
||||
List moderation cases with optional filtering.
|
||||
|
||||
| Query Param | Type | Description |
|
||||
|-------------|------|-------------|
|
||||
| `userId` | string | Filter by target user ID |
|
||||
| `moderatorId` | string | Filter by moderator ID |
|
||||
| `type` | string | Filter by case type: `warn`, `timeout`, `kick`, `ban`, `note`, `prune` |
|
||||
| `active` | boolean | Filter by active status |
|
||||
| `limit` | number | Max results (default: 50) |
|
||||
| `offset` | number | Pagination offset |
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "1",
|
||||
"caseId": "CASE-0001",
|
||||
"type": "warn",
|
||||
"userId": "123456789",
|
||||
"username": "User1",
|
||||
"moderatorId": "987654321",
|
||||
"moderatorName": "Mod1",
|
||||
"reason": "Spam",
|
||||
"metadata": {},
|
||||
"active": true,
|
||||
"createdAt": "2024-01-15T12:00:00Z",
|
||||
"resolvedAt": null,
|
||||
"resolvedBy": null,
|
||||
"resolvedReason": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/moderation/:caseId`
|
||||
Get single case by case ID (e.g., `CASE-0001`).
|
||||
|
||||
### `POST /api/moderation`
|
||||
Create new moderation case.
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"type": "warn",
|
||||
"userId": "123456789",
|
||||
"username": "User1",
|
||||
"moderatorId": "987654321",
|
||||
"moderatorName": "Mod1",
|
||||
"reason": "Rule violation",
|
||||
"metadata": { "duration": "24h" }
|
||||
}
|
||||
```
|
||||
|
||||
### `PUT /api/moderation/:caseId/clear`
|
||||
Clear/resolve a moderation case.
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"clearedBy": "987654321",
|
||||
"clearedByName": "Mod1",
|
||||
"reason": "Appeal accepted"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Transactions
|
||||
|
||||
### `GET /api/transactions`
|
||||
List economy transactions.
|
||||
|
||||
| Query Param | Type | Description |
|
||||
|-------------|------|-------------|
|
||||
| `userId` | string | Filter by user ID |
|
||||
| `type` | string | Filter by transaction type |
|
||||
| `limit` | number | Max results (default: 50) |
|
||||
| `offset` | number | Pagination offset |
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"transactions": [
|
||||
{
|
||||
"id": "1",
|
||||
"userId": "123456789",
|
||||
"relatedUserId": null,
|
||||
"amount": "100",
|
||||
"type": "DAILY_REWARD",
|
||||
"description": "Daily reward (Streak: 3)",
|
||||
"createdAt": "2024-01-15T12:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Lootdrops
|
||||
|
||||
### `GET /api/lootdrops`
|
||||
List lootdrops (default limit 50, sorted by newest).
|
||||
|
||||
| Query Param | Type | Description |
|
||||
|-------------|------|-------------|
|
||||
| `limit` | number | Max results (default: 50) |
|
||||
|
||||
**Response:** `{ "lootdrops": [...] }`
|
||||
|
||||
### `POST /api/lootdrops`
|
||||
Spawn a lootdrop in a channel.
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"channelId": "1234567890",
|
||||
"amount": 100,
|
||||
"currency": "Gold"
|
||||
}
|
||||
```
|
||||
|
||||
### `DELETE /api/lootdrops/:messageId`
|
||||
Cancel and delete a lootdrop.
|
||||
|
||||
---
|
||||
|
||||
## Quests
|
||||
|
||||
### `GET /api/quests`
|
||||
List all quests.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Daily Login",
|
||||
"description": "Login once",
|
||||
"triggerEvent": "login",
|
||||
"requirements": { "target": 1 },
|
||||
"rewards": { "xp": 50, "balance": 100 }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/quests`
|
||||
Create new quest.
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"name": "Daily Login",
|
||||
"description": "Login once",
|
||||
"triggerEvent": "login",
|
||||
"target": 1,
|
||||
"xpReward": 50,
|
||||
"balanceReward": 100
|
||||
}
|
||||
```
|
||||
|
||||
### `PUT /api/quests/:id`
|
||||
Update quest.
|
||||
|
||||
### `DELETE /api/quests/:id`
|
||||
Delete quest.
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
### `GET /api/settings`
|
||||
Get current bot configuration.
|
||||
|
||||
### `POST /api/settings`
|
||||
Update configuration (partial merge supported).
|
||||
|
||||
### `GET /api/settings/meta`
|
||||
Get Discord metadata (roles, channels, commands).
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"roles": [{ "id": "123", "name": "Admin", "color": "#FF0000" }],
|
||||
"channels": [{ "id": "456", "name": "general", "type": 0 }],
|
||||
"commands": [{ "name": "daily", "category": "economy" }]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Admin Actions
|
||||
|
||||
### `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.
|
||||
|
||||
**Body:** `{ "enabled": true, "reason": "Updating..." }`
|
||||
|
||||
---
|
||||
|
||||
## Stats
|
||||
|
||||
### `GET /api/stats`
|
||||
Get full dashboard statistics.
|
||||
|
||||
### `GET /api/stats/activity`
|
||||
Get activity aggregation (cached 5 min).
|
||||
|
||||
---
|
||||
|
||||
## Assets
|
||||
|
||||
### `GET /assets/items/:filename`
|
||||
Serve item images. Cached 24 hours.
|
||||
|
||||
---
|
||||
|
||||
## WebSocket
|
||||
|
||||
### `ws://localhost:3000/ws`
|
||||
Real-time dashboard updates.
|
||||
|
||||
**Messages:**
|
||||
- `STATS_UPDATE` - Periodic stats broadcast (every 5s when clients connected)
|
||||
- `NEW_EVENT` - Real-time system events
|
||||
- `PING/PONG` - Heartbeat
|
||||
|
||||
**Limits:** Max 10 concurrent connections, 16KB max payload, 60s idle timeout.
|
||||
168
docs/feature-flags.md
Normal file
168
docs/feature-flags.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# Feature Flag System
|
||||
|
||||
The feature flag system enables controlled beta testing of new features in production without requiring a separate test environment.
|
||||
|
||||
## Overview
|
||||
|
||||
Feature flags allow you to:
|
||||
- Test new features with a limited audience before full rollout
|
||||
- Enable/disable features without code changes or redeployment
|
||||
- Control access per guild, user, or role
|
||||
- Eliminate environment drift between test and production
|
||||
|
||||
## Architecture
|
||||
|
||||
### Database Schema
|
||||
|
||||
**`feature_flags` table:**
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | serial | Primary key |
|
||||
| `name` | varchar(100) | Unique flag identifier |
|
||||
| `enabled` | boolean | Whether the flag is active |
|
||||
| `description` | text | Human-readable description |
|
||||
| `created_at` | timestamp | Creation time |
|
||||
| `updated_at` | timestamp | Last update time |
|
||||
|
||||
**`feature_flag_access` table:**
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | serial | Primary key |
|
||||
| `flag_id` | integer | References feature_flags.id |
|
||||
| `guild_id` | bigint | Guild whitelist (nullable) |
|
||||
| `user_id` | bigint | User whitelist (nullable) |
|
||||
| `role_id` | bigint | Role whitelist (nullable) |
|
||||
| `created_at` | timestamp | Creation time |
|
||||
|
||||
### Service Layer
|
||||
|
||||
The `featureFlagsService` (`shared/modules/feature-flags/feature-flags.service.ts`) provides:
|
||||
|
||||
```typescript
|
||||
// Check if a flag is globally enabled
|
||||
await featureFlagsService.isFlagEnabled("trading_system");
|
||||
|
||||
// Check if a user has access to a flagged feature
|
||||
await featureFlagsService.hasAccess("trading_system", {
|
||||
guildId: "123456789",
|
||||
userId: "987654321",
|
||||
memberRoles: ["role1", "role2"]
|
||||
});
|
||||
|
||||
// Create a new feature flag
|
||||
await featureFlagsService.createFlag("new_feature", "Description");
|
||||
|
||||
// Enable/disable a flag
|
||||
await featureFlagsService.setFlagEnabled("new_feature", true);
|
||||
|
||||
// Grant access to users/roles/guilds
|
||||
await featureFlagsService.grantAccess("new_feature", { userId: "123" });
|
||||
await featureFlagsService.grantAccess("new_feature", { roleId: "456" });
|
||||
await featureFlagsService.grantAccess("new_feature", { guildId: "789" });
|
||||
|
||||
// List all flags or access records
|
||||
await featureFlagsService.listFlags();
|
||||
await featureFlagsService.listAccess("new_feature");
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Marking a Command as Beta
|
||||
|
||||
Add `beta: true` to any command definition:
|
||||
|
||||
```typescript
|
||||
export const newFeature = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("newfeature")
|
||||
.setDescription("A new experimental feature"),
|
||||
beta: true, // Marks this command as a beta feature
|
||||
execute: async (interaction) => {
|
||||
// Implementation
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
By default, the command name is used as the feature flag name. To use a custom flag name:
|
||||
|
||||
```typescript
|
||||
export const trade = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("trade")
|
||||
.setDescription("Trade items with another user"),
|
||||
beta: true,
|
||||
featureFlag: "trading_system", // Custom flag name
|
||||
execute: async (interaction) => {
|
||||
// Implementation
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Access Control Flow
|
||||
|
||||
When a user attempts to use a beta command:
|
||||
|
||||
1. **Check if flag exists and is enabled** - Returns false if flag doesn't exist or is disabled
|
||||
2. **Check guild whitelist** - User's guild has access if `guild_id` matches
|
||||
3. **Check user whitelist** - User has access if `user_id` matches
|
||||
4. **Check role whitelist** - User has access if any of their roles match
|
||||
|
||||
If none of these conditions are met, the user sees:
|
||||
> **Beta Feature**
|
||||
> This feature is currently in beta testing and not available to all users. Stay tuned for the official release!
|
||||
|
||||
## Admin Commands
|
||||
|
||||
The `/featureflags` command (Administrator only) provides full management:
|
||||
|
||||
### Subcommands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/featureflags list` | List all feature flags with status |
|
||||
| `/featureflags create <name> [description]` | Create a new flag (disabled by default) |
|
||||
| `/featureflags delete <name>` | Delete a flag and all access records |
|
||||
| `/featureflags enable <name>` | Enable a flag globally |
|
||||
| `/featureflags disable <name>` | Disable a flag globally |
|
||||
| `/featureflags grant <name> [user\|role]` | Grant access to a user or role |
|
||||
| `/featureflags revoke <id>` | Revoke access by record ID |
|
||||
| `/featureflags access <name>` | List all access records for a flag |
|
||||
|
||||
### Example Workflow
|
||||
|
||||
```
|
||||
1. Create the flag:
|
||||
/featureflags create trading_system "Item trading between users"
|
||||
|
||||
2. Grant access to beta testers:
|
||||
/featureflags grant trading_system user:@beta_tester
|
||||
/featureflags grant trading_system role:@Beta Testers
|
||||
|
||||
3. Enable the flag:
|
||||
/featureflags enable trading_system
|
||||
|
||||
4. View access list:
|
||||
/featureflags access trading_system
|
||||
|
||||
5. When ready for full release:
|
||||
- Remove beta: true from the command
|
||||
- Delete the flag: /featureflags delete trading_system
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Descriptive Names**: Use snake_case names that clearly describe the feature
|
||||
2. **Document Flags**: Always add a description when creating flags
|
||||
3. **Role-Based Access**: Prefer role-based access over user-based for easier management
|
||||
4. **Clean Up**: Delete flags after features are fully released
|
||||
5. **Testing**: Always test with a small group before wider rollout
|
||||
|
||||
## Implementation Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `shared/db/schema/feature-flags.ts` | Database schema |
|
||||
| `shared/modules/feature-flags/feature-flags.service.ts` | Service layer |
|
||||
| `shared/lib/types.ts` | Command interface with beta properties |
|
||||
| `bot/lib/handlers/CommandHandler.ts` | Beta access check |
|
||||
| `bot/commands/admin/featureflags.ts` | Admin command |
|
||||
199
docs/guild-settings.md
Normal file
199
docs/guild-settings.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# Guild Settings System
|
||||
|
||||
The guild settings system enables per-guild configuration stored in the database, eliminating environment-specific config files and enabling runtime updates without redeployment.
|
||||
|
||||
## Overview
|
||||
|
||||
Guild settings allow you to:
|
||||
- Store per-guild configuration in the database
|
||||
- Update settings at runtime without code changes
|
||||
- Support multiple guilds with different configurations
|
||||
- Maintain backward compatibility with file-based config
|
||||
|
||||
## Architecture
|
||||
|
||||
### Database Schema
|
||||
|
||||
**`guild_settings` table:**
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `guild_id` | bigint | Primary key (Discord guild ID) |
|
||||
| `student_role_id` | bigint | Student role ID |
|
||||
| `visitor_role_id` | bigint | Visitor role ID |
|
||||
| `color_role_ids` | jsonb | Array of color role IDs |
|
||||
| `welcome_channel_id` | bigint | Welcome message channel |
|
||||
| `welcome_message` | text | Custom welcome message |
|
||||
| `feedback_channel_id` | bigint | Feedback channel |
|
||||
| `terminal_channel_id` | bigint | Terminal channel |
|
||||
| `terminal_message_id` | bigint | Terminal message ID |
|
||||
| `moderation_log_channel_id` | bigint | Moderation log channel |
|
||||
| `moderation_dm_on_warn` | jsonb | DM user on warn |
|
||||
| `moderation_auto_timeout_threshold` | jsonb | Auto timeout after N warnings |
|
||||
| `feature_overrides` | jsonb | Feature flag overrides |
|
||||
| `created_at` | timestamp | Creation time |
|
||||
| `updated_at` | timestamp | Last update time |
|
||||
|
||||
### Service Layer
|
||||
|
||||
The `guildSettingsService` (`shared/modules/guild-settings/guild-settings.service.ts`) provides:
|
||||
|
||||
```typescript
|
||||
// Get settings for a guild (returns null if not configured)
|
||||
await guildSettingsService.getSettings(guildId);
|
||||
|
||||
// Create or update settings
|
||||
await guildSettingsService.upsertSettings({
|
||||
guildId: "123456789",
|
||||
studentRoleId: "987654321",
|
||||
visitorRoleId: "111222333",
|
||||
});
|
||||
|
||||
// Update a single setting
|
||||
await guildSettingsService.updateSetting(guildId, "welcomeChannel", "456789123");
|
||||
|
||||
// Delete all settings for a guild
|
||||
await guildSettingsService.deleteSettings(guildId);
|
||||
|
||||
// Color role helpers
|
||||
await guildSettingsService.addColorRole(guildId, roleId);
|
||||
await guildSettingsService.removeColorRole(guildId, roleId);
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Getting Guild Configuration
|
||||
|
||||
Use `getGuildConfig()` instead of direct `config` imports for guild-specific settings:
|
||||
|
||||
```typescript
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
|
||||
// In a command or interaction
|
||||
const guildConfig = await getGuildConfig(interaction.guildId);
|
||||
|
||||
// Access settings
|
||||
const studentRole = guildConfig.studentRole;
|
||||
const welcomeChannel = guildConfig.welcomeChannelId;
|
||||
```
|
||||
|
||||
### Fallback Behavior
|
||||
|
||||
`getGuildConfig()` returns settings in this order:
|
||||
1. **Database settings** (if guild is configured in DB)
|
||||
2. **File config fallback** (during migration period)
|
||||
|
||||
This ensures backward compatibility while migrating from file-based config.
|
||||
|
||||
### Cache Invalidation
|
||||
|
||||
Settings are cached for 60 seconds. After updating settings, invalidate the cache:
|
||||
|
||||
```typescript
|
||||
import { invalidateGuildConfigCache } from "@shared/lib/config";
|
||||
|
||||
await guildSettingsService.upsertSettings({ guildId, ...settings });
|
||||
invalidateGuildConfigCache(guildId);
|
||||
```
|
||||
|
||||
## Admin Commands
|
||||
|
||||
The `/settings` command (Administrator only) provides full management:
|
||||
|
||||
### Subcommands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/settings show` | Display current guild settings |
|
||||
| `/settings set <key> [value]` | Update a setting |
|
||||
| `/settings reset <key>` | Reset a setting to default |
|
||||
| `/settings colors <action> [role]` | Manage color roles |
|
||||
|
||||
### Settable Keys
|
||||
|
||||
| Key | Type | Description |
|
||||
|-----|------|-------------|
|
||||
| `studentRole` | Role | Role for enrolled students |
|
||||
| `visitorRole` | Role | Role for visitors |
|
||||
| `welcomeChannel` | Channel | Channel for welcome messages |
|
||||
| `welcomeMessage` | Text | Custom welcome message |
|
||||
| `feedbackChannel` | Channel | Channel for feedback |
|
||||
| `terminalChannel` | Channel | Terminal channel |
|
||||
| `terminalMessage` | Text | Terminal message ID |
|
||||
| `moderationLogChannel` | Channel | Moderation log channel |
|
||||
| `moderationDmOnWarn` | Boolean | DM users on warn |
|
||||
| `moderationAutoTimeoutThreshold` | Number | Auto timeout threshold |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/guilds/:guildId/settings` | Get guild settings |
|
||||
| PUT | `/api/guilds/:guildId/settings` | Create/replace settings |
|
||||
| PATCH | `/api/guilds/:guildId/settings` | Partial update |
|
||||
| DELETE | `/api/guilds/:guildId/settings` | Delete settings |
|
||||
|
||||
## Migration
|
||||
|
||||
To migrate existing config.json settings to the database:
|
||||
|
||||
```bash
|
||||
bun run db:migrate-config
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Read values from `config.json`
|
||||
2. Create a database record for `DISCORD_GUILD_ID`
|
||||
3. Store all guild-specific settings
|
||||
|
||||
## Migration Strategy for Code
|
||||
|
||||
Update code references incrementally:
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
import { config } from "@shared/lib/config";
|
||||
const role = config.studentRole;
|
||||
|
||||
// After
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
const guildConfig = await getGuildConfig(guildId);
|
||||
const role = guildConfig.studentRole;
|
||||
```
|
||||
|
||||
### Files to Update
|
||||
|
||||
Files using guild-specific config that should be updated:
|
||||
- `bot/events/guildMemberAdd.ts`
|
||||
- `bot/modules/user/enrollment.interaction.ts`
|
||||
- `bot/modules/feedback/feedback.interaction.ts`
|
||||
- `bot/commands/feedback/feedback.ts`
|
||||
- `bot/commands/inventory/use.ts`
|
||||
- `bot/commands/admin/create_color.ts`
|
||||
- `shared/modules/moderation/moderation.service.ts`
|
||||
- `shared/modules/terminal/terminal.service.ts`
|
||||
|
||||
## Files Updated to Use Database Config
|
||||
|
||||
All code has been migrated to use `getGuildConfig()`:
|
||||
|
||||
- `bot/events/guildMemberAdd.ts` - Role assignment on join
|
||||
- `bot/modules/user/enrollment.interaction.ts` - Enrollment flow
|
||||
- `bot/modules/feedback/feedback.interaction.ts` - Feedback submission
|
||||
- `bot/commands/feedback/feedback.ts` - Feedback command
|
||||
- `bot/commands/inventory/use.ts` - Color role handling
|
||||
- `bot/commands/admin/create_color.ts` - Color role creation
|
||||
- `bot/commands/admin/warn.ts` - Warning with DM and auto-timeout
|
||||
- `shared/modules/moderation/moderation.service.ts` - Accepts config param
|
||||
- `shared/modules/terminal/terminal.service.ts` - Terminal location persistence
|
||||
- `shared/modules/economy/lootdrop.service.ts` - Terminal updates
|
||||
|
||||
## Implementation Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `shared/db/schema/guild-settings.ts` | Database schema |
|
||||
| `shared/modules/guild-settings/guild-settings.service.ts` | Service layer |
|
||||
| `shared/lib/config.ts` | Config loader with getGuildConfig() |
|
||||
| `bot/commands/admin/settings.ts` | Admin command |
|
||||
| `web/src/routes/guild-settings.routes.ts` | API routes |
|
||||
| `shared/scripts/migrate-config-to-db.ts` | Migration script |
|
||||
162
docs/main.md
Normal file
162
docs/main.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Aurora - Discord RPG Bot
|
||||
|
||||
A comprehensive, feature-rich Discord RPG bot built with modern technologies using a monorepo architecture.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Aurora uses a **Single Process Monolith** architecture that runs both the Discord bot and REST API in the same Bun process. This design maximizes performance by eliminating inter-process communication overhead and simplifies deployment to a single Docker container.
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
```
|
||||
aurora-bot-discord/
|
||||
├── bot/ # Discord bot implementation
|
||||
│ ├── commands/ # Slash command implementations
|
||||
│ ├── events/ # Discord event handlers
|
||||
│ ├── lib/ # Bot core logic (BotClient, utilities)
|
||||
│ └── index.ts # Bot entry point
|
||||
├── web/ # REST API server
|
||||
│ └── src/routes/ # API route handlers
|
||||
├── shared/ # Shared code between bot and web
|
||||
│ ├── db/ # Database schema and Drizzle ORM
|
||||
│ ├── lib/ # Utilities, config, logger, events
|
||||
│ ├── modules/ # Domain services (economy, admin, quest)
|
||||
│ └── config/ # Configuration files
|
||||
├── docker-compose.yml # Docker services (app, db)
|
||||
└── package.json # Root package manifest
|
||||
```
|
||||
|
||||
## Main Application Parts
|
||||
|
||||
### 1. Discord Bot (`bot/`)
|
||||
|
||||
The bot is built with Discord.js v14 and handles all Discord-related functionality.
|
||||
|
||||
**Core Components:**
|
||||
|
||||
- **BotClient** (`bot/lib/BotClient.ts`): Central client that manages commands, events, and Discord interactions
|
||||
- **Commands** (`bot/commands/`): Slash command implementations organized by category:
|
||||
- `admin/`: Server management commands (config, prune, warnings, notes)
|
||||
- `economy/`: Economy commands (balance, daily, pay, trade, trivia)
|
||||
- `inventory/`: Item management commands
|
||||
- `leveling/`: XP and level tracking
|
||||
- `quest/`: Quest commands
|
||||
- `user/`: User profile commands
|
||||
- **Events** (`bot/events/`): Discord event handlers:
|
||||
- `interactionCreate.ts`: Command interactions
|
||||
- `messageCreate.ts`: Message processing
|
||||
- `ready.ts`: Bot ready events
|
||||
- `guildMemberAdd.ts`: New member handling
|
||||
|
||||
### 2. REST API (`web/`)
|
||||
|
||||
A headless REST API built with Bun's native HTTP server for bot administration and data access.
|
||||
|
||||
**Key Endpoints:**
|
||||
|
||||
- **Stats** (`/api/stats`): Real-time bot metrics and statistics
|
||||
- **Settings** (`/api/settings`): Configuration management endpoints
|
||||
- **Users** (`/api/users`): User data and profiles
|
||||
- **Items** (`/api/items`): Item catalog and management
|
||||
- **Quests** (`/api/quests`): Quest data and progress
|
||||
- **Economy** (`/api/transactions`): Economy and transaction data
|
||||
|
||||
**API Features:**
|
||||
|
||||
- Built with Bun's native HTTP server
|
||||
- WebSocket support for real-time updates (`/ws`)
|
||||
- REST API endpoints for all bot data
|
||||
- Real-time event streaming via WebSocket
|
||||
- Zod validation for all requests
|
||||
|
||||
### 3. Shared Core (`shared/`)
|
||||
|
||||
Shared code accessible by both bot and web applications.
|
||||
|
||||
**Database Layer (`shared/db/`):**
|
||||
|
||||
- **schema.ts**: Drizzle ORM schema definitions for:
|
||||
- `users`: User profiles with economy data
|
||||
- `items`: Item catalog with rarities and types
|
||||
- `inventory`: User item holdings
|
||||
- `transactions`: Economy transaction history
|
||||
- `classes`: RPG class system
|
||||
- `moderationCases`: Moderation logs
|
||||
- `quests`: Quest definitions
|
||||
|
||||
**Modules (`shared/modules/`):**
|
||||
|
||||
- **economy/**: Economy service, lootdrops, daily rewards, trading
|
||||
- **admin/**: Administrative actions (maintenance mode, cache clearing)
|
||||
- **quest/**: Quest creation and tracking
|
||||
- **dashboard/**: Dashboard statistics and real-time event bus
|
||||
- **leveling/**: XP and leveling logic
|
||||
|
||||
**Utilities (`shared/lib/`):**
|
||||
|
||||
- `config.ts`: Application configuration management
|
||||
- `logger.ts`: Structured logging system
|
||||
- `env.ts`: Environment variable handling
|
||||
- `events.ts`: Event bus for inter-module communication
|
||||
- `constants.ts`: Application-wide constants
|
||||
|
||||
## Main Use-Cases
|
||||
|
||||
### For Discord Users
|
||||
|
||||
1. **Class System**: Users can join different RPG classes with unique roles
|
||||
2. **Economy**:
|
||||
- View balance and net worth
|
||||
- Earn currency through daily rewards, trivia, and lootdrops
|
||||
- Send payments to other users
|
||||
3. **Trading**: Secure trading system between users
|
||||
4. **Inventory Management**: Collect, use, and trade items with rarities
|
||||
5. **Leveling**: XP-based progression system tied to activity
|
||||
6. **Quests**: Complete quests for rewards
|
||||
7. **Lootdrops**: Random currency drops in text channels
|
||||
|
||||
### For Server Administrators
|
||||
|
||||
1. **Bot Configuration**: Adjust economy rates, enable/disable features via API
|
||||
2. **Moderation Tools**:
|
||||
- Warn, note, and track moderation cases
|
||||
- Mass prune inactive members
|
||||
- Role management
|
||||
3. **Quest Management**: Create and manage server-specific quests
|
||||
4. **Monitoring**:
|
||||
- Real-time statistics via REST API
|
||||
- Activity data and event logs
|
||||
- Economy leaderboards
|
||||
|
||||
### For Developers
|
||||
|
||||
1. **Single Process Architecture**: Easy debugging with unified runtime
|
||||
2. **Type Safety**: Full TypeScript across all modules
|
||||
3. **Testing**: Bun test framework with unit tests for core services
|
||||
4. **Docker Support**: Production-ready containerization
|
||||
5. **Remote Access**: SSH tunneling scripts for production debugging
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Layer | Technology |
|
||||
| ---------------- | --------------------------------- |
|
||||
| Runtime | Bun 1.0+ |
|
||||
| Bot Framework | Discord.js 14.x |
|
||||
| Web Framework | Bun HTTP Server (REST API) |
|
||||
| Database | PostgreSQL 17 |
|
||||
| ORM | Drizzle ORM |
|
||||
| UI | Discord embeds and components |
|
||||
| Validation | Zod |
|
||||
| Containerization | Docker |
|
||||
|
||||
## Running the Application
|
||||
|
||||
```bash
|
||||
# Database migrations
|
||||
bun run migrate
|
||||
|
||||
# Production (Docker)
|
||||
docker compose up
|
||||
```
|
||||
|
||||
The bot and API server run on port 3000 and are accessible at `http://localhost:3000`.
|
||||
23
package.json
23
package.json
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "app",
|
||||
"version": "1.1.4-pre",
|
||||
"module": "bot/index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"drizzle-kit": "^0.31.7"
|
||||
"drizzle-kit": "^0.31.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "docker compose run --rm app drizzle-kit generate",
|
||||
@@ -17,17 +18,21 @@
|
||||
"db:push:local": "drizzle-kit push",
|
||||
"dev": "bun --watch bot/index.ts",
|
||||
"db:studio": "drizzle-kit studio --port 4983 --host 0.0.0.0",
|
||||
"studio:remote": "bash shared/scripts/remote-studio.sh",
|
||||
"dashboard:remote": "bash shared/scripts/remote-dashboard.sh",
|
||||
"db:migrate-config": "docker compose run --rm app bun shared/scripts/migrate-config-to-db.ts",
|
||||
"db:migrate-game-config": "docker compose run --rm app bun shared/scripts/migrate-game-settings-to-db.ts",
|
||||
"db:migrate-all": "docker compose run --rm app sh -c 'bun shared/scripts/migrate-config-to-db.ts && bun shared/scripts/migrate-game-settings-to-db.ts'",
|
||||
"remote": "bash shared/scripts/remote.sh",
|
||||
"test": "bun test"
|
||||
"logs": "bash shared/scripts/logs.sh",
|
||||
"db:backup": "bash shared/scripts/db-backup.sh",
|
||||
"test": "bun test",
|
||||
"docker:cleanup": "bash shared/scripts/docker-cleanup.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "^0.1.84",
|
||||
"@napi-rs/canvas": "^0.1.89",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"postgres": "^3.4.7",
|
||||
"zod": "^4.1.13"
|
||||
"postgres": "^3.4.8",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
239
scripts/migrate-item-assets.ts
Normal file
239
scripts/migrate-item-assets.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Item Asset Migration Script
|
||||
*
|
||||
* Downloads images from existing Discord CDN URLs and saves them locally.
|
||||
* Updates database records to use local asset paths.
|
||||
*
|
||||
* Usage:
|
||||
* bun run scripts/migrate-item-assets.ts # Dry run (no changes)
|
||||
* bun run scripts/migrate-item-assets.ts --execute # Actually perform migration
|
||||
*/
|
||||
|
||||
import { resolve, join } from "path";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
|
||||
// Initialize database connection
|
||||
const { DrizzleClient } = await import("../shared/db/DrizzleClient");
|
||||
const { items } = await import("../shared/db/schema");
|
||||
|
||||
const ASSETS_DIR = resolve(import.meta.dir, "../bot/assets/graphics/items");
|
||||
const DRY_RUN = !process.argv.includes("--execute");
|
||||
|
||||
interface MigrationResult {
|
||||
itemId: number;
|
||||
itemName: string;
|
||||
originalUrl: string;
|
||||
newPath: string;
|
||||
status: "success" | "skipped" | "failed";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is an external URL (not a local asset path)
|
||||
*/
|
||||
function isExternalUrl(url: string | null): boolean {
|
||||
if (!url) return false;
|
||||
return url.startsWith("http://") || url.startsWith("https://");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is likely a Discord CDN URL
|
||||
*/
|
||||
function isDiscordCdnUrl(url: string): boolean {
|
||||
return url.includes("cdn.discordapp.com") ||
|
||||
url.includes("media.discordapp.net") ||
|
||||
url.includes("discord.gg");
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an image from a URL and save it locally
|
||||
*/
|
||||
async function downloadImage(url: string, destPath: string): Promise<void> {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (!contentType.startsWith("image/")) {
|
||||
throw new Error(`Invalid content type: ${contentType}`);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
await Bun.write(destPath, buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a single item's images
|
||||
*/
|
||||
async function migrateItem(item: {
|
||||
id: number;
|
||||
name: string;
|
||||
iconUrl: string | null;
|
||||
imageUrl: string | null;
|
||||
}): Promise<MigrationResult> {
|
||||
const result: MigrationResult = {
|
||||
itemId: item.id,
|
||||
itemName: item.name,
|
||||
originalUrl: item.iconUrl || item.imageUrl || "",
|
||||
newPath: `/assets/items/${item.id}.png`,
|
||||
status: "skipped"
|
||||
};
|
||||
|
||||
// Check if either URL needs migration
|
||||
const hasExternalIcon = isExternalUrl(item.iconUrl);
|
||||
const hasExternalImage = isExternalUrl(item.imageUrl);
|
||||
|
||||
if (!hasExternalIcon && !hasExternalImage) {
|
||||
result.status = "skipped";
|
||||
return result;
|
||||
}
|
||||
|
||||
// Prefer iconUrl, fall back to imageUrl
|
||||
const urlToDownload = item.iconUrl || item.imageUrl;
|
||||
|
||||
if (!urlToDownload || !isExternalUrl(urlToDownload)) {
|
||||
result.status = "skipped";
|
||||
return result;
|
||||
}
|
||||
|
||||
result.originalUrl = urlToDownload;
|
||||
const destPath = join(ASSETS_DIR, `${item.id}.png`);
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log(` [DRY RUN] Would download: ${urlToDownload}`);
|
||||
console.log(` -> ${destPath}`);
|
||||
result.status = "success";
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
// Download the image
|
||||
await downloadImage(urlToDownload, destPath);
|
||||
|
||||
// Update database record
|
||||
const { eq } = await import("drizzle-orm");
|
||||
await DrizzleClient
|
||||
.update(items)
|
||||
.set({
|
||||
iconUrl: `/assets/items/${item.id}.png`,
|
||||
imageUrl: `/assets/items/${item.id}.png`,
|
||||
})
|
||||
.where(eq(items.id, item.id));
|
||||
|
||||
result.status = "success";
|
||||
console.log(` ✅ Migrated: ${item.name} (ID: ${item.id})`);
|
||||
} catch (error) {
|
||||
result.status = "failed";
|
||||
result.error = error instanceof Error ? error.message : String(error);
|
||||
console.log(` ❌ Failed: ${item.name} (ID: ${item.id}) - ${result.error}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main migration function
|
||||
*/
|
||||
async function main() {
|
||||
console.log("═══════════════════════════════════════════════════════════════");
|
||||
console.log(" Item Asset Migration Script");
|
||||
console.log("═══════════════════════════════════════════════════════════════");
|
||||
console.log();
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log(" ⚠️ DRY RUN MODE - No changes will be made");
|
||||
console.log(" Run with --execute to perform actual migration");
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Ensure assets directory exists
|
||||
await mkdir(ASSETS_DIR, { recursive: true });
|
||||
console.log(` 📁 Assets directory: ${ASSETS_DIR}`);
|
||||
console.log();
|
||||
|
||||
// Fetch all items
|
||||
const allItems = await DrizzleClient.select({
|
||||
id: items.id,
|
||||
name: items.name,
|
||||
iconUrl: items.iconUrl,
|
||||
imageUrl: items.imageUrl,
|
||||
}).from(items);
|
||||
|
||||
console.log(` 📦 Found ${allItems.length} total items`);
|
||||
|
||||
// Filter items that need migration
|
||||
const itemsToMigrate = allItems.filter(item =>
|
||||
isExternalUrl(item.iconUrl) || isExternalUrl(item.imageUrl)
|
||||
);
|
||||
|
||||
console.log(` 🔄 ${itemsToMigrate.length} items have external URLs`);
|
||||
console.log();
|
||||
|
||||
if (itemsToMigrate.length === 0) {
|
||||
console.log(" ✨ No items need migration!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Categorize by URL type
|
||||
const discordCdnItems = itemsToMigrate.filter(item =>
|
||||
isDiscordCdnUrl(item.iconUrl || "") || isDiscordCdnUrl(item.imageUrl || "")
|
||||
);
|
||||
const otherExternalItems = itemsToMigrate.filter(item =>
|
||||
!isDiscordCdnUrl(item.iconUrl || "") && !isDiscordCdnUrl(item.imageUrl || "")
|
||||
);
|
||||
|
||||
console.log(` 📊 Breakdown:`);
|
||||
console.log(` - Discord CDN URLs: ${discordCdnItems.length}`);
|
||||
console.log(` - Other external URLs: ${otherExternalItems.length}`);
|
||||
console.log();
|
||||
|
||||
// Process migrations
|
||||
console.log(" Starting migration...");
|
||||
console.log();
|
||||
|
||||
const results: MigrationResult[] = [];
|
||||
|
||||
for (const item of itemsToMigrate) {
|
||||
const result = await migrateItem(item);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log();
|
||||
console.log("═══════════════════════════════════════════════════════════════");
|
||||
console.log(" Migration Summary");
|
||||
console.log("═══════════════════════════════════════════════════════════════");
|
||||
|
||||
const successful = results.filter(r => r.status === "success").length;
|
||||
const skipped = results.filter(r => r.status === "skipped").length;
|
||||
const failed = results.filter(r => r.status === "failed").length;
|
||||
|
||||
console.log(` ✅ Successful: ${successful}`);
|
||||
console.log(` ⏭️ Skipped: ${skipped}`);
|
||||
console.log(` ❌ Failed: ${failed}`);
|
||||
console.log();
|
||||
|
||||
if (failed > 0) {
|
||||
console.log(" Failed items:");
|
||||
for (const result of results.filter(r => r.status === "failed")) {
|
||||
console.log(` - ${result.itemName}: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log();
|
||||
console.log(" ⚠️ This was a dry run. Run with --execute to apply changes.");
|
||||
}
|
||||
|
||||
// Exit with error code if any failures
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
// Run
|
||||
main().catch(error => {
|
||||
console.error("Migration failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,13 +1,18 @@
|
||||
import { drizzle } from "drizzle-orm/bun-sql";
|
||||
import { SQL } from "bun";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgresJs from "postgres"; // Renamed import
|
||||
import * as schema from "./schema";
|
||||
import { env } from "@shared/lib/env";
|
||||
|
||||
const connectionString = env.DATABASE_URL;
|
||||
export const postgres = new SQL(connectionString);
|
||||
|
||||
export const DrizzleClient = drizzle(postgres, { schema });
|
||||
// Disable prefetch to prevent connection handling issues in serverless/container environments
|
||||
const client = postgresJs(connectionString, { prepare: false });
|
||||
|
||||
export const DrizzleClient = drizzle(client, { schema });
|
||||
|
||||
// Export the raw client as 'postgres' to match previous Bun.SQL export name/usage
|
||||
export const postgres = client;
|
||||
|
||||
export const closeDatabase = async () => {
|
||||
await postgres.close();
|
||||
await client.end();
|
||||
};
|
||||
@@ -1,42 +1,43 @@
|
||||
import { expect, test, describe } from "bun:test";
|
||||
import { postgres } from "./DrizzleClient";
|
||||
import { DrizzleClient } from "./DrizzleClient";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
describe("Database Indexes", () => {
|
||||
test("should have indexes on users table", async () => {
|
||||
const result = await postgres`
|
||||
const result = await DrizzleClient.execute(sql`
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'users'
|
||||
`;
|
||||
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
|
||||
`);
|
||||
const indexNames = result.map(r => r.indexname);
|
||||
expect(indexNames).toContain("users_balance_idx");
|
||||
expect(indexNames).toContain("users_level_xp_idx");
|
||||
});
|
||||
|
||||
test("should have index on transactions table", async () => {
|
||||
const result = await postgres`
|
||||
const result = await DrizzleClient.execute(sql`
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'transactions'
|
||||
`;
|
||||
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
|
||||
`);
|
||||
const indexNames = result.map(r => r.indexname);
|
||||
expect(indexNames).toContain("transactions_created_at_idx");
|
||||
});
|
||||
|
||||
test("should have indexes on moderation_cases table", async () => {
|
||||
const result = await postgres`
|
||||
const result = await DrizzleClient.execute(sql`
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'moderation_cases'
|
||||
`;
|
||||
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
|
||||
`);
|
||||
const indexNames = result.map(r => r.indexname);
|
||||
expect(indexNames).toContain("moderation_cases_user_id_idx");
|
||||
expect(indexNames).toContain("moderation_cases_case_id_idx");
|
||||
});
|
||||
|
||||
test("should have indexes on user_timers table", async () => {
|
||||
const result = await postgres`
|
||||
const result = await DrizzleClient.execute(sql`
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'user_timers'
|
||||
`;
|
||||
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
|
||||
`);
|
||||
const indexNames = result.map(r => r.indexname);
|
||||
expect(indexNames).toContain("user_timers_expires_at_idx");
|
||||
expect(indexNames).toContain("user_timers_lookup_idx");
|
||||
});
|
||||
|
||||
25
shared/db/migrations/0003_new_senator_kelly.sql
Normal file
25
shared/db/migrations/0003_new_senator_kelly.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE "feature_flag_access" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"flag_id" integer NOT NULL,
|
||||
"guild_id" bigint,
|
||||
"user_id" bigint,
|
||||
"role_id" bigint,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "feature_flags" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(100) NOT NULL,
|
||||
"enabled" boolean DEFAULT false NOT NULL,
|
||||
"description" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "feature_flags_name_unique" UNIQUE("name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "items" ALTER COLUMN "rarity" SET DEFAULT 'C';--> statement-breakpoint
|
||||
ALTER TABLE "feature_flag_access" ADD CONSTRAINT "feature_flag_access_flag_id_feature_flags_id_fk" FOREIGN KEY ("flag_id") REFERENCES "public"."feature_flags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_ffa_flag_id" ON "feature_flag_access" USING btree ("flag_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_ffa_guild_id" ON "feature_flag_access" USING btree ("guild_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_ffa_user_id" ON "feature_flag_access" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_ffa_role_id" ON "feature_flag_access" USING btree ("role_id");
|
||||
17
shared/db/migrations/0004_bored_kat_farrell.sql
Normal file
17
shared/db/migrations/0004_bored_kat_farrell.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE "guild_settings" (
|
||||
"guild_id" bigint PRIMARY KEY NOT NULL,
|
||||
"student_role_id" bigint,
|
||||
"visitor_role_id" bigint,
|
||||
"color_role_ids" jsonb DEFAULT '[]'::jsonb,
|
||||
"welcome_channel_id" bigint,
|
||||
"welcome_message" text,
|
||||
"feedback_channel_id" bigint,
|
||||
"terminal_channel_id" bigint,
|
||||
"terminal_message_id" bigint,
|
||||
"moderation_log_channel_id" bigint,
|
||||
"moderation_dm_on_warn" jsonb DEFAULT 'true'::jsonb,
|
||||
"moderation_auto_timeout_threshold" jsonb,
|
||||
"feature_overrides" jsonb DEFAULT '{}'::jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
1205
shared/db/migrations/meta/0003_snapshot.json
Normal file
1205
shared/db/migrations/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1313
shared/db/migrations/meta/0004_snapshot.json
Normal file
1313
shared/db/migrations/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,20 @@
|
||||
"when": 1767716705797,
|
||||
"tag": "0002_fancy_forge",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1770903573324,
|
||||
"tag": "0003_new_senator_kelly",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1770904612078,
|
||||
"tag": "0004_bored_kat_farrell",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,264 +1,3 @@
|
||||
import {
|
||||
pgTable,
|
||||
bigint,
|
||||
varchar,
|
||||
boolean,
|
||||
jsonb,
|
||||
timestamp,
|
||||
serial,
|
||||
text,
|
||||
integer,
|
||||
primaryKey,
|
||||
index,
|
||||
bigserial,
|
||||
check
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { relations, sql } from 'drizzle-orm';
|
||||
|
||||
// --- TABLES ---
|
||||
|
||||
// 1. Classes
|
||||
export const classes = pgTable('classes', {
|
||||
id: bigint('id', { mode: 'bigint' }).primaryKey(),
|
||||
name: varchar('name', { length: 255 }).unique().notNull(),
|
||||
balance: bigint('balance', { mode: 'bigint' }).default(0n),
|
||||
roleId: varchar('role_id', { length: 255 }),
|
||||
});
|
||||
|
||||
// 2. Users
|
||||
export const users = pgTable('users', {
|
||||
id: bigint('id', { mode: 'bigint' }).primaryKey(),
|
||||
classId: bigint('class_id', { mode: 'bigint' }).references(() => classes.id),
|
||||
username: varchar('username', { length: 255 }).unique().notNull(),
|
||||
isActive: boolean('is_active').default(true),
|
||||
|
||||
// Economy
|
||||
balance: bigint('balance', { mode: 'bigint' }).default(0n),
|
||||
xp: bigint('xp', { mode: 'bigint' }).default(0n),
|
||||
level: integer('level').default(1),
|
||||
dailyStreak: integer('daily_streak').default(0),
|
||||
|
||||
// Metadata
|
||||
settings: jsonb('settings').default({}),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
|
||||
}, (table) => [
|
||||
index('users_username_idx').on(table.username),
|
||||
index('users_balance_idx').on(table.balance),
|
||||
index('users_level_xp_idx').on(table.level, table.xp),
|
||||
]);
|
||||
|
||||
// 3. Items
|
||||
export const items = pgTable('items', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 255 }).unique().notNull(),
|
||||
description: text('description'),
|
||||
rarity: varchar('rarity', { length: 20 }).default('Common'),
|
||||
|
||||
// Economy & Visuals
|
||||
type: varchar('type', { length: 50 }).notNull().default('MATERIAL'),
|
||||
// Examples: 'CONSUMABLE', 'EQUIPMENT', 'MATERIAL'
|
||||
usageData: jsonb('usage_data').default({}),
|
||||
price: bigint('price', { mode: 'bigint' }),
|
||||
iconUrl: text('icon_url').notNull(),
|
||||
imageUrl: text('image_url').notNull(),
|
||||
});
|
||||
|
||||
// 4. Inventory (Join Table)
|
||||
export const inventory = pgTable('inventory', {
|
||||
userId: bigint('user_id', { mode: 'bigint' })
|
||||
.references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
itemId: integer('item_id')
|
||||
.references(() => items.id, { onDelete: 'cascade' }).notNull(),
|
||||
quantity: bigint('quantity', { mode: 'bigint' }).default(1n),
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.userId, table.itemId] }),
|
||||
check('quantity_check', sql`${table.quantity} > 0`)
|
||||
]);
|
||||
|
||||
// 5. Transactions
|
||||
export const transactions = pgTable('transactions', {
|
||||
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
|
||||
userId: bigint('user_id', { mode: 'bigint' })
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
relatedUserId: bigint('related_user_id', { mode: 'bigint' })
|
||||
.references(() => users.id, { onDelete: 'set null' }),
|
||||
amount: bigint('amount', { mode: 'bigint' }).notNull(),
|
||||
type: varchar('type', { length: 50 }).notNull(),
|
||||
description: text('description'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
}, (table) => [
|
||||
index('transactions_created_at_idx').on(table.createdAt),
|
||||
]);
|
||||
|
||||
export const itemTransactions = pgTable('item_transactions', {
|
||||
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
|
||||
userId: bigint('user_id', { mode: 'bigint' })
|
||||
.references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
relatedUserId: bigint('related_user_id', { mode: 'bigint' })
|
||||
.references(() => users.id, { onDelete: 'set null' }), // who they got it from/gave it to
|
||||
itemId: integer('item_id')
|
||||
.references(() => items.id, { onDelete: 'cascade' }).notNull(),
|
||||
quantity: bigint('quantity', { mode: 'bigint' }).notNull(), // positive = gain, negative = loss
|
||||
type: varchar('type', { length: 50 }).notNull(), // e.g., 'TRADE', 'SHOP_BUY', 'DROP'
|
||||
description: text('description'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
|
||||
// 6. Quests
|
||||
export const quests = pgTable('quests', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
description: text('description'),
|
||||
triggerEvent: varchar('trigger_event', { length: 50 }).notNull(),
|
||||
requirements: jsonb('requirements').notNull().default({}),
|
||||
rewards: jsonb('rewards').notNull().default({}),
|
||||
});
|
||||
|
||||
// 7. User Quests (Join Table)
|
||||
export const userQuests = pgTable('user_quests', {
|
||||
userId: bigint('user_id', { mode: 'bigint' })
|
||||
.references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
questId: integer('quest_id')
|
||||
.references(() => quests.id, { onDelete: 'cascade' }).notNull(),
|
||||
progress: integer('progress').default(0),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.userId, table.questId] })
|
||||
]);
|
||||
|
||||
// 8. User Timers (Generic: Cooldowns, Effects, Access)
|
||||
export const userTimers = pgTable('user_timers', {
|
||||
userId: bigint('user_id', { mode: 'bigint' })
|
||||
.references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
type: varchar('type', { length: 50 }).notNull(), // 'COOLDOWN', 'EFFECT', 'ACCESS'
|
||||
key: varchar('key', { length: 100 }).notNull(), // 'daily', 'chn_12345', 'xp_boost'
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
metadata: jsonb('metadata').default({}), // Store channelId, specific buff amounts, etc.
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.userId, table.type, table.key] }),
|
||||
index('user_timers_expires_at_idx').on(table.expiresAt),
|
||||
index('user_timers_lookup_idx').on(table.userId, table.type, table.key),
|
||||
]);
|
||||
// 9. Lootdrops
|
||||
export const lootdrops = pgTable('lootdrops', {
|
||||
messageId: varchar('message_id', { length: 255 }).primaryKey(),
|
||||
channelId: varchar('channel_id', { length: 255 }).notNull(),
|
||||
rewardAmount: integer('reward_amount').notNull(),
|
||||
currency: varchar('currency', { length: 50 }).notNull(),
|
||||
claimedBy: bigint('claimed_by', { mode: 'bigint' }).references(() => users.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
});
|
||||
|
||||
// 10. Moderation Cases
|
||||
export const moderationCases = pgTable('moderation_cases', {
|
||||
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
|
||||
caseId: varchar('case_id', { length: 50 }).unique().notNull(),
|
||||
type: varchar('type', { length: 20 }).notNull(), // 'warn', 'timeout', 'kick', 'ban', 'note', 'prune'
|
||||
userId: bigint('user_id', { mode: 'bigint' }).notNull(),
|
||||
username: varchar('username', { length: 255 }).notNull(),
|
||||
moderatorId: bigint('moderator_id', { mode: 'bigint' }).notNull(),
|
||||
moderatorName: varchar('moderator_name', { length: 255 }).notNull(),
|
||||
reason: text('reason').notNull(),
|
||||
metadata: jsonb('metadata').default({}),
|
||||
active: boolean('active').default(true).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
|
||||
resolvedBy: bigint('resolved_by', { mode: 'bigint' }),
|
||||
resolvedReason: text('resolved_reason'),
|
||||
}, (table) => [
|
||||
index('moderation_cases_user_id_idx').on(table.userId),
|
||||
index('moderation_cases_case_id_idx').on(table.caseId),
|
||||
]);
|
||||
|
||||
|
||||
|
||||
export const classesRelations = relations(classes, ({ many }) => ({
|
||||
users: many(users),
|
||||
}));
|
||||
|
||||
export const usersRelations = relations(users, ({ one, many }) => ({
|
||||
class: one(classes, {
|
||||
fields: [users.classId],
|
||||
references: [classes.id],
|
||||
}),
|
||||
inventory: many(inventory),
|
||||
transactions: many(transactions),
|
||||
quests: many(userQuests),
|
||||
timers: many(userTimers),
|
||||
}));
|
||||
|
||||
export const itemsRelations = relations(items, ({ many }) => ({
|
||||
inventoryEntries: many(inventory),
|
||||
}));
|
||||
|
||||
export const inventoryRelations = relations(inventory, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [inventory.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
item: one(items, {
|
||||
fields: [inventory.itemId],
|
||||
references: [items.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const transactionsRelations = relations(transactions, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [transactions.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const questsRelations = relations(quests, ({ many }) => ({
|
||||
userEntries: many(userQuests),
|
||||
}));
|
||||
|
||||
export const userQuestsRelations = relations(userQuests, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [userQuests.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
quest: one(quests, {
|
||||
fields: [userQuests.questId],
|
||||
references: [quests.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const userTimersRelations = relations(userTimers, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [userTimers.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const itemTransactionsRelations = relations(itemTransactions, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [itemTransactions.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
relatedUser: one(users, {
|
||||
fields: [itemTransactions.relatedUserId],
|
||||
references: [users.id],
|
||||
}),
|
||||
item: one(items, {
|
||||
fields: [itemTransactions.itemId],
|
||||
references: [items.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const moderationCasesRelations = relations(moderationCases, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [moderationCases.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
moderator: one(users, {
|
||||
fields: [moderationCases.moderatorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
resolver: one(users, {
|
||||
fields: [moderationCases.resolvedBy],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
// Re-export all schema definitions from domain modules
|
||||
// This file is kept for backward compatibility
|
||||
export * from './schema/index';
|
||||
|
||||
69
shared/db/schema/economy.ts
Normal file
69
shared/db/schema/economy.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
pgTable,
|
||||
bigint,
|
||||
varchar,
|
||||
text,
|
||||
timestamp,
|
||||
bigserial,
|
||||
index,
|
||||
integer,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { relations, type InferSelectModel } from 'drizzle-orm';
|
||||
import { users } from './users';
|
||||
import { items } from './inventory';
|
||||
|
||||
// --- TYPES ---
|
||||
export type Transaction = InferSelectModel<typeof transactions>;
|
||||
export type ItemTransaction = InferSelectModel<typeof itemTransactions>;
|
||||
|
||||
// --- TABLES ---
|
||||
export const transactions = pgTable('transactions', {
|
||||
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
|
||||
userId: bigint('user_id', { mode: 'bigint' })
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
relatedUserId: bigint('related_user_id', { mode: 'bigint' })
|
||||
.references(() => users.id, { onDelete: 'set null' }),
|
||||
amount: bigint('amount', { mode: 'bigint' }).notNull(),
|
||||
type: varchar('type', { length: 50 }).notNull(),
|
||||
description: text('description'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
}, (table) => [
|
||||
index('transactions_created_at_idx').on(table.createdAt),
|
||||
]);
|
||||
|
||||
export const itemTransactions = pgTable('item_transactions', {
|
||||
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
|
||||
userId: bigint('user_id', { mode: 'bigint' })
|
||||
.references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
relatedUserId: bigint('related_user_id', { mode: 'bigint' })
|
||||
.references(() => users.id, { onDelete: 'set null' }),
|
||||
itemId: integer('item_id')
|
||||
.references(() => items.id, { onDelete: 'cascade' }).notNull(),
|
||||
quantity: bigint('quantity', { mode: 'bigint' }).notNull(),
|
||||
type: varchar('type', { length: 50 }).notNull(), // e.g., 'TRADE', 'SHOP_BUY', 'DROP'
|
||||
description: text('description'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
|
||||
// --- RELATIONS ---
|
||||
export const transactionsRelations = relations(transactions, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [transactions.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const itemTransactionsRelations = relations(itemTransactions, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [itemTransactions.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
relatedUser: one(users, {
|
||||
fields: [itemTransactions.relatedUserId],
|
||||
references: [users.id],
|
||||
}),
|
||||
item: one(items, {
|
||||
fields: [itemTransactions.itemId],
|
||||
references: [items.id],
|
||||
}),
|
||||
}));
|
||||
49
shared/db/schema/feature-flags.ts
Normal file
49
shared/db/schema/feature-flags.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
pgTable,
|
||||
serial,
|
||||
varchar,
|
||||
boolean,
|
||||
text,
|
||||
timestamp,
|
||||
bigint,
|
||||
integer,
|
||||
index,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { relations, type InferSelectModel } from 'drizzle-orm';
|
||||
|
||||
export type FeatureFlag = InferSelectModel<typeof featureFlags>;
|
||||
export type FeatureFlagAccess = InferSelectModel<typeof featureFlagAccess>;
|
||||
|
||||
export const featureFlags = pgTable('feature_flags', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 100 }).notNull().unique(),
|
||||
enabled: boolean('enabled').default(false).notNull(),
|
||||
description: text('description'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const featureFlagAccess = pgTable('feature_flag_access', {
|
||||
id: serial('id').primaryKey(),
|
||||
flagId: integer('flag_id').notNull().references(() => featureFlags.id, { onDelete: 'cascade' }),
|
||||
guildId: bigint('guild_id', { mode: 'bigint' }),
|
||||
userId: bigint('user_id', { mode: 'bigint' }),
|
||||
roleId: bigint('role_id', { mode: 'bigint' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
index('idx_ffa_flag_id').on(table.flagId),
|
||||
index('idx_ffa_guild_id').on(table.guildId),
|
||||
index('idx_ffa_user_id').on(table.userId),
|
||||
index('idx_ffa_role_id').on(table.roleId),
|
||||
]);
|
||||
|
||||
export const featureFlagsRelations = relations(featureFlags, ({ many }) => ({
|
||||
access: many(featureFlagAccess),
|
||||
}));
|
||||
|
||||
export const featureFlagAccessRelations = relations(featureFlagAccess, ({ one }) => ({
|
||||
flag: one(featureFlags, {
|
||||
fields: [featureFlagAccess.flagId],
|
||||
references: [featureFlags.id],
|
||||
}),
|
||||
}));
|
||||
88
shared/db/schema/game-settings.ts
Normal file
88
shared/db/schema/game-settings.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { relations, type InferSelectModel, type InferInsertModel } from 'drizzle-orm';
|
||||
|
||||
export type GameSettings = InferSelectModel<typeof gameSettings>;
|
||||
export type GameSettingsInsert = InferInsertModel<typeof gameSettings>;
|
||||
|
||||
export interface LevelingConfig {
|
||||
base: number;
|
||||
exponent: number;
|
||||
chat: {
|
||||
cooldownMs: number;
|
||||
minXp: number;
|
||||
maxXp: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EconomyConfig {
|
||||
daily: {
|
||||
amount: string;
|
||||
streakBonus: string;
|
||||
weeklyBonus: string;
|
||||
cooldownMs: number;
|
||||
};
|
||||
transfers: {
|
||||
allowSelfTransfer: boolean;
|
||||
minAmount: string;
|
||||
};
|
||||
exam: {
|
||||
multMin: number;
|
||||
multMax: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InventoryConfig {
|
||||
maxStackSize: string;
|
||||
maxSlots: number;
|
||||
}
|
||||
|
||||
export interface LootdropConfig {
|
||||
activityWindowMs: number;
|
||||
minMessages: number;
|
||||
spawnChance: number;
|
||||
cooldownMs: number;
|
||||
reward: {
|
||||
min: number;
|
||||
max: number;
|
||||
currency: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TriviaConfig {
|
||||
entryFee: string;
|
||||
rewardMultiplier: number;
|
||||
timeoutSeconds: number;
|
||||
cooldownMs: number;
|
||||
categories: number[];
|
||||
difficulty: 'easy' | 'medium' | 'hard' | 'random';
|
||||
}
|
||||
|
||||
export interface ModerationConfig {
|
||||
prune: {
|
||||
maxAmount: number;
|
||||
confirmThreshold: number;
|
||||
batchSize: number;
|
||||
batchDelayMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const gameSettings = pgTable('game_settings', {
|
||||
id: text('id').primaryKey().default('default'),
|
||||
leveling: jsonb('leveling').$type<LevelingConfig>().notNull(),
|
||||
economy: jsonb('economy').$type<EconomyConfig>().notNull(),
|
||||
inventory: jsonb('inventory').$type<InventoryConfig>().notNull(),
|
||||
lootdrop: jsonb('lootdrop').$type<LootdropConfig>().notNull(),
|
||||
trivia: jsonb('trivia').$type<TriviaConfig>().notNull(),
|
||||
moderation: jsonb('moderation').$type<ModerationConfig>().notNull(),
|
||||
commands: jsonb('commands').$type<Record<string, boolean>>().default({}),
|
||||
system: jsonb('system').$type<Record<string, unknown>>().default({}),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const gameSettingsRelations = relations(gameSettings, () => ({}));
|
||||
51
shared/db/schema/guild-settings.ts
Normal file
51
shared/db/schema/guild-settings.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
pgTable,
|
||||
bigint,
|
||||
timestamp,
|
||||
text,
|
||||
jsonb,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { relations, type InferSelectModel, type InferInsertModel } from 'drizzle-orm';
|
||||
|
||||
export type GuildSettings = InferSelectModel<typeof guildSettings>;
|
||||
export type GuildSettingsInsert = InferInsertModel<typeof guildSettings>;
|
||||
|
||||
export interface GuildConfig {
|
||||
studentRole?: string;
|
||||
visitorRole?: string;
|
||||
colorRoles: string[];
|
||||
welcomeChannelId?: string;
|
||||
welcomeMessage?: string;
|
||||
feedbackChannelId?: string;
|
||||
terminal?: {
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
};
|
||||
moderation: {
|
||||
cases: {
|
||||
dmOnWarn: boolean;
|
||||
logChannelId?: string;
|
||||
autoTimeoutThreshold?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const guildSettings = pgTable('guild_settings', {
|
||||
guildId: bigint('guild_id', { mode: 'bigint' }).primaryKey(),
|
||||
studentRoleId: bigint('student_role_id', { mode: 'bigint' }),
|
||||
visitorRoleId: bigint('visitor_role_id', { mode: 'bigint' }),
|
||||
colorRoleIds: jsonb('color_role_ids').$type<string[]>().default([]),
|
||||
welcomeChannelId: bigint('welcome_channel_id', { mode: 'bigint' }),
|
||||
welcomeMessage: text('welcome_message'),
|
||||
feedbackChannelId: bigint('feedback_channel_id', { mode: 'bigint' }),
|
||||
terminalChannelId: bigint('terminal_channel_id', { mode: 'bigint' }),
|
||||
terminalMessageId: bigint('terminal_message_id', { mode: 'bigint' }),
|
||||
moderationLogChannelId: bigint('moderation_log_channel_id', { mode: 'bigint' }),
|
||||
moderationDmOnWarn: jsonb('moderation_dm_on_warn').$type<boolean>().default(true),
|
||||
moderationAutoTimeoutThreshold: jsonb('moderation_auto_timeout_threshold').$type<number>(),
|
||||
featureOverrides: jsonb('feature_overrides').$type<Record<string, boolean>>().default({}),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const guildSettingsRelations = relations(guildSettings, () => ({}));
|
||||
9
shared/db/schema/index.ts
Normal file
9
shared/db/schema/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Domain modules
|
||||
export * from './users';
|
||||
export * from './inventory';
|
||||
export * from './economy';
|
||||
export * from './quests';
|
||||
export * from './moderation';
|
||||
export * from './feature-flags';
|
||||
export * from './guild-settings';
|
||||
export * from './game-settings';
|
||||
57
shared/db/schema/inventory.ts
Normal file
57
shared/db/schema/inventory.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
pgTable,
|
||||
bigint,
|
||||
varchar,
|
||||
serial,
|
||||
text,
|
||||
integer,
|
||||
jsonb,
|
||||
primaryKey,
|
||||
check,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { relations, sql, type InferSelectModel } from 'drizzle-orm';
|
||||
import { users } from './users';
|
||||
|
||||
// --- TYPES ---
|
||||
export type Item = InferSelectModel<typeof items>;
|
||||
export type Inventory = InferSelectModel<typeof inventory>;
|
||||
|
||||
// --- TABLES ---
|
||||
export const items = pgTable('items', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 255 }).unique().notNull(),
|
||||
description: text('description'),
|
||||
rarity: varchar('rarity', { length: 20 }).default('C'),
|
||||
type: varchar('type', { length: 50 }).notNull().default('MATERIAL'),
|
||||
usageData: jsonb('usage_data').default({}),
|
||||
price: bigint('price', { mode: 'bigint' }),
|
||||
iconUrl: text('icon_url').notNull(),
|
||||
imageUrl: text('image_url').notNull(),
|
||||
});
|
||||
|
||||
export const inventory = pgTable('inventory', {
|
||||
userId: bigint('user_id', { mode: 'bigint' })
|
||||
.references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
itemId: integer('item_id')
|
||||
.references(() => items.id, { onDelete: 'cascade' }).notNull(),
|
||||
quantity: bigint('quantity', { mode: 'bigint' }).default(1n),
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.userId, table.itemId] }),
|
||||
check('quantity_check', sql`${table.quantity} > 0`)
|
||||
]);
|
||||
|
||||
// --- RELATIONS ---
|
||||
export const itemsRelations = relations(items, ({ many }) => ({
|
||||
inventoryEntries: many(inventory),
|
||||
}));
|
||||
|
||||
export const inventoryRelations = relations(inventory, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [inventory.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
item: one(items, {
|
||||
fields: [inventory.itemId],
|
||||
references: [items.id],
|
||||
}),
|
||||
}));
|
||||
65
shared/db/schema/moderation.ts
Normal file
65
shared/db/schema/moderation.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
pgTable,
|
||||
bigint,
|
||||
varchar,
|
||||
text,
|
||||
jsonb,
|
||||
timestamp,
|
||||
boolean,
|
||||
bigserial,
|
||||
integer,
|
||||
index,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { relations, type InferSelectModel } from 'drizzle-orm';
|
||||
import { users } from './users';
|
||||
|
||||
// --- TYPES ---
|
||||
export type ModerationCase = InferSelectModel<typeof moderationCases>;
|
||||
export type Lootdrop = InferSelectModel<typeof lootdrops>;
|
||||
|
||||
// --- TABLES ---
|
||||
export const moderationCases = pgTable('moderation_cases', {
|
||||
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
|
||||
caseId: varchar('case_id', { length: 50 }).unique().notNull(),
|
||||
type: varchar('type', { length: 20 }).notNull(), // 'warn', 'timeout', 'kick', 'ban', 'note', 'prune'
|
||||
userId: bigint('user_id', { mode: 'bigint' }).notNull(),
|
||||
username: varchar('username', { length: 255 }).notNull(),
|
||||
moderatorId: bigint('moderator_id', { mode: 'bigint' }).notNull(),
|
||||
moderatorName: varchar('moderator_name', { length: 255 }).notNull(),
|
||||
reason: text('reason').notNull(),
|
||||
metadata: jsonb('metadata').default({}),
|
||||
active: boolean('active').default(true).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
|
||||
resolvedBy: bigint('resolved_by', { mode: 'bigint' }),
|
||||
resolvedReason: text('resolved_reason'),
|
||||
}, (table) => [
|
||||
index('moderation_cases_user_id_idx').on(table.userId),
|
||||
index('moderation_cases_case_id_idx').on(table.caseId),
|
||||
]);
|
||||
|
||||
export const lootdrops = pgTable('lootdrops', {
|
||||
messageId: varchar('message_id', { length: 255 }).primaryKey(),
|
||||
channelId: varchar('channel_id', { length: 255 }).notNull(),
|
||||
rewardAmount: integer('reward_amount').notNull(),
|
||||
currency: varchar('currency', { length: 50 }).notNull(),
|
||||
claimedBy: bigint('claimed_by', { mode: 'bigint' }).references(() => users.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
});
|
||||
|
||||
// --- RELATIONS ---
|
||||
export const moderationCasesRelations = relations(moderationCases, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [moderationCases.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
moderator: one(users, {
|
||||
fields: [moderationCases.moderatorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
resolver: one(users, {
|
||||
fields: [moderationCases.resolvedBy],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
54
shared/db/schema/quests.ts
Normal file
54
shared/db/schema/quests.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
pgTable,
|
||||
bigint,
|
||||
varchar,
|
||||
serial,
|
||||
text,
|
||||
jsonb,
|
||||
timestamp,
|
||||
integer,
|
||||
primaryKey,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { relations, type InferSelectModel } from 'drizzle-orm';
|
||||
import { users } from './users';
|
||||
|
||||
// --- TYPES ---
|
||||
export type Quest = InferSelectModel<typeof quests>;
|
||||
export type UserQuest = InferSelectModel<typeof userQuests>;
|
||||
|
||||
// --- TABLES ---
|
||||
export const quests = pgTable('quests', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
description: text('description'),
|
||||
triggerEvent: varchar('trigger_event', { length: 50 }).notNull(),
|
||||
requirements: jsonb('requirements').notNull().default({}),
|
||||
rewards: jsonb('rewards').notNull().default({}),
|
||||
});
|
||||
|
||||
export const userQuests = pgTable('user_quests', {
|
||||
userId: bigint('user_id', { mode: 'bigint' })
|
||||
.references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
questId: integer('quest_id')
|
||||
.references(() => quests.id, { onDelete: 'cascade' }).notNull(),
|
||||
progress: integer('progress').default(0),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.userId, table.questId] })
|
||||
]);
|
||||
|
||||
// --- RELATIONS ---
|
||||
export const questsRelations = relations(quests, ({ many }) => ({
|
||||
userEntries: many(userQuests),
|
||||
}));
|
||||
|
||||
export const userQuestsRelations = relations(userQuests, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [userQuests.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
quest: one(quests, {
|
||||
fields: [userQuests.questId],
|
||||
references: [quests.id],
|
||||
}),
|
||||
}));
|
||||
80
shared/db/schema/users.ts
Normal file
80
shared/db/schema/users.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
pgTable,
|
||||
bigint,
|
||||
varchar,
|
||||
boolean,
|
||||
jsonb,
|
||||
timestamp,
|
||||
integer,
|
||||
primaryKey,
|
||||
index,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { relations, type InferSelectModel } from 'drizzle-orm';
|
||||
|
||||
// --- TYPES ---
|
||||
export type Class = InferSelectModel<typeof classes>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type UserTimer = InferSelectModel<typeof userTimers>;
|
||||
|
||||
// --- TABLES ---
|
||||
export const classes = pgTable('classes', {
|
||||
id: bigint('id', { mode: 'bigint' }).primaryKey(),
|
||||
name: varchar('name', { length: 255 }).unique().notNull(),
|
||||
balance: bigint('balance', { mode: 'bigint' }).default(0n),
|
||||
roleId: varchar('role_id', { length: 255 }),
|
||||
});
|
||||
|
||||
export const users = pgTable('users', {
|
||||
id: bigint('id', { mode: 'bigint' }).primaryKey(),
|
||||
classId: bigint('class_id', { mode: 'bigint' }).references(() => classes.id),
|
||||
username: varchar('username', { length: 255 }).unique().notNull(),
|
||||
isActive: boolean('is_active').default(true),
|
||||
|
||||
// Economy
|
||||
balance: bigint('balance', { mode: 'bigint' }).default(0n),
|
||||
xp: bigint('xp', { mode: 'bigint' }).default(0n),
|
||||
level: integer('level').default(1),
|
||||
dailyStreak: integer('daily_streak').default(0),
|
||||
|
||||
// Metadata
|
||||
settings: jsonb('settings').default({}),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
|
||||
}, (table) => [
|
||||
index('users_username_idx').on(table.username),
|
||||
index('users_balance_idx').on(table.balance),
|
||||
index('users_level_xp_idx').on(table.level, table.xp),
|
||||
]);
|
||||
|
||||
export const userTimers = pgTable('user_timers', {
|
||||
userId: bigint('user_id', { mode: 'bigint' })
|
||||
.references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||
type: varchar('type', { length: 50 }).notNull(), // 'COOLDOWN', 'EFFECT', 'ACCESS'
|
||||
key: varchar('key', { length: 100 }).notNull(), // TimerKey, 'chn_12345', 'xp_boost'
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
metadata: jsonb('metadata').default({}),
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.userId, table.type, table.key] }),
|
||||
index('user_timers_expires_at_idx').on(table.expiresAt),
|
||||
index('user_timers_lookup_idx').on(table.userId, table.type, table.key),
|
||||
]);
|
||||
|
||||
// --- RELATIONS ---
|
||||
export const classesRelations = relations(classes, ({ many }) => ({
|
||||
users: many(users),
|
||||
}));
|
||||
|
||||
export const usersRelations = relations(users, ({ one, many }) => ({
|
||||
class: one(classes, {
|
||||
fields: [users.classId],
|
||||
references: [classes.id],
|
||||
}),
|
||||
timers: many(userTimers),
|
||||
}));
|
||||
|
||||
export const userTimersRelations = relations(userTimers, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [userTimers.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
@@ -1,63 +0,0 @@
|
||||
# Command Reference
|
||||
|
||||
This document lists all available slash commands in Aurora, categorized by their function.
|
||||
|
||||
## Economy
|
||||
|
||||
| Command | Description | Options | Permissions |
|
||||
|---|---|---|---|
|
||||
| `/balance` | View your or another user's balance. | `user` (Optional): The user to check. | Everyone |
|
||||
| `/daily` | Claim your daily currency reward and streak bonus. | None | Everyone |
|
||||
| `/pay` | Transfer currency to another user. | `user` (Required): Recipient.<br>`amount` (Required): Amount to send. | Everyone |
|
||||
| `/trade` | Start a trade session with another user. | `user` (Required): The user to trade with. | Everyone |
|
||||
| `/exam` | Take your weekly exam to earn rewards based on XP gain. | None | Everyone |
|
||||
|
||||
## Inventory & Items
|
||||
|
||||
| Command | Description | Options | Permissions |
|
||||
|---|---|---|---|
|
||||
| `/inventory` | View your or another user's inventory. | `user` (Optional): The user to check. | Everyone |
|
||||
| `/use` | Use an item from your inventory. | `item` (Required): The item to use (Autocomplete). | Everyone |
|
||||
|
||||
## User & Social
|
||||
|
||||
| Command | Description | Options | Permissions |
|
||||
|---|---|---|---|
|
||||
| `/profile` | View your or another user's Student ID card. | `user` (Optional): The user to view. | Everyone |
|
||||
| `/leaderboard` | View top players. | `type` (Required): 'Level / XP' or 'Balance'. | Everyone |
|
||||
| `/feedback` | Submit feedback, bug reports, or suggestions. | None | Everyone |
|
||||
| `/quests` | View your active quests. | None | Everyone |
|
||||
|
||||
## Admin
|
||||
|
||||
> [!IMPORTANT]
|
||||
> These commands require Administrator permissions or specific roles as configured.
|
||||
|
||||
### General Management
|
||||
| Command | Description | Options |
|
||||
|---|---|---|
|
||||
| `/config` | Manage bot configuration. | `group` (Req): Section.<br>`key` (Req): Setting.<br>`value` (Req): New value. |
|
||||
| `/refresh` | Refresh commands or configuration cache. | `type`: 'Commands' or 'Config'. |
|
||||
| `/update` | Update the bot from the repository. | None |
|
||||
| `/features` | Enable/Disable system features. | `feature` (Req): Feature name.<br>`enabled` (Req): True/False. |
|
||||
| `/webhook` | Send a message via webhook. | `payload` (Req): JSON payload. |
|
||||
|
||||
### Moderation
|
||||
| Command | Description | Options |
|
||||
|---|---|---|
|
||||
| `/warn` | Warn a user. | `user` (Req): Target.<br>`reason` (Req): Reason. |
|
||||
| `/warnings` | View active warnings for a user. | `user` (Req): Target. |
|
||||
| `/clearwarning`| Clear a specific warning. | `case_id` (Req): Case ID. |
|
||||
| `/case` | View details of a specific moderation case. | `case_id` (Req): Case ID. |
|
||||
| `/cases` | View moderation history for a user. | `user` (Req): Target. |
|
||||
| `/note` | Add a note to a user. | `user` (Req): Target.<br>`note` (Req): Content. |
|
||||
| `/notes` | View notes for a user. | `user` (Req): Target. |
|
||||
| `/prune` | Bulk delete messages. | `amount` (Req): Number (1-100). |
|
||||
|
||||
### Game Admin
|
||||
| Command | Description | Options |
|
||||
|---|---|---|
|
||||
| `/create_item` | Create a new item in the database. | (Modal based interaction) |
|
||||
| `/create_color`| Create a new color role. | `name` (Req): Role name.<br>`hex` (Req): Hex color code. |
|
||||
| `/listing` | Manage shop listings (Admin view). | None (Context sensitive?) |
|
||||
| `/terminal` | Control the terminal display channel. | `action`: 'setup', 'update', 'clear'. |
|
||||
@@ -1,160 +0,0 @@
|
||||
# Configuration Guide
|
||||
|
||||
This document outlines the structure and available options for the `config/config.json` file. The configuration is validated using Zod schemas at runtime (see `src/lib/config.ts`).
|
||||
|
||||
## Core Structure
|
||||
|
||||
### Leveling
|
||||
Configuration for the XP and leveling system.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `base` | `number` | The base XP required for the first level. |
|
||||
| `exponent` | `number` | The exponent used to calculate XP curves. |
|
||||
| `chat.cooldownMs` | `number` | Time in milliseconds between XP gains from chat. |
|
||||
| `chat.minXp` | `number` | Minimum XP awarded per message. |
|
||||
| `chat.maxXp` | `number` | Maximum XP awarded per message. |
|
||||
|
||||
### Economy
|
||||
Settings for currency, rewards, and transfers.
|
||||
|
||||
#### Daily
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `amount` | `integer` | Base amount granted by `/daily`. |
|
||||
| `streakBonus` | `integer` | Bonus amount per streak day. |
|
||||
| `weeklyBonus` | `integer` | Bonus amount for a 7-day streak. |
|
||||
| `cooldownMs` | `number` | Cooldown period for the command (usually 24h). |
|
||||
|
||||
#### Transfers
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `allowSelfTransfer` | `boolean` | Whether users can transfer money to themselves. |
|
||||
| `minAmount` | `integer` | Minimum amount required for a transfer. |
|
||||
|
||||
#### Exam
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `multMin` | `number` | Minimum multiplier for exam rewards. |
|
||||
| `multMax` | `number` | Maximum multiplier for exam rewards. |
|
||||
|
||||
### Inventory
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `maxStackSize` | `integer` | Maximum count of a single item in one slot. |
|
||||
| `maxSlots` | `number` | Total number of inventory slots available. |
|
||||
|
||||
### Lootdrop
|
||||
Settings for the random chat loot drop events.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `activityWindowMs` | `number` | Time window to track activity for spawning drops. |
|
||||
| `minMessages` | `number` | Minimum messages required in window to trigger drop. |
|
||||
| `spawnChance` | `number` | Probability (0-1) of a drop spawning when conditions met. |
|
||||
| `cooldownMs` | `number` | Minimum time between loot drops. |
|
||||
| `reward.min` | `number` | Minimum currency reward. |
|
||||
| `reward.max` | `number` | Maximum currency reward. |
|
||||
| `reward.currency` | `string` | The currency ID/Symbol used for rewards. |
|
||||
|
||||
### Roles
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `studentRole` | `string` | Discord Role ID for students. |
|
||||
| `visitorRole` | `string` | Discord Role ID for visitors. |
|
||||
| `colorRoles` | `string[]` | List of Discord Role IDs available as color roles. |
|
||||
|
||||
### Moderation
|
||||
Automated moderation settings.
|
||||
|
||||
#### Prune
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `maxAmount` | `number` | Maximum messages to delete in one go. |
|
||||
| `confirmThreshold` | `number` | Amount above which confirmation is required. |
|
||||
| `batchSize` | `number` | Size of delete batches. |
|
||||
| `batchDelayMs` | `number` | Delay between batches. |
|
||||
|
||||
#### Cases
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `dmOnWarn` | `boolean` | Whether to DM users when they are warned. |
|
||||
| `logChannelId` | `string` | (Optional) Channel ID for moderation logs. |
|
||||
| `autoTimeoutThreshold` | `number` | (Optional) Warn count to trigger auto-timeout. |
|
||||
|
||||
### System & Misc
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `commands` | `Object` | Map of command names (keys) to boolean (values) to enable/disable them. |
|
||||
| `welcomeChannelId` | `string` | (Optional) Channel ID for welcome messages. |
|
||||
| `welcomeMessage` | `string` | (Optional) Custom welcome message text. |
|
||||
| `feedbackChannelId` | `string` | (Optional) Channel ID where feedback is posted. |
|
||||
| `terminal.channelId` | `string` | (Optional) Channel ID for terminal display. |
|
||||
| `terminal.messageId` | `string` | (Optional) Message ID for terminal display. |
|
||||
|
||||
## Example Config
|
||||
|
||||
```json
|
||||
{
|
||||
"leveling": {
|
||||
"base": 100,
|
||||
"exponent": 1.5,
|
||||
"chat": {
|
||||
"cooldownMs": 60000,
|
||||
"minXp": 15,
|
||||
"maxXp": 25
|
||||
}
|
||||
},
|
||||
"economy": {
|
||||
"daily": {
|
||||
"amount": "100",
|
||||
"streakBonus": "10",
|
||||
"weeklyBonus": "500",
|
||||
"cooldownMs": 86400000
|
||||
},
|
||||
"transfers": {
|
||||
"allowSelfTransfer": false,
|
||||
"minAmount": "10"
|
||||
},
|
||||
"exam": {
|
||||
"multMin": 1.0,
|
||||
"multMax": 2.0
|
||||
}
|
||||
},
|
||||
"inventory": {
|
||||
"maxStackSize": "99",
|
||||
"maxSlots": 20
|
||||
},
|
||||
"lootdrop": {
|
||||
"activityWindowMs": 300000,
|
||||
"minMessages": 10,
|
||||
"spawnChance": 0.05,
|
||||
"cooldownMs": 3600000,
|
||||
"reward": {
|
||||
"min": 50,
|
||||
"max": 150,
|
||||
"currency": "CREDITS"
|
||||
}
|
||||
},
|
||||
"commands": {
|
||||
"example": true
|
||||
},
|
||||
"studentRole": "123456789012345678",
|
||||
"visitorRole": "123456789012345678",
|
||||
"colorRoles": [],
|
||||
"moderation": {
|
||||
"prune": {
|
||||
"maxAmount": 100,
|
||||
"confirmThreshold": 50,
|
||||
"batchSize": 100,
|
||||
"batchDelayMs": 1000
|
||||
},
|
||||
"cases": {
|
||||
"dmOnWarn": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Fields marked as `integer` or `bigint` in the types can often be provided as strings in the JSON to ensure precision, but the system handles parsing them.
|
||||
@@ -1,149 +0,0 @@
|
||||
# Database Schema
|
||||
|
||||
This document outlines the database schema for the Aurora project. The database is PostgreSQL, managed via Drizzle ORM.
|
||||
|
||||
## Tables
|
||||
|
||||
### Users (`users`)
|
||||
Stores user data, economy, and progression.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `bigint` | Primary Key. Discord User ID. |
|
||||
| `class_id` | `bigint` | Foreign Key -> `classes.id`. |
|
||||
| `username` | `varchar(255)` | User's Discord username. |
|
||||
| `is_active` | `boolean` | Whether the user is active (default: true). |
|
||||
| `balance` | `bigint` | User's currency balance. |
|
||||
| `xp` | `bigint` | User's experience points. |
|
||||
| `level` | `integer` | User's level. |
|
||||
| `daily_streak` | `integer` | Current streak of daily command usage. |
|
||||
| `settings` | `jsonb` | User-specific settings. |
|
||||
| `created_at` | `timestamp` | Record creation time. |
|
||||
| `updated_at` | `timestamp` | Last update time. |
|
||||
|
||||
### Classes (`classes`)
|
||||
Available character classes.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `bigint` | Primary Key. Custom ID. |
|
||||
| `name` | `varchar(255)` | Class name (Unique). |
|
||||
| `balance` | `bigint` | Class bank balance (shared/flavor). |
|
||||
| `role_id` | `varchar(255)` | Discord Role ID associated with the class. |
|
||||
|
||||
### Items (`items`)
|
||||
Definitions of items available in the game.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `serial` | Primary Key. Auto-incrementing ID. |
|
||||
| `name` | `varchar(255)` | Item name (Unique). |
|
||||
| `description` | `text` | Item description. |
|
||||
| `rarity` | `varchar(20)` | Common, Rare, etc. Default: 'Common'. |
|
||||
| `type` | `varchar(50)` | MATERIAL, CONSUMABLE, EQUIPMENT, etc. |
|
||||
| `usage_data` | `jsonb` | Effect data for consumables/usables. |
|
||||
| `price` | `bigint` | Base value of the item. |
|
||||
| `icon_url` | `text` | URL for the item's icon. |
|
||||
| `image_url` | `text` | URL for the item's large image. |
|
||||
|
||||
### Inventory (`inventory`)
|
||||
Items held by users.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `user_id` | `bigint` | PK/FK -> `users.id`. |
|
||||
| `item_id` | `integer` | PK/FK -> `items.id`. |
|
||||
| `quantity` | `bigint` | Amount held. Must be > 0. |
|
||||
|
||||
### Transactions (`transactions`)
|
||||
Currency transaction history.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `bigserial` | Primary Key. |
|
||||
| `user_id` | `bigint` | FK -> `users.id`. The user affecting the balance. |
|
||||
| `related_user_id` | `bigint` | FK -> `users.id`. The other party (if any). |
|
||||
| `amount` | `bigint` | Amount transferred. |
|
||||
| `type` | `varchar(50)` | Transaction type identifier. |
|
||||
| `description` | `text` | Human-readable description. |
|
||||
| `created_at` | `timestamp` | Time of transaction. |
|
||||
|
||||
### Item Transactions (`item_transactions`)
|
||||
Item flow history.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `bigserial` | Primary Key. |
|
||||
| `user_id` | `bigint` | FK -> `users.id`. |
|
||||
| `related_user_id` | `bigint` | FK -> `users.id`. |
|
||||
| `item_id` | `integer` | FK -> `items.id`. |
|
||||
| `quantity` | `bigint` | Amount gained (+) or lost (-). |
|
||||
| `type` | `varchar(50)` | TRADE, SHOP_BUY, DROP, etc. |
|
||||
| `description` | `text` | Description. |
|
||||
| `created_at` | `timestamp` | Time of transaction. |
|
||||
|
||||
### Quests (`quests`)
|
||||
Quest definitions.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `serial` | Primary Key. |
|
||||
| `name` | `varchar(255)` | Quest title. |
|
||||
| `description` | `text` | Quest text. |
|
||||
| `trigger_event` | `varchar(50)` | Event that triggers progress checks. |
|
||||
| `requirements` | `jsonb` | Completion criteria. |
|
||||
| `rewards` | `jsonb` | Rewards for completion. |
|
||||
|
||||
### User Quests (`user_quests`)
|
||||
User progress on quests.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `user_id` | `bigint` | PK/FK -> `users.id`. |
|
||||
| `quest_id` | `integer` | PK/FK -> `quests.id`. |
|
||||
| `progress` | `integer` | Current progress value. |
|
||||
| `completed_at` | `timestamp` | Completion time (null if active). |
|
||||
|
||||
### User Timers (`user_timers`)
|
||||
Generic timers for cooldowns, temporary effects, etc.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `user_id` | `bigint` | PK/FK -> `users.id`. |
|
||||
| `type` | `varchar(50)` | PK. Timer type (COOLDOWN, EFFECT, ACCESS). |
|
||||
| `key` | `varchar(100)` | PK. specific ID (e.g. 'daily'). |
|
||||
| `expires_at` | `timestamp` | When the timer expires. |
|
||||
| `metadata` | `jsonb` | Extra data. |
|
||||
|
||||
### Lootdrops (`lootdrops`)
|
||||
Active chat loot drop events.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `message_id` | `varchar(255)` | Primary Key. Discord Message ID. |
|
||||
| `channel_id` | `varchar(255)` | Discord Channel ID. |
|
||||
| `reward_amount` | `integer` | Currency amount. |
|
||||
| `currency` | `varchar(50)` | Currency type constant. |
|
||||
| `claimed_by` | `bigint` | FK -> `users.id`. Null if unclaimed. |
|
||||
| `created_at` | `timestamp` | Spawn time. |
|
||||
| `expires_at` | `timestamp` | Despawn time. |
|
||||
|
||||
### Moderation Cases (`moderation_cases`)
|
||||
History of moderation actions.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `bigserial` | Primary Key. |
|
||||
| `case_id` | `varchar(50)` | Unique friendly ID. |
|
||||
| `type` | `varchar(20)` | warn, timeout, kick, ban, etc. |
|
||||
| `user_id` | `bigint` | Target user ID. |
|
||||
| `username` | `varchar(255)` | Target username snapshot. |
|
||||
| `moderator_id` | `bigint` | Acting moderator ID. |
|
||||
| `moderator_name` | `varchar(255)` | Moderator username snapshot. |
|
||||
| `reason` | `text` | Reason for action. |
|
||||
| `metadata` | `jsonb` | Extra data. |
|
||||
| `active` | `boolean` | Is this case active? |
|
||||
| `created_at` | `timestamp` | Creation time. |
|
||||
| `resolved_at` | `timestamp` | Resolution/Expiration time. |
|
||||
| `resolved_by` | `bigint` | User ID who resolved it. |
|
||||
| `resolved_reason` | `text` | Reason for resolution. |
|
||||
@@ -1,127 +0,0 @@
|
||||
# Lootbox Creation Guide
|
||||
|
||||
Currently, the Item Wizard does not support creating **Lootbox** items directly. Instead, they must be inserted manually into the database. This guide details the required JSON structure for the `LOOTBOX` effect.
|
||||
|
||||
## Item Structure
|
||||
|
||||
To create a lootbox, you need to insert a row into the `items` table. The critical part is the `usageData` JSON column.
|
||||
|
||||
```json
|
||||
{
|
||||
"consume": true,
|
||||
"effects": [
|
||||
{
|
||||
"type": "LOOTBOX",
|
||||
"pool": [ ... ]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Loot Table Structure
|
||||
|
||||
The `pool` property is an array of `LootTableItem` objects. A random item is selected based on the total `weight` of all items in the pool.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `type` | `string` | One of: `CURRENCY`, `ITEM`, `XP`, `NOTHING`. |
|
||||
| `weight` | `number` | The relative probability weight of this outcome. |
|
||||
| `message` | `string` | (Optional) Custom message to display when this outcome is selected. |
|
||||
|
||||
### Outcome Types
|
||||
|
||||
#### 1. Currency
|
||||
Gives the user coins.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "CURRENCY",
|
||||
"weight": 50,
|
||||
"amount": 100, // Fixed amount OR
|
||||
"minAmount": 50, // Minimum random amount
|
||||
"maxAmount": 150 // Maximum random amount
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. XP
|
||||
Gives the user Experience Points.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "XP",
|
||||
"weight": 30,
|
||||
"amount": 500 // Fixed amount OR range (minAmount/maxAmount)
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Item
|
||||
Gives the user another item (by ID).
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ITEM",
|
||||
"weight": 10,
|
||||
"itemId": 42, // The ID of the item to give
|
||||
"amount": 1 // (Optional) Quantity to give, default 1
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Nothing
|
||||
An empty roll.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "NOTHING",
|
||||
"weight": 10,
|
||||
"message": "The box was empty! Better luck next time."
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
Here is a full SQL insert example (using a hypothetical SQL client or Drizzle studio) for a "Basic Lootbox":
|
||||
|
||||
**Name**: Basic Lootbox
|
||||
**Type**: CONSUMABLE
|
||||
**Effect**:
|
||||
- 50% chance for 100-200 Coins
|
||||
- 30% chance for 500 XP
|
||||
- 10% chance for Item ID 5 (e.g. Rare Gem)
|
||||
- 10% chance for Nothing
|
||||
|
||||
**JSON for `usageData`**:
|
||||
```json
|
||||
{
|
||||
"consume": true,
|
||||
"effects": [
|
||||
{
|
||||
"type": "LOOTBOX",
|
||||
"pool": [
|
||||
{
|
||||
"type": "CURRENCY",
|
||||
"weight": 50,
|
||||
"minAmount": 100,
|
||||
"maxAmount": 200
|
||||
},
|
||||
{
|
||||
"type": "XP",
|
||||
"weight": 30,
|
||||
"amount": 500
|
||||
},
|
||||
{
|
||||
"type": "ITEM",
|
||||
"weight": 10,
|
||||
"itemId": 5,
|
||||
"amount": 1,
|
||||
"message": "Startstruck! You found a Rare Gem!"
|
||||
},
|
||||
{
|
||||
"type": "NOTHING",
|
||||
"weight": 10,
|
||||
"message": "It's empty..."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user