Compare commits
128 Commits
feat/dashb
...
f822d90dd3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f822d90dd3 | ||
|
|
141c3098f8 | ||
|
|
0c67a8754f | ||
|
|
bf20c61190 | ||
|
|
099601ce6d | ||
|
|
55d2376ca1 | ||
|
|
6eb4a32a12 | ||
|
|
2d35a5eabb | ||
|
|
570cdc69c1 | ||
|
|
c2b1fb6db1 | ||
|
|
d15d53e839 | ||
|
|
58374d1746 | ||
|
|
ae6a068197 | ||
|
|
43d32918ab | ||
|
|
0bc254b728 | ||
|
|
610d97bde3 | ||
|
|
babccfd08a | ||
|
|
ee7d63df3e | ||
|
|
5f107d03a7 | ||
|
|
1ff24b0f7f | ||
|
|
a5e3534260 | ||
|
|
228005322e | ||
|
|
67a3aa4b0f | ||
|
|
64804f7066 | ||
|
|
73ad889018 | ||
|
|
9c7f1e4418 | ||
|
|
efb50916b2 | ||
|
|
6abb52694e | ||
|
|
76968e31a6 | ||
|
|
29bf0e6f1c | ||
|
|
8c306fbd23 | ||
|
|
b0c3baf5b7 | ||
|
|
f575588b9a | ||
|
|
553b9b4952 | ||
|
|
073348fa55 | ||
|
|
4232674494 | ||
|
|
fbf1e52c28 | ||
|
|
20284dc57b | ||
|
|
36f9c76fa9 | ||
|
|
46e95ce7b3 | ||
|
|
9acd3f3d76 | ||
|
|
5e8683a19f | ||
|
|
ee088ad84b | ||
|
|
b18b5fab62 | ||
|
|
0b56486ab2 | ||
|
|
11c589b01c | ||
|
|
e4169d9dd5 | ||
|
|
1929f0dd1f | ||
|
|
db4e7313c3 | ||
|
|
1ffe397fbb | ||
|
|
34958aa220 | ||
|
|
109b36ffe2 | ||
|
|
cd954afe36 | ||
|
|
2b60883173 | ||
|
|
c2d67d7435 | ||
|
|
e252d6e00a | ||
|
|
95f1b4e04a | ||
|
|
62c6ca5e87 | ||
|
|
aac9be19f2 | ||
|
|
bb823c86c1 | ||
|
|
119301f1c3 | ||
|
|
9a2fc101da | ||
|
|
7049cbfd9d | ||
|
|
db859e8f12 | ||
|
|
5ff3fa9ab5 | ||
|
|
c8bf69a969 | ||
|
|
fee4969910 | ||
|
|
dabcb4cab3 | ||
|
|
1a3f5c6654 | ||
|
|
422db6479b | ||
|
|
35ecea16f7 | ||
|
|
9ff679ee5c | ||
|
|
ebefd8c0df | ||
|
|
73531f38ae | ||
|
|
5a6356d271 | ||
|
|
f9dafeac3b | ||
|
|
1a2bbb011c | ||
|
|
2ead35789d | ||
|
|
c1da71227d | ||
|
|
17e636c4e5 | ||
|
|
d7543d9f48 | ||
|
|
afe82c449b | ||
|
|
3c1334b30e | ||
|
|
58f261562a | ||
|
|
4ecbffd617 | ||
|
|
5491551544 | ||
|
|
7d658bbef9 | ||
|
|
d117bcb697 | ||
|
|
94e332ba57 | ||
|
|
3ef9773990 | ||
|
|
d243a11bd3 | ||
|
|
47ce0f12e6 | ||
|
|
f2caa1a3ee | ||
|
|
2a72beb0ef | ||
|
|
2f73f38877 | ||
|
|
9e5c6b5ac3 | ||
|
|
eb108695d3 | ||
|
|
7d541825d8 | ||
|
|
52f8ab11f0 | ||
|
|
f8436e9755 | ||
|
|
194a032c7f | ||
|
|
94a5a183d0 | ||
|
|
c7730b9355 | ||
|
|
1e20a5a7a0 | ||
|
|
54944283a3 | ||
|
|
f79ee6fbc7 | ||
|
|
915f1bc4ad | ||
|
|
4af2690bab | ||
|
|
6e57ab07e4 | ||
|
|
3a620a84c5 | ||
|
|
7d68652ea5 | ||
|
|
35bd1f58dd | ||
|
|
1cd3dbcd72 | ||
|
|
c97249f2ca | ||
|
|
0d923491b5 | ||
|
|
d870ef69d5 | ||
|
|
682e9d208e | ||
|
|
4a691ac71d | ||
|
|
1b84dbd36d | ||
|
|
a5b8d922e3 | ||
|
|
238d9a8803 | ||
|
|
713ea07040 | ||
|
|
bea6c33024 | ||
|
|
8fe300c8a2 | ||
|
|
9caa95a0d8 | ||
|
|
c6fd23b5fa | ||
|
|
d46434de18 | ||
|
|
cf4c28e1df |
@@ -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
|
||||||
19
.env.example
19
.env.example
@@ -1,13 +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_USER=aurora
|
||||||
DB_PASSWORD=aurora
|
DB_PASSWORD=aurora
|
||||||
DB_NAME=aurora
|
DB_NAME=aurora
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
DB_HOST=db
|
DB_HOST=db
|
||||||
|
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
|
||||||
|
|
||||||
|
# Discord
|
||||||
|
# Get from: https://discord.com/developers/applications
|
||||||
DISCORD_BOT_TOKEN=your-discord-bot-token
|
DISCORD_BOT_TOKEN=your-discord-bot-token
|
||||||
DISCORD_CLIENT_ID=your-discord-client-id
|
DISCORD_CLIENT_ID=your-discord-client-id
|
||||||
DISCORD_GUILD_ID=your-discord-guild-id
|
DISCORD_GUILD_ID=your-discord-guild-id
|
||||||
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
|
|
||||||
ADMIN_TOKEN=Ffeg4hgsdfvsnyms,kmeuy64sy5y
|
|
||||||
|
|
||||||
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
|
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
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
.env
|
.env
|
||||||
node_modules
|
node_modules
|
||||||
|
docker-compose.override.yml
|
||||||
shared/db-logs
|
shared/db-logs
|
||||||
shared/db/data
|
shared/db/data
|
||||||
shared/db/loga
|
shared/db/loga
|
||||||
@@ -45,3 +46,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
src/db/data
|
src/db/data
|
||||||
src/db/log
|
src/db/log
|
||||||
scratchpad/
|
scratchpad/
|
||||||
|
bot/assets/graphics/items
|
||||||
|
tickets/
|
||||||
|
|||||||
257
AGENTS.md
Normal file
257
AGENTS.md
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
# AGENTS.md - AI Coding Agent Guidelines
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
AuroraBot is a Discord bot with a REST API built using Bun, Discord.js, and PostgreSQL with Drizzle ORM.
|
||||||
|
|
||||||
|
## Build/Lint/Test Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
bun --watch bot/index.ts # Run bot + API server with hot reload
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
bun test # Run all tests
|
||||||
|
bun test path/to/file.test.ts # Run single test file
|
||||||
|
bun test --watch # Watch mode
|
||||||
|
bun test shared/modules/economy # Run tests in directory
|
||||||
|
|
||||||
|
# Database
|
||||||
|
bun run generate # Generate Drizzle migrations (Docker)
|
||||||
|
bun run migrate # Run migrations (Docker)
|
||||||
|
bun run db:push # Push schema changes (Docker)
|
||||||
|
bun run db:push:local # Push schema changes (local)
|
||||||
|
bun run db:studio # Open Drizzle Studio
|
||||||
|
|
||||||
|
# Docker (recommended for local dev)
|
||||||
|
docker compose up # Start bot, API, and database
|
||||||
|
docker compose up app # Start just the app (bot + API)
|
||||||
|
docker compose up db # Start just the database
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
bot/ # Discord bot
|
||||||
|
├── commands/ # Slash commands by category
|
||||||
|
├── events/ # Discord event handlers
|
||||||
|
├── lib/ # Bot core (BotClient, handlers, loaders)
|
||||||
|
├── modules/ # Feature modules (views, interactions)
|
||||||
|
└── graphics/ # Canvas image generation
|
||||||
|
|
||||||
|
shared/ # Shared between bot and web
|
||||||
|
├── db/ # Database schema and migrations
|
||||||
|
├── lib/ # Utils, config, errors, types
|
||||||
|
└── modules/ # Domain services (economy, user, etc.)
|
||||||
|
|
||||||
|
web/ # API server
|
||||||
|
└── src/routes/ # API route handlers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Import Conventions
|
||||||
|
|
||||||
|
Use path aliases defined in tsconfig.json:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// External packages first
|
||||||
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
// Path aliases second
|
||||||
|
import { economyService } from "@shared/modules/economy/economy.service";
|
||||||
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
import { users } from "@db/schema";
|
||||||
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
|
import { handleTradeInteraction } from "@modules/trade/trade.interaction";
|
||||||
|
|
||||||
|
// Relative imports last
|
||||||
|
import { localHelper } from "./helper";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available Aliases:**
|
||||||
|
|
||||||
|
- `@/*` - bot/
|
||||||
|
- `@shared/*` - shared/
|
||||||
|
- `@db/*` - shared/db/
|
||||||
|
- `@lib/*` - bot/lib/
|
||||||
|
- `@modules/*` - bot/modules/
|
||||||
|
- `@commands/*` - bot/commands/
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
| Element | Convention | Example |
|
||||||
|
| ---------------- | ----------------------- | ---------------------------------------- |
|
||||||
|
| Files | camelCase or kebab-case | `BotClient.ts`, `economy.service.ts` |
|
||||||
|
| Classes | PascalCase | `CommandHandler`, `UserError` |
|
||||||
|
| Functions | camelCase | `createCommand`, `handleShopInteraction` |
|
||||||
|
| Constants | UPPER_SNAKE_CASE | `EVENTS`, `BRANDING` |
|
||||||
|
| Enums | PascalCase | `TimerType`, `TransactionType` |
|
||||||
|
| Services | camelCase singleton | `economyService`, `userService` |
|
||||||
|
| Types/Interfaces | PascalCase | `Command`, `Event`, `GameConfigType` |
|
||||||
|
| DB tables | snake_case | `users`, `moderation_cases` |
|
||||||
|
| Custom IDs | snake_case with prefix | `shop_buy_`, `trade_accept_` |
|
||||||
|
|
||||||
|
## Code Patterns
|
||||||
|
|
||||||
|
### Command Definition
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const commandName = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("commandname")
|
||||||
|
.setDescription("Description"),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
await interaction.deferReply();
|
||||||
|
// Implementation
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Pattern (Singleton Object)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const serviceName = {
|
||||||
|
methodName: async (params: ParamType): Promise<ReturnType> => {
|
||||||
|
return await withTransaction(async (tx) => {
|
||||||
|
// Database operations
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Module File Organization
|
||||||
|
|
||||||
|
- `*.view.ts` - Creates Discord embeds/components
|
||||||
|
- `*.interaction.ts` - Handles button/select/modal interactions
|
||||||
|
- `*.types.ts` - Module-specific TypeScript types
|
||||||
|
- `*.service.ts` - Business logic (in shared/modules/)
|
||||||
|
- `*.test.ts` - Test files (co-located with source)
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Custom Error Classes
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { UserError, SystemError } from "@shared/lib/errors";
|
||||||
|
|
||||||
|
// User-facing errors (shown to user)
|
||||||
|
throw new UserError("You don't have enough coins!");
|
||||||
|
|
||||||
|
// System errors (logged, generic message shown)
|
||||||
|
throw new SystemError("Database connection failed");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recommended: `withCommandErrorHandling`
|
||||||
|
|
||||||
|
Use the `withCommandErrorHandling` utility from `@lib/commandUtils` to standardize
|
||||||
|
error handling across all commands. It handles `deferReply`, `UserError` display,
|
||||||
|
and unexpected error logging automatically.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
|
export const myCommand = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("mycommand")
|
||||||
|
.setDescription("Does something"),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const result = await service.method();
|
||||||
|
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
||||||
|
},
|
||||||
|
{ ephemeral: true } // optional: makes the deferred reply ephemeral
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `ephemeral` — whether `deferReply` should be ephemeral
|
||||||
|
- `successMessage` — a simple string to send on success
|
||||||
|
- `onSuccess` — a callback invoked with the operation result
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Patterns
|
||||||
|
|
||||||
|
### Transaction Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { withTransaction } from "@/lib/db";
|
||||||
|
|
||||||
|
return await withTransaction(async (tx) => {
|
||||||
|
const user = await tx.query.users.findFirst({
|
||||||
|
where: eq(users.id, discordId),
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(users)
|
||||||
|
.set({ coins: newBalance })
|
||||||
|
.where(eq(users.id, discordId));
|
||||||
|
await tx.insert(transactions).values({ userId: discordId, amount, type });
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}, existingTx); // Pass existing tx if in nested transaction
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schema Notes
|
||||||
|
|
||||||
|
- Use `bigint` mode for Discord IDs and currency amounts
|
||||||
|
- Relations defined separately from table definitions
|
||||||
|
- Schema modules: `shared/db/schema/*.ts` (users, inventory, economy, quests, moderation)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test File Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||||
|
|
||||||
|
// Mock modules BEFORE imports
|
||||||
|
mock.module("@shared/db/DrizzleClient", () => ({
|
||||||
|
DrizzleClient: { query: mockQuery },
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("serviceName", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFn.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle expected case", async () => {
|
||||||
|
// Arrange
|
||||||
|
mockFn.mockResolvedValue(testData);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await service.method(input);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
expect(mockFn).toHaveBeenCalledWith(expectedArgs);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Runtime:** Bun 1.0+
|
||||||
|
- **Bot:** Discord.js 14.x
|
||||||
|
- **Web:** Bun HTTP Server (REST API)
|
||||||
|
- **Database:** PostgreSQL 16+ with Drizzle ORM
|
||||||
|
- **UI:** Discord embeds and components
|
||||||
|
- **Validation:** Zod
|
||||||
|
- **Testing:** Bun Test
|
||||||
|
- **Container:** Docker
|
||||||
|
|
||||||
|
## Key Files Reference
|
||||||
|
|
||||||
|
| Purpose | File |
|
||||||
|
| ------------- | ---------------------- |
|
||||||
|
| Bot entry | `bot/index.ts` |
|
||||||
|
| DB schema | `shared/db/schema.ts` |
|
||||||
|
| Error classes | `shared/lib/errors.ts` |
|
||||||
|
| Config loader | `shared/lib/config.ts` |
|
||||||
|
| Environment | `shared/lib/env.ts` |
|
||||||
|
| Embed helpers | `bot/lib/embeds.ts` |
|
||||||
|
| Command utils | `shared/lib/utils.ts` |
|
||||||
|
| Error handler | `bot/lib/commandUtils.ts` |
|
||||||
70
Dockerfile
70
Dockerfile
@@ -1,22 +1,74 @@
|
|||||||
|
# ============================================
|
||||||
|
# Base stage - shared configuration
|
||||||
|
# ============================================
|
||||||
FROM oven/bun:latest AS base
|
FROM oven/bun:latest AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
# Install system dependencies with cleanup in same layer
|
||||||
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends git && \
|
||||||
|
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
|
|
||||||
# Install root project dependencies
|
# ============================================
|
||||||
|
# Dependencies stage - installs all deps
|
||||||
|
# ============================================
|
||||||
|
FROM base AS deps
|
||||||
|
|
||||||
|
# Copy only package files first (better layer caching)
|
||||||
COPY package.json bun.lock ./
|
COPY package.json bun.lock ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
RUN bun install --frozen-lockfile
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
# Install web project dependencies
|
# ============================================
|
||||||
COPY web/package.json web/bun.lock ./web/
|
# Development stage - for local dev with volume mounts
|
||||||
RUN cd web && bun install --frozen-lockfile
|
# ============================================
|
||||||
|
FROM base AS development
|
||||||
|
|
||||||
# Copy source code
|
# Copy dependencies from deps stage
|
||||||
COPY . .
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
|
||||||
# Expose ports (3000 for web dashboard)
|
# Expose ports
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Default command
|
# Default command
|
||||||
CMD ["bun", "run", "dev"]
|
CMD ["bun", "run", "dev"]
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Builder stage - copies source for production
|
||||||
|
# ============================================
|
||||||
|
FROM base AS builder
|
||||||
|
|
||||||
|
# Copy dependencies from deps stage
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Production stage - minimal runtime image
|
||||||
|
# ============================================
|
||||||
|
FROM oven/bun:latest AS production
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy only what's needed for production
|
||||||
|
COPY --from=builder --chown=bun:bun /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder --chown=bun:bun /app/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.
|
Aurora is a powerful Discord bot designed to facilitate RPG-like elements within a Discord server. It features a robust economy, class system, inventory management, quests, and more, all built on top of a high-performance stack using Bun and Drizzle ORM.
|
||||||
|
|
||||||
|
**New in v1.0:** Aurora now includes a fully integrated **REST API** for accessing bot data, statistics, and configuration, running alongside the bot in a single process.
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
|
### Discord Bot
|
||||||
* **Class System**: Users can join different classes.
|
* **Class System**: Users can join different classes.
|
||||||
* **Economy**: Complete economy system with balance, transactions, and daily rewards.
|
* **Economy**: Complete economy system with balance, transactions, and daily rewards.
|
||||||
* **Inventory & Items**: sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management.
|
* **Inventory & Items**: Sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management.
|
||||||
* **Leveling**: XP-based leveling system to track user activity and progress.
|
* **Leveling**: XP-based leveling system to track user activity and progress.
|
||||||
* **Quests**: Quest system with requirements and rewards.
|
* **Quests**: Quest system with requirements and rewards.
|
||||||
* **Trading**: Secure trading system between users.
|
* **Trading**: Secure trading system between users.
|
||||||
* **Lootdrops**: Random loot drops in channels to engage users.
|
* **Lootdrops**: Random loot drops in channels to engage users.
|
||||||
* **Admin Tools**: Administrative commands for server management.
|
* **Admin Tools**: Administrative commands for server management.
|
||||||
|
|
||||||
|
### REST API
|
||||||
|
* **Live Analytics**: Real-time statistics endpoint (commands, transactions).
|
||||||
|
* **Configuration Management**: Update bot settings via API.
|
||||||
|
* **Database Inspection**: Integrated Drizzle Studio access.
|
||||||
|
* **WebSocket Support**: Real-time event streaming for live updates.
|
||||||
|
|
||||||
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
Aurora uses a **Single Process Monolith** architecture to maximize performance and simplify resource sharing.
|
||||||
|
|
||||||
|
* **Unified Runtime**: Both the Discord Client and the REST API run within the same Bun process.
|
||||||
|
* **Shared State**: This allows the API to access live bot memory (caches, gateways) directly without complex inter-process communication (IPC).
|
||||||
|
* **Simplified Deployment**: You only need to deploy a single Docker container.
|
||||||
|
|
||||||
## 🛠️ Tech Stack
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
* **Runtime**: [Bun](https://bun.sh/)
|
* **Runtime**: [Bun](https://bun.sh/)
|
||||||
* **Framework**: [Discord.js](https://discord.js.org/)
|
* **Bot Framework**: [Discord.js](https://discord.js.org/)
|
||||||
|
* **API Framework**: Bun HTTP Server (REST API)
|
||||||
|
* **UI**: Discord embeds and components
|
||||||
* **Database**: [PostgreSQL](https://www.postgresql.org/)
|
* **Database**: [PostgreSQL](https://www.postgresql.org/)
|
||||||
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
||||||
* **Validation**: [Zod](https://zod.dev/)
|
* **Validation**: [Zod](https://zod.dev/)
|
||||||
@@ -74,12 +92,14 @@ Aurora is a powerful Discord bot designed to facilitate RPG-like elements within
|
|||||||
bun run db:push
|
bun run db:push
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running the Bot
|
### Running the Bot & API
|
||||||
|
|
||||||
**Development Mode** (with hot reload):
|
**Development Mode** (with hot reload):
|
||||||
```bash
|
```bash
|
||||||
bun run dev
|
bun run dev
|
||||||
```
|
```
|
||||||
|
* Bot: Online in Discord
|
||||||
|
* API: http://localhost:3000
|
||||||
|
|
||||||
**Production Mode**:
|
**Production Mode**:
|
||||||
Build and run with Docker (recommended):
|
Build and run with Docker (recommended):
|
||||||
@@ -87,27 +107,46 @@ Build and run with Docker (recommended):
|
|||||||
docker compose up -d app
|
docker compose up -d app
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 🔐 Accessing Production Services (SSH Tunnel)
|
||||||
|
|
||||||
|
For security, the Production Database and API are **not exposed** to the public internet by default. They are only accessible via localhost on the server.
|
||||||
|
|
||||||
|
To access them from your local machine, use the included SSH tunnel script.
|
||||||
|
|
||||||
|
1. Add your VPS details to your local `.env` file:
|
||||||
|
```env
|
||||||
|
VPS_USER=root
|
||||||
|
VPS_HOST=123.45.67.89
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run the remote connection script:
|
||||||
|
```bash
|
||||||
|
bun run remote
|
||||||
|
```
|
||||||
|
|
||||||
|
This will establish secure tunnels for:
|
||||||
|
* **API**: http://localhost:3000
|
||||||
|
* **Drizzle Studio**: http://localhost:4983
|
||||||
|
|
||||||
## 📜 Scripts
|
## 📜 Scripts
|
||||||
|
|
||||||
* `bun run dev`: Start the bot in watch mode.
|
* `bun run dev`: Start the bot and API server in watch mode.
|
||||||
|
* `bun run remote`: Open SSH tunnel to production services.
|
||||||
* `bun run generate`: Generate Drizzle migrations.
|
* `bun run generate`: Generate Drizzle migrations.
|
||||||
* `bun run migrate`: Apply migrations (via Docker).
|
* `bun run migrate`: Apply migrations (via Docker).
|
||||||
* `bun run db:push`: Push, schema to DB (via Docker).
|
|
||||||
* `bun run db:studio`: Open Drizzle Studio to inspect the database.
|
* `bun run db:studio`: Open Drizzle Studio to inspect the database.
|
||||||
* `bun test`: Run tests.
|
* `bun test`: Run tests.
|
||||||
|
|
||||||
## 📂 Project Structure
|
## 📂 Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
├── src
|
├── bot # Discord Bot logic & entry point
|
||||||
│ ├── commands # Slash commands
|
├── web # REST API Server
|
||||||
│ ├── events # Discord event handlers
|
├── shared # Shared code (Database, Config, Types)
|
||||||
│ ├── modules # Feature modules (Economy, Inventory, etc.)
|
|
||||||
│ ├── db # Database schema and connection
|
|
||||||
│ └── lib # Shared utilities
|
|
||||||
├── drizzle # Drizzle migration files
|
├── drizzle # Drizzle migration files
|
||||||
├── config # Configuration files
|
├── scripts # Utility scripts
|
||||||
└── scripts # Utility scripts
|
├── docker-compose.yml
|
||||||
|
└── package.json
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|||||||
0
bot/assets/graphics/items/.gitkeep
Normal file
0
bot/assets/graphics/items/.gitkeep
Normal file
@@ -1,7 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const moderationCase = createCommand({
|
export const moderationCase = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -16,39 +17,35 @@ export const moderationCase = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||||
|
|
||||||
try {
|
// Validate case ID format
|
||||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
if (!caseId.match(/^CASE-\d+$/)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate case ID format
|
// Get the case
|
||||||
if (!caseId.match(/^CASE-\d+$/)) {
|
const moderationCase = await moderationService.getCaseById(caseId);
|
||||||
|
|
||||||
|
if (!moderationCase) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display the case
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
embeds: [getCaseEmbed(moderationCase)]
|
||||||
});
|
});
|
||||||
return;
|
},
|
||||||
}
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
// Get the case
|
|
||||||
const moderationCase = await ModerationService.getCaseById(caseId);
|
|
||||||
|
|
||||||
if (!moderationCase) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display the case
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getCaseEmbed(moderationCase)]
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Case command error:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("An error occurred while fetching the case.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getCasesListEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const cases = createCommand({
|
export const cases = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -22,33 +23,29 @@ export const cases = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const targetUser = interaction.options.getUser("user", true);
|
||||||
|
const activeOnly = interaction.options.getBoolean("active_only") || false;
|
||||||
|
|
||||||
try {
|
// Get cases for the user
|
||||||
const targetUser = interaction.options.getUser("user", true);
|
const userCases = await moderationService.getUserCases(targetUser.id, activeOnly);
|
||||||
const activeOnly = interaction.options.getBoolean("active_only") || false;
|
|
||||||
|
|
||||||
// Get cases for the user
|
const title = activeOnly
|
||||||
const userCases = await ModerationService.getUserCases(targetUser.id, activeOnly);
|
? `⚠️ Active Cases for ${targetUser.username}`
|
||||||
|
: `📋 All Cases for ${targetUser.username}`;
|
||||||
|
|
||||||
const title = activeOnly
|
const description = userCases.length === 0
|
||||||
? `⚠️ Active Cases for ${targetUser.username}`
|
? undefined
|
||||||
: `📋 All Cases for ${targetUser.username}`;
|
: `Total cases: **${userCases.length}**`;
|
||||||
|
|
||||||
const description = userCases.length === 0
|
// Display the cases
|
||||||
? undefined
|
await interaction.editReply({
|
||||||
: `Total cases: **${userCases.length}**`;
|
embeds: [getCasesListEmbed(userCases, title, description)]
|
||||||
|
});
|
||||||
// Display the cases
|
},
|
||||||
await interaction.editReply({
|
{ ephemeral: true }
|
||||||
embeds: [getCasesListEmbed(userCases, title, description)]
|
);
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Cases command error:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("An error occurred while fetching cases.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const clearwarning = createCommand({
|
export const clearwarning = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -23,62 +24,58 @@ export const clearwarning = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||||
|
const reason = interaction.options.getString("reason") || "Cleared by moderator";
|
||||||
|
|
||||||
try {
|
// Validate case ID format
|
||||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
if (!caseId.match(/^CASE-\d+$/)) {
|
||||||
const reason = interaction.options.getString("reason") || "Cleared by moderator";
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate case ID format
|
// Check if case exists and is active
|
||||||
if (!caseId.match(/^CASE-\d+$/)) {
|
const existingCase = await moderationService.getCaseById(caseId);
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
if (!existingCase) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingCase.active) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed(`Case **${caseId}** is already resolved.`)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingCase.type !== 'warn') {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed(`Case **${caseId}** is not a warning. Only warnings can be cleared.`)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the warning
|
||||||
|
await moderationService.clearCase({
|
||||||
|
caseId,
|
||||||
|
clearedBy: interaction.user.id,
|
||||||
|
clearedByName: interaction.user.username,
|
||||||
|
reason
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if case exists and is active
|
// Send success message
|
||||||
const existingCase = await ModerationService.getCaseById(caseId);
|
|
||||||
|
|
||||||
if (!existingCase) {
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
embeds: [getClearSuccessEmbed(caseId)]
|
||||||
});
|
});
|
||||||
return;
|
},
|
||||||
}
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
if (!existingCase.active) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** is already resolved.`)]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingCase.type !== 'warn') {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** is not a warning. Only warnings can be cleared.`)]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear the warning
|
|
||||||
await ModerationService.clearCase({
|
|
||||||
caseId,
|
|
||||||
clearedBy: interaction.user.id,
|
|
||||||
clearedByName: interaction.user.username,
|
|
||||||
reason
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send success message
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getClearSuccessEmbed(caseId)]
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Clear warning command error:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("An error occurred while clearing the warning.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ModalSubmitInteraction } from "discord.js";
|
|
||||||
import { config, saveConfig } from "@shared/lib/config";
|
|
||||||
import type { GameConfigType } from "@shared/lib/config";
|
|
||||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
|
||||||
|
|
||||||
export const configCommand = createCommand({
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName("config")
|
|
||||||
.setDescription("Edit the bot configuration")
|
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
|
||||||
execute: async (interaction) => {
|
|
||||||
console.log(`Config command executed by ${interaction.user.tag}`);
|
|
||||||
const replacer = (key: string, value: any) => {
|
|
||||||
if (typeof value === 'bigint') {
|
|
||||||
return value.toString();
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentConfigJson = JSON.stringify(config, replacer, 4);
|
|
||||||
|
|
||||||
const modal = new ModalBuilder()
|
|
||||||
.setCustomId("config-modal")
|
|
||||||
.setTitle("Edit Configuration");
|
|
||||||
|
|
||||||
const jsonInput = new TextInputBuilder()
|
|
||||||
.setCustomId("json-input")
|
|
||||||
.setLabel("Configuration JSON")
|
|
||||||
.setStyle(TextInputStyle.Paragraph)
|
|
||||||
.setValue(currentConfigJson)
|
|
||||||
.setRequired(true);
|
|
||||||
|
|
||||||
const actionRow = new ActionRowBuilder<TextInputBuilder>().addComponents(jsonInput);
|
|
||||||
modal.addComponents(actionRow);
|
|
||||||
|
|
||||||
await interaction.showModal(modal);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const submitted = await interaction.awaitModalSubmit({
|
|
||||||
time: 300000, // 5 minutes
|
|
||||||
filter: (i) => i.customId === "config-modal" && i.user.id === interaction.user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
const jsonString = submitted.fields.getTextInputValue("json-input");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newConfig = JSON.parse(jsonString);
|
|
||||||
saveConfig(newConfig as GameConfigType);
|
|
||||||
|
|
||||||
await submitted.reply({
|
|
||||||
embeds: [createSuccessEmbed("Configuration updated successfully.", "Config Saved")]
|
|
||||||
});
|
|
||||||
} catch (parseError) {
|
|
||||||
await submitted.reply({
|
|
||||||
embeds: [createErrorEmbed(`Invalid JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`, "Config Update Failed")],
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
// Timeout or other error handling if needed, usually just ignore timeouts for modals
|
|
||||||
if (error instanceof Error && error.message.includes('time')) {
|
|
||||||
// specific timeout handling if desired
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { config, saveConfig } from "@shared/lib/config";
|
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
|
||||||
|
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
|
||||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
import { items } from "@db/schema";
|
import { items } from "@db/schema";
|
||||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const createColor = createCommand({
|
export const createColor = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -31,64 +33,60 @@ export const createColor = createCommand({
|
|||||||
)
|
)
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply();
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const name = interaction.options.getString("name", true);
|
||||||
|
const colorInput = interaction.options.getString("color", true);
|
||||||
|
const price = interaction.options.getNumber("price") || 500;
|
||||||
|
const imageUrl = interaction.options.getString("image") || "https://cdn.discordapp.com/attachments/1450061247365124199/1453122950822760559/Main_Chip_1.png";
|
||||||
|
|
||||||
const name = interaction.options.getString("name", true);
|
// 1. Validate Color
|
||||||
const colorInput = interaction.options.getString("color", true);
|
const colorRegex = /^#([0-9A-F]{3}){1,2}$/i;
|
||||||
const price = interaction.options.getNumber("price") || 500;
|
if (!colorRegex.test(colorInput)) {
|
||||||
const imageUrl = interaction.options.getString("image") || "https://cdn.discordapp.com/attachments/1450061247365124199/1453122950822760559/Main_Chip_1.png";
|
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Hex Color code. Format: #RRGGBB")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Validate Color
|
// 2. Create Role
|
||||||
const colorRegex = /^#([0-9A-F]{3}){1,2}$/i;
|
const role = await interaction.guild?.roles.create({
|
||||||
if (!colorRegex.test(colorInput)) {
|
name: name,
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Hex Color code. Format: #RRGGBB")] });
|
color: colorInput as any,
|
||||||
return;
|
reason: `Created via /createcolor by ${interaction.user.tag}`
|
||||||
}
|
});
|
||||||
|
|
||||||
try {
|
if (!role) {
|
||||||
// 2. Create Role
|
throw new Error("Failed to create role.");
|
||||||
const role = await interaction.guild?.roles.create({
|
}
|
||||||
name: name,
|
|
||||||
color: colorInput as any, // Discord.js types are a bit strict on ColorResolvable, but string generally works or needs parsing
|
|
||||||
reason: `Created via /createcolor by ${interaction.user.tag}`
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!role) {
|
// 3. Add to guild settings
|
||||||
throw new Error("Failed to create role.");
|
await guildSettingsService.addColorRole(interaction.guildId!, role.id);
|
||||||
}
|
invalidateGuildConfigCache(interaction.guildId!);
|
||||||
|
|
||||||
// 3. Update Config
|
// 4. Create Item
|
||||||
if (!config.colorRoles.includes(role.id)) {
|
await DrizzleClient.insert(items).values({
|
||||||
config.colorRoles.push(role.id);
|
name: `Color Role - ${name}`,
|
||||||
saveConfig(config);
|
description: `Use this item to apply the ${name} color to your name.`,
|
||||||
}
|
type: "CONSUMABLE",
|
||||||
|
rarity: "Common",
|
||||||
|
price: BigInt(price),
|
||||||
|
iconUrl: "",
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
usageData: {
|
||||||
|
consume: false,
|
||||||
|
effects: [{ type: "COLOR_ROLE", roleId: role.id }]
|
||||||
|
} as any
|
||||||
|
});
|
||||||
|
|
||||||
// 4. Create Item
|
// 5. Success
|
||||||
await DrizzleClient.insert(items).values({
|
await interaction.editReply({
|
||||||
name: `Color Role - ${name}`,
|
embeds: [createSuccessEmbed(
|
||||||
description: `Use this item to apply the ${name} color to your name.`,
|
`**Role:** <@&${role.id}> (${colorInput})\n**Item:** Color Role - ${name}\n**Price:** ${price} 🪙`,
|
||||||
type: "CONSUMABLE",
|
"✅ Color Role & Item Created"
|
||||||
rarity: "Common",
|
)]
|
||||||
price: BigInt(price),
|
});
|
||||||
iconUrl: "",
|
},
|
||||||
imageUrl: imageUrl,
|
{ ephemeral: true }
|
||||||
usageData: {
|
);
|
||||||
consume: false,
|
|
||||||
effects: [{ type: "COLOR_ROLE", roleId: role.id }]
|
|
||||||
} as any
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Success
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [createSuccessEmbed(
|
|
||||||
`**Role:** <@&${role.id}> (${colorInput})\n**Item:** Color Role - ${name}\n**Price:** ${price} 🪙`,
|
|
||||||
"✅ Color Role & Item Created"
|
|
||||||
)]
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error in createcolor:", error);
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed(`Failed to create color role: ${error.message}`)] });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
293
bot/commands/admin/featureflags.ts
Normal file
293
bot/commands/admin/featureflags.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { createCommand } from "@shared/lib/utils";
|
||||||
|
import { SlashCommandBuilder, PermissionFlagsBits, Colors, userMention, roleMention, ChatInputCommandInteraction } from "discord.js";
|
||||||
|
import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service";
|
||||||
|
import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
|
export const featureflags = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("featureflags")
|
||||||
|
.setDescription("Manage feature flags for beta testing")
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("list")
|
||||||
|
.setDescription("List all feature flags")
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("create")
|
||||||
|
.setDescription("Create a new feature flag")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("name")
|
||||||
|
.setDescription("Name of the feature flag")
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("description")
|
||||||
|
.setDescription("Description of the feature flag")
|
||||||
|
.setRequired(false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("delete")
|
||||||
|
.setDescription("Delete a feature flag")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("name")
|
||||||
|
.setDescription("Name of the feature flag")
|
||||||
|
.setRequired(true)
|
||||||
|
.setAutocomplete(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("enable")
|
||||||
|
.setDescription("Enable a feature flag")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("name")
|
||||||
|
.setDescription("Name of the feature flag")
|
||||||
|
.setRequired(true)
|
||||||
|
.setAutocomplete(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("disable")
|
||||||
|
.setDescription("Disable a feature flag")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("name")
|
||||||
|
.setDescription("Name of the feature flag")
|
||||||
|
.setRequired(true)
|
||||||
|
.setAutocomplete(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("grant")
|
||||||
|
.setDescription("Grant access to a feature flag")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("name")
|
||||||
|
.setDescription("Name of the feature flag")
|
||||||
|
.setRequired(true)
|
||||||
|
.setAutocomplete(true)
|
||||||
|
)
|
||||||
|
.addUserOption(opt =>
|
||||||
|
opt.setName("user")
|
||||||
|
.setDescription("User to grant access to")
|
||||||
|
.setRequired(false)
|
||||||
|
)
|
||||||
|
.addRoleOption(opt =>
|
||||||
|
opt.setName("role")
|
||||||
|
.setDescription("Role to grant access to")
|
||||||
|
.setRequired(false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("revoke")
|
||||||
|
.setDescription("Revoke access from a feature flag")
|
||||||
|
.addIntegerOption(opt =>
|
||||||
|
opt.setName("id")
|
||||||
|
.setDescription("Access record ID to revoke")
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("access")
|
||||||
|
.setDescription("List access records for a feature flag")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("name")
|
||||||
|
.setDescription("Name of the feature flag")
|
||||||
|
.setRequired(true)
|
||||||
|
.setAutocomplete(true)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
autocomplete: async (interaction) => {
|
||||||
|
const focused = interaction.options.getFocused(true);
|
||||||
|
|
||||||
|
if (focused.name === "name") {
|
||||||
|
const flags = await featureFlagsService.listFlags();
|
||||||
|
const filtered = flags
|
||||||
|
.filter(f => f.name.toLowerCase().includes(focused.value.toLowerCase()))
|
||||||
|
.slice(0, 25);
|
||||||
|
|
||||||
|
await interaction.respond(
|
||||||
|
filtered.map(f => ({ name: `${f.name} (${f.enabled ? "enabled" : "disabled"})`, value: f.name }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
execute: async (interaction) => {
|
||||||
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
switch (subcommand) {
|
||||||
|
case "list":
|
||||||
|
await handleList(interaction);
|
||||||
|
break;
|
||||||
|
case "create":
|
||||||
|
await handleCreate(interaction);
|
||||||
|
break;
|
||||||
|
case "delete":
|
||||||
|
await handleDelete(interaction);
|
||||||
|
break;
|
||||||
|
case "enable":
|
||||||
|
await handleEnable(interaction);
|
||||||
|
break;
|
||||||
|
case "disable":
|
||||||
|
await handleDisable(interaction);
|
||||||
|
break;
|
||||||
|
case "grant":
|
||||||
|
await handleGrant(interaction);
|
||||||
|
break;
|
||||||
|
case "revoke":
|
||||||
|
await handleRevoke(interaction);
|
||||||
|
break;
|
||||||
|
case "access":
|
||||||
|
await handleAccess(interaction);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleList(interaction: ChatInputCommandInteraction) {
|
||||||
|
const flags = await featureFlagsService.listFlags();
|
||||||
|
|
||||||
|
if (flags.length === 0) {
|
||||||
|
await interaction.editReply({ embeds: [createBaseEmbed("Feature Flags", "No feature flags have been created yet.", Colors.Blue)] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = createBaseEmbed("Feature Flags", undefined, Colors.Blue)
|
||||||
|
.addFields(
|
||||||
|
flags.map(f => ({
|
||||||
|
name: f.name,
|
||||||
|
value: `${f.enabled ? "✅ Enabled" : "❌ Disabled"}\n${f.description || "*No description*"}`,
|
||||||
|
inline: false,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate(interaction: ChatInputCommandInteraction) {
|
||||||
|
const name = interaction.options.getString("name", true);
|
||||||
|
const description = interaction.options.getString("description");
|
||||||
|
|
||||||
|
const flag = await featureFlagsService.createFlag(name, description ?? undefined);
|
||||||
|
|
||||||
|
if (!flag) {
|
||||||
|
await interaction.editReply({ embeds: [createErrorEmbed("Failed to create feature flag.")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" created successfully. Use \`/featureflags enable\` to enable it.`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(interaction: ChatInputCommandInteraction) {
|
||||||
|
const name = interaction.options.getString("name", true);
|
||||||
|
|
||||||
|
const flag = await featureFlagsService.deleteFlag(name);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been deleted.`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEnable(interaction: ChatInputCommandInteraction) {
|
||||||
|
const name = interaction.options.getString("name", true);
|
||||||
|
|
||||||
|
const flag = await featureFlagsService.setFlagEnabled(name, true);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been enabled.`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDisable(interaction: ChatInputCommandInteraction) {
|
||||||
|
const name = interaction.options.getString("name", true);
|
||||||
|
|
||||||
|
const flag = await featureFlagsService.setFlagEnabled(name, false);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been disabled.`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGrant(interaction: ChatInputCommandInteraction) {
|
||||||
|
const name = interaction.options.getString("name", true);
|
||||||
|
const user = interaction.options.getUser("user");
|
||||||
|
const role = interaction.options.getRole("role");
|
||||||
|
|
||||||
|
if (!user && !role) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("You must specify either a user or a role to grant access to.")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await featureFlagsService.grantAccess(name, {
|
||||||
|
userId: user?.id,
|
||||||
|
roleId: role?.id,
|
||||||
|
guildId: interaction.guildId!,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!access) {
|
||||||
|
await interaction.editReply({ embeds: [createErrorEmbed("Failed to grant access.")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target: string;
|
||||||
|
if (user) {
|
||||||
|
target = userMention(user.id);
|
||||||
|
} else if (role) {
|
||||||
|
target = roleMention(role.id);
|
||||||
|
} else {
|
||||||
|
target = "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Access to "**${name}**" granted to ${target} (ID: ${access.id})`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevoke(interaction: ChatInputCommandInteraction) {
|
||||||
|
const id = interaction.options.getInteger("id", true);
|
||||||
|
|
||||||
|
const access = await featureFlagsService.revokeAccess(id);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Access record #${access.id} has been revoked.`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAccess(interaction: ChatInputCommandInteraction) {
|
||||||
|
const name = interaction.options.getString("name", true);
|
||||||
|
|
||||||
|
const accessRecords = await featureFlagsService.listAccess(name);
|
||||||
|
|
||||||
|
if (accessRecords.length === 0) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createBaseEmbed("Feature Flag Access", `No access records for "**${name}**".`, Colors.Blue)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = accessRecords.map(a => {
|
||||||
|
let target = "Unknown";
|
||||||
|
if (a.userId) target = `User: ${userMention(a.userId.toString())}`;
|
||||||
|
else if (a.roleId) target = `Role: ${roleMention(a.roleId.toString())}`;
|
||||||
|
else if (a.guildId) target = `Guild: ${a.guildId.toString()}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: `ID: ${a.id}`,
|
||||||
|
value: target,
|
||||||
|
inline: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const embed = createBaseEmbed(`Feature Flag Access: ${name}`, undefined, Colors.Blue)
|
||||||
|
.addFields(fields);
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
}
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
|
||||||
import { createBaseEmbed } from "@lib/embeds";
|
|
||||||
import { config, reloadConfig, toggleCommand } from "@shared/lib/config";
|
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
|
||||||
|
|
||||||
export const features = createCommand({
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName("features")
|
|
||||||
.setDescription("Manage bot features and commands")
|
|
||||||
.addSubcommand(sub =>
|
|
||||||
sub.setName("list")
|
|
||||||
.setDescription("List all commands and their status")
|
|
||||||
)
|
|
||||||
.addSubcommand(sub =>
|
|
||||||
sub.setName("toggle")
|
|
||||||
.setDescription("Enable or disable a command")
|
|
||||||
.addStringOption(option =>
|
|
||||||
option.setName("command")
|
|
||||||
.setDescription("The name of the command")
|
|
||||||
.setRequired(true)
|
|
||||||
)
|
|
||||||
.addBooleanOption(option =>
|
|
||||||
option.setName("enabled")
|
|
||||||
.setDescription("Whether the command should be enabled")
|
|
||||||
.setRequired(true)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
|
||||||
execute: async (interaction) => {
|
|
||||||
const subcommand = interaction.options.getSubcommand();
|
|
||||||
|
|
||||||
if (subcommand === "list") {
|
|
||||||
const activeCommands = AuroraClient.commands;
|
|
||||||
const categories = new Map<string, string[]>();
|
|
||||||
|
|
||||||
// Group active commands
|
|
||||||
activeCommands.forEach(cmd => {
|
|
||||||
const cat = cmd.category || 'Uncategorized';
|
|
||||||
if (!categories.has(cat)) categories.set(cat, []);
|
|
||||||
categories.get(cat)!.push(cmd.data.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Config overrides
|
|
||||||
const overrides = Object.entries(config.commands)
|
|
||||||
.map(([name, enabled]) => `• **${name}**: ${enabled ? "✅ Enabled (Override)" : "❌ Disabled"}`);
|
|
||||||
|
|
||||||
const embed = createBaseEmbed("Command Features", undefined, "Blue");
|
|
||||||
|
|
||||||
// Add fields for each category
|
|
||||||
const sortedCategories = [...categories.keys()].sort();
|
|
||||||
for (const cat of sortedCategories) {
|
|
||||||
const cmds = categories.get(cat)!.sort();
|
|
||||||
const cmdList = cmds.map(name => {
|
|
||||||
const isOverride = config.commands[name] !== undefined;
|
|
||||||
return isOverride ? `**${name}** (See Overrides)` : `**${name}**`;
|
|
||||||
}).join(", ");
|
|
||||||
|
|
||||||
embed.addFields({ name: `📂 ${cat.toUpperCase()}`, value: cmdList || "None" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overrides.length > 0) {
|
|
||||||
embed.addFields({ name: "⚙️ Configuration Overrides", value: overrides.join("\n") });
|
|
||||||
} else {
|
|
||||||
embed.addFields({ name: "⚙️ Configuration Overrides", value: "No overrides set." });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check permissions manually as a fallback (though defaultMemberPermissions handles it at the API level)
|
|
||||||
if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) {
|
|
||||||
await interaction.reply({ content: "❌ You need Administrator permissions to use this command.", flags: MessageFlags.Ephemeral });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
|
|
||||||
} else if (subcommand === "toggle") {
|
|
||||||
const commandName = interaction.options.getString("command", true);
|
|
||||||
const enabled = interaction.options.getBoolean("enabled", true);
|
|
||||||
|
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
|
||||||
|
|
||||||
toggleCommand(commandName, enabled);
|
|
||||||
|
|
||||||
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Reloading configuration...` });
|
|
||||||
|
|
||||||
// Reload config from disk (which was updated by toggleCommand)
|
|
||||||
reloadConfig();
|
|
||||||
|
|
||||||
await AuroraClient.loadCommands(true);
|
|
||||||
await AuroraClient.deployCommands();
|
|
||||||
|
|
||||||
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Commands reloaded!` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,20 +1,18 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import {
|
import {
|
||||||
SlashCommandBuilder,
|
SlashCommandBuilder,
|
||||||
ActionRowBuilder,
|
|
||||||
ButtonBuilder,
|
|
||||||
ButtonStyle,
|
|
||||||
type BaseGuildTextChannel,
|
type BaseGuildTextChannel,
|
||||||
PermissionFlagsBits,
|
PermissionFlagsBits,
|
||||||
MessageFlags
|
MessageFlags
|
||||||
} from "discord.js";
|
} from "discord.js";
|
||||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds";
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
import { UserError } from "@/lib/errors";
|
|
||||||
import { items } from "@db/schema";
|
import { items } from "@db/schema";
|
||||||
import { ilike, isNotNull, and } from "drizzle-orm";
|
import { ilike, isNotNull, and, inArray } from "drizzle-orm";
|
||||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
import { getShopListingMessage } from "@/modules/economy/shop.view";
|
import { getShopListingMessage } from "@/modules/economy/shop.view";
|
||||||
|
import { EffectType, LootType } from "@shared/lib/constants";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const listing = createCommand({
|
export const listing = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -33,44 +31,67 @@ export const listing = createCommand({
|
|||||||
)
|
)
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const itemId = interaction.options.getNumber("item", true);
|
||||||
|
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
|
||||||
|
|
||||||
const itemId = interaction.options.getNumber("item", true);
|
if (!targetChannel || !targetChannel.isSendable()) {
|
||||||
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
|
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!targetChannel || !targetChannel.isSendable()) {
|
const item = await inventoryService.getItem(itemId);
|
||||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] });
|
if (!item) {
|
||||||
return;
|
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] });
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const item = await inventoryService.getItem(itemId);
|
if (!item.price) {
|
||||||
if (!item) {
|
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item "${item.name}" is not for sale (no price set).`)] });
|
||||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] });
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!item.price) {
|
// Prepare context for lootboxes
|
||||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item "${item.name}" is not for sale (no price set).`)] });
|
const context: { referencedItems: Map<number, { name: string; rarity: string }> } = { referencedItems: new Map() };
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const listingMessage = getShopListingMessage({
|
const usageData = item.usageData as any;
|
||||||
...item,
|
const lootboxEffect = usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX);
|
||||||
formattedPrice: `${item.price} 🪙`,
|
|
||||||
price: item.price
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
if (lootboxEffect && lootboxEffect.pool) {
|
||||||
await targetChannel.send(listingMessage);
|
const itemIds = lootboxEffect.pool
|
||||||
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
|
.filter((drop: any) => drop.type === LootType.ITEM && drop.itemId)
|
||||||
} catch (error: any) {
|
.map((drop: any) => drop.itemId);
|
||||||
if (error instanceof UserError) {
|
|
||||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
if (itemIds.length > 0) {
|
||||||
} else {
|
// Remove duplicates
|
||||||
console.error("Error creating listing:", error);
|
const uniqueIds = [...new Set(itemIds)] as number[];
|
||||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
|
||||||
}
|
const referencedItems = await DrizzleClient.select({
|
||||||
}
|
id: items.id,
|
||||||
|
name: items.name,
|
||||||
|
rarity: items.rarity
|
||||||
|
}).from(items).where(inArray(items.id, uniqueIds));
|
||||||
|
|
||||||
|
for (const ref of referencedItems) {
|
||||||
|
context.referencedItems.set(ref.id, { name: ref.name, rarity: ref.rarity || 'C' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listingMessage = getShopListingMessage({
|
||||||
|
...item,
|
||||||
|
rarity: item.rarity || undefined,
|
||||||
|
formattedPrice: `${item.price} 🪙`,
|
||||||
|
price: item.price
|
||||||
|
}, context);
|
||||||
|
|
||||||
|
await targetChannel.send(listingMessage as any);
|
||||||
|
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
|
||||||
|
},
|
||||||
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
},
|
},
|
||||||
autocomplete: async (interaction) => {
|
autocomplete: async (interaction) => {
|
||||||
const focusedValue = interaction.options.getFocused();
|
const focusedValue = interaction.options.getFocused();
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { CaseType } from "@shared/lib/constants";
|
import { CaseType } from "@shared/lib/constants";
|
||||||
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const note = createCommand({
|
export const note = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -24,39 +25,35 @@ export const note = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const targetUser = interaction.options.getUser("user", true);
|
||||||
|
const noteText = interaction.options.getString("note", true);
|
||||||
|
|
||||||
try {
|
// Create the note case
|
||||||
const targetUser = interaction.options.getUser("user", true);
|
const moderationCase = await moderationService.createCase({
|
||||||
const noteText = interaction.options.getString("note", true);
|
type: CaseType.NOTE,
|
||||||
|
userId: targetUser.id,
|
||||||
// Create the note case
|
username: targetUser.username,
|
||||||
const moderationCase = await ModerationService.createCase({
|
moderatorId: interaction.user.id,
|
||||||
type: CaseType.NOTE,
|
moderatorName: interaction.user.username,
|
||||||
userId: targetUser.id,
|
reason: noteText,
|
||||||
username: targetUser.username,
|
|
||||||
moderatorId: interaction.user.id,
|
|
||||||
moderatorName: interaction.user.username,
|
|
||||||
reason: noteText,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!moderationCase) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("Failed to create note.")]
|
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send success message
|
if (!moderationCase) {
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)]
|
embeds: [getModerationErrorEmbed("Failed to create note.")]
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
// Send success message
|
||||||
console.error("Note command error:", error);
|
await interaction.editReply({
|
||||||
await interaction.editReply({
|
embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)]
|
||||||
embeds: [getModerationErrorEmbed("An error occurred while adding the note.")]
|
});
|
||||||
});
|
},
|
||||||
}
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getCasesListEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const notes = createCommand({
|
export const notes = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -16,28 +17,24 @@ export const notes = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const targetUser = interaction.options.getUser("user", true);
|
||||||
|
|
||||||
try {
|
// Get all notes for the user
|
||||||
const targetUser = interaction.options.getUser("user", true);
|
const userNotes = await moderationService.getUserNotes(targetUser.id);
|
||||||
|
|
||||||
// Get all notes for the user
|
// Display the notes
|
||||||
const userNotes = await ModerationService.getUserNotes(targetUser.id);
|
await interaction.editReply({
|
||||||
|
embeds: [getCasesListEmbed(
|
||||||
// Display the notes
|
userNotes,
|
||||||
await interaction.editReply({
|
`📝 Staff Notes for ${targetUser.username}`,
|
||||||
embeds: [getCasesListEmbed(
|
userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**`
|
||||||
userNotes,
|
)]
|
||||||
`📝 Staff Notes for ${targetUser.username}`,
|
});
|
||||||
userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**`
|
},
|
||||||
)]
|
{ ephemeral: true }
|
||||||
});
|
);
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Notes command error:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("An error occurred while fetching notes.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||||
import { config } from "@shared/lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
import { PruneService } from "@shared/modules/moderation/prune.service";
|
import { pruneService } from "@shared/modules/moderation/prune.service";
|
||||||
import {
|
import {
|
||||||
getConfirmationMessage,
|
getConfirmationMessage,
|
||||||
getProgressEmbed,
|
getProgressEmbed,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getPruneWarningEmbed,
|
getPruneWarningEmbed,
|
||||||
getCancelledEmbed
|
getCancelledEmbed
|
||||||
} from "@/modules/moderation/prune.view";
|
} from "@/modules/moderation/prune.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const prune = createCommand({
|
export const prune = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -38,142 +39,126 @@ export const prune = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const amount = interaction.options.getInteger("amount");
|
||||||
|
const user = interaction.options.getUser("user");
|
||||||
|
const all = interaction.options.getBoolean("all") || false;
|
||||||
|
|
||||||
try {
|
// Validate inputs
|
||||||
const amount = interaction.options.getInteger("amount");
|
if (!amount && !all) {
|
||||||
const user = interaction.options.getUser("user");
|
// Default to 10 messages
|
||||||
const all = interaction.options.getBoolean("all") || false;
|
} else if (amount && all) {
|
||||||
|
|
||||||
// Validate inputs
|
|
||||||
if (!amount && !all) {
|
|
||||||
// Default to 10 messages
|
|
||||||
} else if (amount && all) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getPruneErrorEmbed("Cannot specify both `amount` and `all`. Please use one or the other.")]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalAmount = all ? 'all' : (amount || 10);
|
|
||||||
const confirmThreshold = config.moderation.prune.confirmThreshold;
|
|
||||||
|
|
||||||
// Check if confirmation is needed
|
|
||||||
const needsConfirmation = all || (typeof finalAmount === 'number' && finalAmount > confirmThreshold);
|
|
||||||
|
|
||||||
if (needsConfirmation) {
|
|
||||||
// Estimate message count for confirmation
|
|
||||||
let estimatedCount: number | undefined;
|
|
||||||
if (all) {
|
|
||||||
try {
|
|
||||||
estimatedCount = await PruneService.estimateMessageCount(interaction.channel!);
|
|
||||||
} catch {
|
|
||||||
estimatedCount = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { embeds, components } = getConfirmationMessage(finalAmount, estimatedCount);
|
|
||||||
const response = await interaction.editReply({ embeds, components });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const confirmation = await response.awaitMessageComponent({
|
|
||||||
filter: (i) => i.user.id === interaction.user.id,
|
|
||||||
componentType: ComponentType.Button,
|
|
||||||
time: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
if (confirmation.customId === "cancel_prune") {
|
|
||||||
await confirmation.update({
|
|
||||||
embeds: [getCancelledEmbed()],
|
|
||||||
components: []
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// User confirmed, proceed with deletion
|
|
||||||
await confirmation.update({
|
|
||||||
embeds: [getProgressEmbed({ current: 0, total: estimatedCount || finalAmount as number })],
|
|
||||||
components: []
|
|
||||||
});
|
|
||||||
|
|
||||||
// Execute deletion with progress callback for 'all' mode
|
|
||||||
const result = await PruneService.deleteMessages(
|
|
||||||
interaction.channel!,
|
|
||||||
{
|
|
||||||
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
|
|
||||||
userId: user?.id,
|
|
||||||
all
|
|
||||||
},
|
|
||||||
all ? async (progress) => {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getProgressEmbed(progress)]
|
|
||||||
});
|
|
||||||
} : undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
// Show success
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [getSuccessEmbed(result)],
|
embeds: [getPruneErrorEmbed("Cannot specify both `amount` and `all`. Please use one or the other.")]
|
||||||
components: []
|
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.message.includes("time")) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getPruneWarningEmbed("Confirmation timed out. Please try again.")],
|
|
||||||
components: []
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No confirmation needed, proceed directly
|
|
||||||
const result = await PruneService.deleteMessages(
|
|
||||||
interaction.channel!,
|
|
||||||
{
|
|
||||||
amount: finalAmount as number,
|
|
||||||
userId: user?.id,
|
|
||||||
all: false
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if no messages were found
|
|
||||||
if (result.deletedCount === 0) {
|
|
||||||
if (user) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getPruneWarningEmbed(`No messages found from **${user.username}** in the last ${finalAmount} messages.`)]
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getPruneWarningEmbed("No messages found to delete.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.editReply({
|
const finalAmount = all ? 'all' : (amount || 10);
|
||||||
embeds: [getSuccessEmbed(result)]
|
const confirmThreshold = config.moderation.prune.confirmThreshold;
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
// Check if confirmation is needed
|
||||||
console.error("Prune command error:", error);
|
const needsConfirmation = all || (typeof finalAmount === 'number' && finalAmount > confirmThreshold);
|
||||||
|
|
||||||
let errorMessage = "An unexpected error occurred while trying to delete messages.";
|
if (needsConfirmation) {
|
||||||
if (error instanceof Error) {
|
// Estimate message count for confirmation
|
||||||
if (error.message.includes("permission")) {
|
let estimatedCount: number | undefined;
|
||||||
errorMessage = "I don't have permission to delete messages in this channel.";
|
if (all) {
|
||||||
} else if (error.message.includes("channel type")) {
|
try {
|
||||||
errorMessage = "This command cannot be used in this type of channel.";
|
estimatedCount = await pruneService.estimateMessageCount(interaction.channel!);
|
||||||
|
} catch {
|
||||||
|
estimatedCount = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { embeds, components } = getConfirmationMessage(finalAmount, estimatedCount);
|
||||||
|
const response = await interaction.editReply({ embeds, components });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const confirmation = await response.awaitMessageComponent({
|
||||||
|
filter: (i) => i.user.id === interaction.user.id,
|
||||||
|
componentType: ComponentType.Button,
|
||||||
|
time: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmation.customId === "cancel_prune") {
|
||||||
|
await confirmation.update({
|
||||||
|
embeds: [getCancelledEmbed()],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User confirmed, proceed with deletion
|
||||||
|
await confirmation.update({
|
||||||
|
embeds: [getProgressEmbed({ current: 0, total: estimatedCount || finalAmount as number })],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute deletion with progress callback for 'all' mode
|
||||||
|
const result = await pruneService.deleteMessages(
|
||||||
|
interaction.channel!,
|
||||||
|
{
|
||||||
|
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
|
||||||
|
userId: user?.id,
|
||||||
|
all
|
||||||
|
},
|
||||||
|
all ? async (progress) => {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getProgressEmbed(progress)]
|
||||||
|
});
|
||||||
|
} : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show success
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getSuccessEmbed(result)],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes("time")) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getPruneWarningEmbed("Confirmation timed out. Please try again.")],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
errorMessage = error.message;
|
// No confirmation needed, proceed directly
|
||||||
}
|
const result = await pruneService.deleteMessages(
|
||||||
}
|
interaction.channel!,
|
||||||
|
{
|
||||||
|
amount: finalAmount as number,
|
||||||
|
userId: user?.id,
|
||||||
|
all: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
await interaction.editReply({
|
// Check if no messages were found
|
||||||
embeds: [getPruneErrorEmbed(errorMessage)]
|
if (result.deletedCount === 0) {
|
||||||
});
|
if (user) {
|
||||||
}
|
await interaction.editReply({
|
||||||
|
embeds: [getPruneWarningEmbed(`No messages found from **${user.username}** in the last ${finalAmount} messages.`)]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getPruneWarningEmbed("No messages found to delete.")]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getSuccessEmbed(result)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createSuccessEmbed } from "@lib/embeds";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const refresh = createCommand({
|
export const refresh = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -9,25 +10,24 @@ export const refresh = createCommand({
|
|||||||
.setDescription("Reloads all commands and config without restarting")
|
.setDescription("Reloads all commands and config without restarting")
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const start = Date.now();
|
||||||
|
await AuroraClient.loadCommands(true);
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
try {
|
// Deploy commands
|
||||||
const start = Date.now();
|
await AuroraClient.deployCommands();
|
||||||
await AuroraClient.loadCommands(true);
|
|
||||||
const duration = Date.now() - start;
|
|
||||||
|
|
||||||
// Deploy commands
|
const embed = createSuccessEmbed(
|
||||||
await AuroraClient.deployCommands();
|
`Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`,
|
||||||
|
"System Refreshed"
|
||||||
|
);
|
||||||
|
|
||||||
const embed = createSuccessEmbed(
|
await interaction.editReply({ embeds: [embed] });
|
||||||
`Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`,
|
},
|
||||||
"System Refreshed"
|
{ ephemeral: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("An error occurred while refreshing commands. Check console for details.", "Refresh Failed")] });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
243
bot/commands/admin/settings.ts
Normal file
243
bot/commands/admin/settings.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import { createCommand } from "@shared/lib/utils";
|
||||||
|
import { SlashCommandBuilder, PermissionFlagsBits, Colors, ChatInputCommandInteraction } from "discord.js";
|
||||||
|
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
|
||||||
|
import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
|
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
|
export const settings = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("settings")
|
||||||
|
.setDescription("Manage guild settings")
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("show")
|
||||||
|
.setDescription("Show current guild settings"))
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("set")
|
||||||
|
.setDescription("Set a guild setting")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("key")
|
||||||
|
.setDescription("Setting to change")
|
||||||
|
.setRequired(true)
|
||||||
|
.addChoices(
|
||||||
|
{ name: "Student Role", value: "studentRole" },
|
||||||
|
{ name: "Visitor Role", value: "visitorRole" },
|
||||||
|
{ name: "Welcome Channel", value: "welcomeChannel" },
|
||||||
|
{ name: "Welcome Message", value: "welcomeMessage" },
|
||||||
|
{ name: "Feedback Channel", value: "feedbackChannel" },
|
||||||
|
{ name: "Terminal Channel", value: "terminalChannel" },
|
||||||
|
{ name: "Terminal Message", value: "terminalMessage" },
|
||||||
|
{ name: "Moderation Log Channel", value: "moderationLogChannel" },
|
||||||
|
{ name: "DM on Warn", value: "moderationDmOnWarn" },
|
||||||
|
{ name: "Auto Timeout Threshold", value: "moderationAutoTimeoutThreshold" },
|
||||||
|
))
|
||||||
|
.addRoleOption(opt =>
|
||||||
|
opt.setName("role")
|
||||||
|
.setDescription("Role value"))
|
||||||
|
.addChannelOption(opt =>
|
||||||
|
opt.setName("channel")
|
||||||
|
.setDescription("Channel value"))
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("text")
|
||||||
|
.setDescription("Text value"))
|
||||||
|
.addIntegerOption(opt =>
|
||||||
|
opt.setName("number")
|
||||||
|
.setDescription("Number value"))
|
||||||
|
.addBooleanOption(opt =>
|
||||||
|
opt.setName("boolean")
|
||||||
|
.setDescription("Boolean value (true/false)")))
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("reset")
|
||||||
|
.setDescription("Reset a setting to default")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("key")
|
||||||
|
.setDescription("Setting to reset")
|
||||||
|
.setRequired(true)
|
||||||
|
.addChoices(
|
||||||
|
{ name: "Student Role", value: "studentRole" },
|
||||||
|
{ name: "Visitor Role", value: "visitorRole" },
|
||||||
|
{ name: "Welcome Channel", value: "welcomeChannel" },
|
||||||
|
{ name: "Welcome Message", value: "welcomeMessage" },
|
||||||
|
{ name: "Feedback Channel", value: "feedbackChannel" },
|
||||||
|
{ name: "Terminal Channel", value: "terminalChannel" },
|
||||||
|
{ name: "Terminal Message", value: "terminalMessage" },
|
||||||
|
{ name: "Moderation Log Channel", value: "moderationLogChannel" },
|
||||||
|
{ name: "DM on Warn", value: "moderationDmOnWarn" },
|
||||||
|
{ name: "Auto Timeout Threshold", value: "moderationAutoTimeoutThreshold" },
|
||||||
|
)))
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("colors")
|
||||||
|
.setDescription("Manage color roles")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("action")
|
||||||
|
.setDescription("Action to perform")
|
||||||
|
.setRequired(true)
|
||||||
|
.addChoices(
|
||||||
|
{ name: "List", value: "list" },
|
||||||
|
{ name: "Add", value: "add" },
|
||||||
|
{ name: "Remove", value: "remove" },
|
||||||
|
))
|
||||||
|
.addRoleOption(opt =>
|
||||||
|
opt.setName("role")
|
||||||
|
.setDescription("Role to add/remove")
|
||||||
|
.setRequired(false))),
|
||||||
|
|
||||||
|
execute: async (interaction) => {
|
||||||
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
const guildId = interaction.guildId!;
|
||||||
|
|
||||||
|
switch (subcommand) {
|
||||||
|
case "show":
|
||||||
|
await handleShow(interaction, guildId);
|
||||||
|
break;
|
||||||
|
case "set":
|
||||||
|
await handleSet(interaction, guildId);
|
||||||
|
break;
|
||||||
|
case "reset":
|
||||||
|
await handleReset(interaction, guildId);
|
||||||
|
break;
|
||||||
|
case "colors":
|
||||||
|
await handleColors(interaction, guildId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleShow(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||||
|
const settings = await getGuildConfig(guildId);
|
||||||
|
|
||||||
|
const colorRolesDisplay = settings.colorRoles?.length
|
||||||
|
? settings.colorRoles.map(id => `<@&${id}>`).join(", ")
|
||||||
|
: "None";
|
||||||
|
|
||||||
|
const embed = createBaseEmbed("Guild Settings", undefined, Colors.Blue)
|
||||||
|
.addFields(
|
||||||
|
{ name: "Student Role", value: settings.studentRole ? `<@&${settings.studentRole}>` : "Not set", inline: true },
|
||||||
|
{ name: "Visitor Role", value: settings.visitorRole ? `<@&${settings.visitorRole}>` : "Not set", inline: true },
|
||||||
|
{ name: "\u200b", value: "\u200b", inline: true },
|
||||||
|
{ name: "Welcome Channel", value: settings.welcomeChannelId ? `<#${settings.welcomeChannelId}>` : "Not set", inline: true },
|
||||||
|
{ name: "Feedback Channel", value: settings.feedbackChannelId ? `<#${settings.feedbackChannelId}>` : "Not set", inline: true },
|
||||||
|
{ name: "Moderation Log", value: settings.moderation?.cases?.logChannelId ? `<#${settings.moderation.cases.logChannelId}>` : "Not set", inline: true },
|
||||||
|
{ name: "Terminal Channel", value: settings.terminal?.channelId ? `<#${settings.terminal.channelId}>` : "Not set", inline: true },
|
||||||
|
{ name: "DM on Warn", value: settings.moderation?.cases?.dmOnWarn !== false ? "Enabled" : "Disabled", inline: true },
|
||||||
|
{ name: "Auto Timeout", value: settings.moderation?.cases?.autoTimeoutThreshold ? `${settings.moderation.cases.autoTimeoutThreshold} warnings` : "Disabled", inline: true },
|
||||||
|
{ name: "Color Roles", value: colorRolesDisplay, inline: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (settings.welcomeMessage) {
|
||||||
|
embed.addFields({ name: "Welcome Message", value: settings.welcomeMessage.substring(0, 1024), inline: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSet(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||||
|
const key = interaction.options.getString("key", true);
|
||||||
|
const role = interaction.options.getRole("role");
|
||||||
|
const channel = interaction.options.getChannel("channel");
|
||||||
|
const text = interaction.options.getString("text");
|
||||||
|
const number = interaction.options.getInteger("number");
|
||||||
|
const boolean = interaction.options.getBoolean("boolean");
|
||||||
|
|
||||||
|
let value: string | number | boolean | null = null;
|
||||||
|
|
||||||
|
if (role) value = role.id;
|
||||||
|
else if (channel) value = channel.id;
|
||||||
|
else if (text) value = text;
|
||||||
|
else if (number !== null) value = number;
|
||||||
|
else if (boolean !== null) value = boolean;
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("Please provide a role, channel, text, number, or boolean value")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await guildSettingsService.updateSetting(guildId, key, value);
|
||||||
|
invalidateGuildConfigCache(guildId);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Setting "${key}" updated`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReset(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||||
|
const key = interaction.options.getString("key", true);
|
||||||
|
|
||||||
|
await guildSettingsService.updateSetting(guildId, key, null);
|
||||||
|
invalidateGuildConfigCache(guildId);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Setting "${key}" reset to default`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleColors(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||||
|
const action = interaction.options.getString("action", true);
|
||||||
|
const role = interaction.options.getRole("role");
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case "list": {
|
||||||
|
const settings = await getGuildConfig(guildId);
|
||||||
|
const colorRoles = settings.colorRoles ?? [];
|
||||||
|
|
||||||
|
if (colorRoles.length === 0) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createBaseEmbed("Color Roles", "No color roles configured.", Colors.Blue)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = createBaseEmbed("Color Roles", undefined, Colors.Blue)
|
||||||
|
.addFields({
|
||||||
|
name: `Configured Roles (${colorRoles.length})`,
|
||||||
|
value: colorRoles.map(id => `<@&${id}>`).join("\n"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "add": {
|
||||||
|
if (!role) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("Please specify a role to add.")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await guildSettingsService.addColorRole(guildId, role.id);
|
||||||
|
invalidateGuildConfigCache(guildId);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Added <@&${role.id}> to color roles.`)]
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "remove": {
|
||||||
|
if (!role) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("Please specify a role to remove.")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await guildSettingsService.removeColorRole(guildId, role.id);
|
||||||
|
invalidateGuildConfigCache(guildId);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Removed <@&${role.id}> from color roles.`)]
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
|
||||||
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
||||||
import { createBaseEmbed, createErrorEmbed } from "@/lib/embeds";
|
import { createErrorEmbed } from "@/lib/embeds";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const terminal = createCommand({
|
export const terminal = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -23,15 +24,14 @@ export const terminal = createCommand({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.reply({ ephemeral: true, content: "Initializing terminal..." });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
try {
|
async () => {
|
||||||
await terminalService.init(channel as TextChannel);
|
await terminalService.init(channel as TextChannel);
|
||||||
await interaction.editReply({ content: "✅ Terminal initialized!" });
|
await interaction.editReply({ content: "✅ Terminal initialized!" });
|
||||||
} catch (error) {
|
},
|
||||||
console.error(error);
|
{ ephemeral: true }
|
||||||
await interaction.editReply({ content: "❌ Failed to initialize terminal." });
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
|
||||||
import { UpdateService } from "@shared/modules/admin/update.service";
|
|
||||||
import {
|
|
||||||
getCheckingEmbed,
|
|
||||||
getNoUpdatesEmbed,
|
|
||||||
getUpdatesAvailableMessage,
|
|
||||||
getPreparingEmbed,
|
|
||||||
getUpdatingEmbed,
|
|
||||||
getCancelledEmbed,
|
|
||||||
getTimeoutEmbed,
|
|
||||||
getErrorEmbed,
|
|
||||||
getRollbackSuccessEmbed,
|
|
||||||
getRollbackFailedEmbed
|
|
||||||
} from "@/modules/admin/update.view";
|
|
||||||
|
|
||||||
export const update = createCommand({
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName("update")
|
|
||||||
.setDescription("Check for updates and restart the bot")
|
|
||||||
.addSubcommand(sub =>
|
|
||||||
sub.setName("check")
|
|
||||||
.setDescription("Check for and apply available updates")
|
|
||||||
.addBooleanOption(option =>
|
|
||||||
option.setName("force")
|
|
||||||
.setDescription("Force update even if no changes detected")
|
|
||||||
.setRequired(false)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.addSubcommand(sub =>
|
|
||||||
sub.setName("rollback")
|
|
||||||
.setDescription("Rollback to the previous version")
|
|
||||||
)
|
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
|
||||||
|
|
||||||
execute: async (interaction) => {
|
|
||||||
const subcommand = interaction.options.getSubcommand();
|
|
||||||
|
|
||||||
if (subcommand === "rollback") {
|
|
||||||
await handleRollback(interaction);
|
|
||||||
} else {
|
|
||||||
await handleUpdate(interaction);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleUpdate(interaction: any) {
|
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
|
||||||
const force = interaction.options.getBoolean("force") || false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Check for updates
|
|
||||||
await interaction.editReply({ embeds: [getCheckingEmbed()] });
|
|
||||||
const updateInfo = await UpdateService.checkForUpdates();
|
|
||||||
|
|
||||||
if (!updateInfo.hasUpdates && !force) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getNoUpdatesEmbed(updateInfo.currentCommit)]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Analyze requirements
|
|
||||||
const requirements = await UpdateService.checkUpdateRequirements(updateInfo.branch);
|
|
||||||
const categories = UpdateService.categorizeChanges(requirements.changedFiles);
|
|
||||||
|
|
||||||
// 3. Show confirmation with details
|
|
||||||
const { embeds, components } = getUpdatesAvailableMessage(
|
|
||||||
updateInfo,
|
|
||||||
requirements,
|
|
||||||
categories,
|
|
||||||
force
|
|
||||||
);
|
|
||||||
const response = await interaction.editReply({ embeds, components });
|
|
||||||
|
|
||||||
// 4. Wait for confirmation
|
|
||||||
try {
|
|
||||||
const confirmation = await response.awaitMessageComponent({
|
|
||||||
filter: (i: any) => i.user.id === interaction.user.id,
|
|
||||||
componentType: ComponentType.Button,
|
|
||||||
time: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
if (confirmation.customId === "confirm_update") {
|
|
||||||
await confirmation.update({
|
|
||||||
embeds: [getPreparingEmbed()],
|
|
||||||
components: []
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Save rollback point
|
|
||||||
const previousCommit = await UpdateService.saveRollbackPoint();
|
|
||||||
|
|
||||||
// 6. Prepare restart context
|
|
||||||
await UpdateService.prepareRestartContext({
|
|
||||||
channelId: interaction.channelId,
|
|
||||||
userId: interaction.user.id,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
runMigrations: requirements.needsMigrations,
|
|
||||||
installDependencies: requirements.needsRootInstall || requirements.needsWebInstall,
|
|
||||||
previousCommit: previousCommit.substring(0, 7),
|
|
||||||
newCommit: updateInfo.latestCommit
|
|
||||||
});
|
|
||||||
|
|
||||||
// 7. Show updating status
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getUpdatingEmbed(requirements)]
|
|
||||||
});
|
|
||||||
|
|
||||||
// 8. Perform update
|
|
||||||
await UpdateService.performUpdate(updateInfo.branch);
|
|
||||||
|
|
||||||
// 9. Trigger restart
|
|
||||||
await UpdateService.triggerRestart();
|
|
||||||
|
|
||||||
} else {
|
|
||||||
await confirmation.update({
|
|
||||||
embeds: [getCancelledEmbed()],
|
|
||||||
components: []
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Error && e.message.includes("time")) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getTimeoutEmbed()],
|
|
||||||
components: []
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Update failed:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getErrorEmbed(error)],
|
|
||||||
components: []
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRollback(interaction: any) {
|
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const hasRollback = await UpdateService.hasRollbackPoint();
|
|
||||||
|
|
||||||
if (!hasRollback) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getRollbackFailedEmbed("No rollback point available. Rollback is only possible after a recent update.")]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await UpdateService.rollback();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getRollbackSuccessEmbed(result.message.split(" ").pop() || "unknown")]
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restart after rollback
|
|
||||||
setTimeout(() => UpdateService.triggerRestart(), 1000);
|
|
||||||
} else {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getRollbackFailedEmbed(result.message)]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Rollback failed:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getErrorEmbed(error)]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import {
|
import {
|
||||||
getWarnSuccessEmbed,
|
getWarnSuccessEmbed,
|
||||||
getModerationErrorEmbed,
|
getModerationErrorEmbed,
|
||||||
getUserWarningEmbed
|
|
||||||
} from "@/modules/moderation/moderation.view";
|
} from "@/modules/moderation/moderation.view";
|
||||||
import { config } from "@shared/lib/config";
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const warn = createCommand({
|
export const warn = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -28,60 +28,63 @@ export const warn = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const targetUser = interaction.options.getUser("user", true);
|
||||||
|
const reason = interaction.options.getString("reason", true);
|
||||||
|
|
||||||
try {
|
// Don't allow warning bots
|
||||||
const targetUser = interaction.options.getUser("user", true);
|
if (targetUser.bot) {
|
||||||
const reason = interaction.options.getString("reason", true);
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed("You cannot warn bots.")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Don't allow warning bots
|
// Don't allow self-warnings
|
||||||
if (targetUser.bot) {
|
if (targetUser.id === interaction.user.id) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getModerationErrorEmbed("You cannot warn yourself.")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch guild config for moderation settings
|
||||||
|
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||||
|
|
||||||
|
// Issue the warning via service
|
||||||
|
const { moderationCase, warningCount, autoTimeoutIssued } = await moderationService.issueWarning({
|
||||||
|
userId: targetUser.id,
|
||||||
|
username: targetUser.username,
|
||||||
|
moderatorId: interaction.user.id,
|
||||||
|
moderatorName: interaction.user.username,
|
||||||
|
reason,
|
||||||
|
guildName: interaction.guild?.name || undefined,
|
||||||
|
dmTarget: targetUser,
|
||||||
|
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id),
|
||||||
|
config: {
|
||||||
|
dmOnWarn: guildConfig.moderation?.cases?.dmOnWarn,
|
||||||
|
autoTimeoutThreshold: guildConfig.moderation?.cases?.autoTimeoutThreshold,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send success message to moderator
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [getModerationErrorEmbed("You cannot warn bots.")]
|
embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't allow self-warnings
|
// Follow up if auto-timeout was issued
|
||||||
if (targetUser.id === interaction.user.id) {
|
if (autoTimeoutIssued) {
|
||||||
await interaction.editReply({
|
await interaction.followUp({
|
||||||
embeds: [getModerationErrorEmbed("You cannot warn yourself.")]
|
embeds: [getModerationErrorEmbed(
|
||||||
});
|
`⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.`
|
||||||
return;
|
)],
|
||||||
}
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
// Issue the warning via service
|
}
|
||||||
const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({
|
},
|
||||||
userId: targetUser.id,
|
{ ephemeral: true }
|
||||||
username: targetUser.username,
|
);
|
||||||
moderatorId: interaction.user.id,
|
|
||||||
moderatorName: interaction.user.username,
|
|
||||||
reason,
|
|
||||||
guildName: interaction.guild?.name || undefined,
|
|
||||||
dmTarget: targetUser,
|
|
||||||
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send success message to moderator
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
|
|
||||||
});
|
|
||||||
|
|
||||||
// Follow up if auto-timeout was issued
|
|
||||||
if (autoTimeoutIssued) {
|
|
||||||
await interaction.followUp({
|
|
||||||
embeds: [getModerationErrorEmbed(
|
|
||||||
`⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.`
|
|
||||||
)],
|
|
||||||
flags: MessageFlags.Ephemeral
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Warn command error:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("An error occurred while issuing the warning.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getWarningsEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const warnings = createCommand({
|
export const warnings = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -16,24 +17,20 @@ export const warnings = createCommand({
|
|||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const targetUser = interaction.options.getUser("user", true);
|
||||||
|
|
||||||
try {
|
// Get active warnings for the user
|
||||||
const targetUser = interaction.options.getUser("user", true);
|
const activeWarnings = await moderationService.getUserWarnings(targetUser.id);
|
||||||
|
|
||||||
// Get active warnings for the user
|
// Display the warnings
|
||||||
const activeWarnings = await ModerationService.getUserWarnings(targetUser.id);
|
await interaction.editReply({
|
||||||
|
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)]
|
||||||
// Display the warnings
|
});
|
||||||
await interaction.editReply({
|
},
|
||||||
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)]
|
{ ephemeral: true }
|
||||||
});
|
);
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Warnings command error:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("An error occurred while fetching warnings.")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createCommand } from "@shared/lib/utils";
|
|||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { createErrorEmbed } from "@/lib/embeds";
|
import { createErrorEmbed } from "@/lib/embeds";
|
||||||
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const webhook = createCommand({
|
export const webhook = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -14,43 +15,40 @@ export const webhook = createCommand({
|
|||||||
.setRequired(true)
|
.setRequired(true)
|
||||||
),
|
),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const payloadString = interaction.options.getString("payload", true);
|
||||||
|
let payload;
|
||||||
|
|
||||||
const payloadString = interaction.options.getString("payload", true);
|
try {
|
||||||
let payload;
|
payload = JSON.parse(payloadString);
|
||||||
|
} catch (error) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("The provided payload is not valid JSON.", "Invalid JSON")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const channel = interaction.channel;
|
||||||
payload = JSON.parse(payloadString);
|
|
||||||
} catch (error) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [createErrorEmbed("The provided payload is not valid JSON.", "Invalid JSON")]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const channel = interaction.channel;
|
if (!channel || !('createWebhook' in channel)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("This channel does not support webhooks.", "Unsupported Channel")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!channel || !('createWebhook' in channel)) {
|
await sendWebhookMessage(
|
||||||
await interaction.editReply({
|
channel,
|
||||||
embeds: [createErrorEmbed("This channel does not support webhooks.", "Unsupported Channel")]
|
payload,
|
||||||
});
|
interaction.client.user,
|
||||||
return;
|
`Proxy message requested by ${interaction.user.tag}`
|
||||||
}
|
);
|
||||||
|
|
||||||
try {
|
await interaction.editReply({ content: "Message sent successfully!" });
|
||||||
await sendWebhookMessage(
|
},
|
||||||
channel,
|
{ ephemeral: true }
|
||||||
payload,
|
);
|
||||||
interaction.client.user,
|
|
||||||
`Proxy message requested by ${interaction.user.tag}`
|
|
||||||
);
|
|
||||||
|
|
||||||
await interaction.editReply({ content: "Message sent successfully!" });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Webhook error:", error);
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [createErrorEmbed("Failed to send message via webhook. Ensure the bot has 'Manage Webhooks' permission and the payload is valid.", "Delivery Failed")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,34 +2,29 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { economyService } from "@shared/modules/economy/economy.service";
|
import { economyService } from "@shared/modules/economy/economy.service";
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createSuccessEmbed } from "@lib/embeds";
|
||||||
import { UserError } from "@/lib/errors";
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const daily = createCommand({
|
export const daily = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
.setName("daily")
|
.setName("daily")
|
||||||
.setDescription("Claim your daily reward"),
|
.setDescription("Claim your daily reward"),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
try {
|
await withCommandErrorHandling(
|
||||||
const result = await economyService.claimDaily(interaction.user.id);
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const result = await economyService.claimDaily(interaction.user.id);
|
||||||
|
|
||||||
const embed = createSuccessEmbed(`You claimed ** ${result.amount}** Astral Units!${result.isWeekly ? `\n🎉 **Weekly Bonus!** +${result.weeklyBonus} extra!` : ''}`, "💰 Daily Reward Claimed!")
|
const embed = createSuccessEmbed(`You claimed ** ${result.amount}** Astral Units!${result.isWeekly ? `\n🎉 **Weekly Bonus!** +${result.weeklyBonus} extra!` : ''}`, "💰 Daily Reward Claimed!")
|
||||||
.addFields(
|
.addFields(
|
||||||
{ name: "Streak", value: `🔥 ${result.streak} days`, inline: true },
|
{ name: "Streak", value: `🔥 ${result.streak} days`, inline: true },
|
||||||
{ name: "Weekly Progress", value: `${"🟩".repeat(result.streak % 7 || 7)}${"⬜".repeat(7 - (result.streak % 7 || 7))} (${result.streak % 7 || 7}/7)`, inline: true },
|
{ name: "Weekly Progress", value: `${"🟩".repeat(result.streak % 7 || 7)}${"⬜".repeat(7 - (result.streak % 7 || 7))} (${result.streak % 7 || 7}/7)`, inline: true },
|
||||||
{ name: "Next Reward", value: `<t:${Math.floor(result.nextReadyAt.getTime() / 1000)}:R> `, inline: true }
|
{ name: "Next Reward", value: `<t:${Math.floor(result.nextReadyAt.getTime() / 1000)}:R> `, inline: true }
|
||||||
)
|
)
|
||||||
.setColor("Gold");
|
.setColor("Gold");
|
||||||
|
|
||||||
await interaction.reply({ embeds: [embed] });
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error instanceof UserError) {
|
|
||||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
|
||||||
} else {
|
|
||||||
console.error("Error claiming daily:", error);
|
|
||||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1,21 +1,8 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { userService } from "@shared/modules/user/user.service";
|
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
import { UserError } from "@/lib/errors";
|
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
|
||||||
import { userTimers, users } from "@db/schema";
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
import { eq, and, sql } from "drizzle-orm";
|
|
||||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
|
||||||
import { config } from "@shared/lib/config";
|
|
||||||
import { TimerType } from "@shared/lib/constants";
|
|
||||||
|
|
||||||
const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
|
|
||||||
const EXAM_TIMER_KEY = 'default';
|
|
||||||
|
|
||||||
interface ExamMetadata {
|
|
||||||
examDay: number;
|
|
||||||
lastXp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||||
|
|
||||||
@@ -24,182 +11,62 @@ export const exam = createCommand({
|
|||||||
.setName("exam")
|
.setName("exam")
|
||||||
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
|
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply();
|
await withCommandErrorHandling(
|
||||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
interaction,
|
||||||
if (!user) {
|
async () => {
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to retrieve user data.")] });
|
// First, try to take the exam or check status
|
||||||
return;
|
const result = await examService.takeExam(interaction.user.id);
|
||||||
}
|
|
||||||
const now = new Date();
|
|
||||||
const currentDay = now.getDay();
|
|
||||||
|
|
||||||
try {
|
if (result.status === ExamStatus.NOT_REGISTERED) {
|
||||||
// 1. Fetch existing timer/exam data
|
// Register the user
|
||||||
const timer = await DrizzleClient.query.userTimers.findFirst({
|
const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username);
|
||||||
where: and(
|
const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000);
|
||||||
eq(userTimers.userId, user.id),
|
|
||||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
|
||||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. First Run Logic
|
await interaction.editReply({
|
||||||
if (!timer) {
|
embeds: [createSuccessEmbed(
|
||||||
// Set exam day to today
|
`You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` +
|
||||||
const nextExamDate = new Date(now);
|
`Come back on <t:${nextRegTimestamp}:D> (<t:${nextRegTimestamp}:R>) to take your first exam!`,
|
||||||
nextExamDate.setDate(now.getDate() + 7);
|
"Exam Registration Successful"
|
||||||
nextExamDate.setHours(0, 0, 0, 0);
|
)]
|
||||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const metadata: ExamMetadata = {
|
const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000);
|
||||||
examDay: currentDay,
|
|
||||||
lastXp: (user.xp ?? 0n).toString()
|
|
||||||
};
|
|
||||||
|
|
||||||
await DrizzleClient.insert(userTimers).values({
|
if (result.status === ExamStatus.COOLDOWN) {
|
||||||
userId: user.id,
|
await interaction.editReply({
|
||||||
type: EXAM_TIMER_TYPE,
|
embeds: [createErrorEmbed(
|
||||||
key: EXAM_TIMER_KEY,
|
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
||||||
expiresAt: nextExamDate,
|
`Next exam available: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`
|
||||||
metadata: metadata
|
)]
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === ExamStatus.MISSED) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed(
|
||||||
|
`You missed your exam day! Your exam day is **${DAYS[result.examDay!]}** (Server Time).\n` +
|
||||||
|
`You verify your attendance but score a **0**.\n` +
|
||||||
|
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
|
||||||
|
"Exam Failed"
|
||||||
|
)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it reached here with AVAILABLE, it means they passed
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [createSuccessEmbed(
|
embeds: [createSuccessEmbed(
|
||||||
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}** (Server Time).\n` +
|
`**XP Gained:** ${result.xpDiff?.toString()}\n` +
|
||||||
`Come back on <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>) to take your first exam!`,
|
`**Multiplier:** x${result.multiplier?.toFixed(2)}\n` +
|
||||||
"Exam Registration Successful"
|
`**Reward:** ${result.reward?.toString()} Currency\n\n` +
|
||||||
|
`See you next week: <t:${nextExamTimestamp}:D>`,
|
||||||
|
"Exam Passed!"
|
||||||
)]
|
)]
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
const metadata = timer.metadata as unknown as ExamMetadata;
|
|
||||||
const examDay = metadata.examDay;
|
|
||||||
|
|
||||||
// 3. Cooldown Check
|
|
||||||
const expiresAt = new Date(timer.expiresAt);
|
|
||||||
expiresAt.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
if (now < expiresAt) {
|
|
||||||
// Calculate time remaining
|
|
||||||
const timestamp = Math.floor(expiresAt.getTime() / 1000);
|
|
||||||
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [createErrorEmbed(
|
|
||||||
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
|
||||||
`Next exam available: <t:${timestamp}:D> (<t:${timestamp}:R>)`
|
|
||||||
)]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Day Check
|
|
||||||
if (currentDay !== examDay) {
|
|
||||||
// Calculate next correct exam day to correct the schedule
|
|
||||||
let daysUntil = (examDay - currentDay + 7) % 7;
|
|
||||||
if (daysUntil === 0) daysUntil = 7;
|
|
||||||
|
|
||||||
const nextExamDate = new Date(now);
|
|
||||||
nextExamDate.setDate(now.getDate() + daysUntil);
|
|
||||||
nextExamDate.setHours(0, 0, 0, 0);
|
|
||||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
|
||||||
|
|
||||||
const newMetadata: ExamMetadata = {
|
|
||||||
examDay: examDay,
|
|
||||||
lastXp: (user.xp ?? 0n).toString()
|
|
||||||
};
|
|
||||||
|
|
||||||
await DrizzleClient.update(userTimers)
|
|
||||||
.set({
|
|
||||||
expiresAt: nextExamDate,
|
|
||||||
metadata: newMetadata
|
|
||||||
})
|
|
||||||
.where(and(
|
|
||||||
eq(userTimers.userId, user.id),
|
|
||||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
|
||||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
|
||||||
));
|
|
||||||
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [createErrorEmbed(
|
|
||||||
`You missed your exam day! Your exam day is **${DAYS[examDay]}** (Server Time).\n` +
|
|
||||||
`You verify your attendance but score a **0**.\n` +
|
|
||||||
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
|
|
||||||
"Exam Failed"
|
|
||||||
)]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Reward Calculation
|
|
||||||
const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case
|
|
||||||
const currentXp = user.xp ?? 0n;
|
|
||||||
const diff = currentXp - lastXp;
|
|
||||||
|
|
||||||
// Calculate Reward
|
|
||||||
const multMin = config.economy.exam.multMin;
|
|
||||||
const multMax = config.economy.exam.multMax;
|
|
||||||
const multiplier = Math.random() * (multMax - multMin) + multMin;
|
|
||||||
|
|
||||||
// Allow negative reward? existing description implies "difference", usually gain.
|
|
||||||
// If diff is negative (lost XP?), reward might be 0.
|
|
||||||
let reward = 0n;
|
|
||||||
if (diff > 0n) {
|
|
||||||
reward = BigInt(Math.floor(Number(diff) * multiplier));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Update State
|
|
||||||
const nextExamDate = new Date(now);
|
|
||||||
nextExamDate.setDate(now.getDate() + 7);
|
|
||||||
nextExamDate.setHours(0, 0, 0, 0);
|
|
||||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
|
||||||
|
|
||||||
const newMetadata: ExamMetadata = {
|
|
||||||
examDay: examDay,
|
|
||||||
lastXp: currentXp.toString()
|
|
||||||
};
|
|
||||||
|
|
||||||
await DrizzleClient.transaction(async (tx) => {
|
|
||||||
// Update Timer
|
|
||||||
await tx.update(userTimers)
|
|
||||||
.set({
|
|
||||||
expiresAt: nextExamDate,
|
|
||||||
metadata: newMetadata
|
|
||||||
})
|
|
||||||
.where(and(
|
|
||||||
eq(userTimers.userId, user.id),
|
|
||||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
|
||||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
|
||||||
));
|
|
||||||
|
|
||||||
// Add Currency
|
|
||||||
if (reward > 0n) {
|
|
||||||
await tx.update(users)
|
|
||||||
.set({
|
|
||||||
balance: sql`${users.balance} + ${reward}`
|
|
||||||
})
|
|
||||||
.where(eq(users.id, user.id));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [createSuccessEmbed(
|
|
||||||
`**XP Gained:** ${diff.toString()}\n` +
|
|
||||||
`**Multiplier:** x${multiplier.toFixed(2)}\n` +
|
|
||||||
`**Reward:** ${reward.toString()} Currency\n\n` +
|
|
||||||
`See you next week: <t:${nextExamTimestamp}:D>`,
|
|
||||||
"Exam Passed!"
|
|
||||||
)]
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error instanceof UserError) {
|
|
||||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
|
||||||
} else {
|
|
||||||
console.error("Error in exam command:", error);
|
|
||||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { economyService } from "@shared/modules/economy/economy.service";
|
|||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { config } from "@shared/lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
import { UserError } from "@/lib/errors";
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
export const pay = createCommand({
|
export const pay = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -50,20 +50,14 @@ export const pay = createCommand({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await withCommandErrorHandling(
|
||||||
await interaction.deferReply();
|
interaction,
|
||||||
await economyService.transfer(senderId, receiverId.toString(), amount);
|
async () => {
|
||||||
|
await economyService.transfer(senderId, receiverId.toString(), amount);
|
||||||
|
|
||||||
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
|
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
|
||||||
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
|
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error instanceof UserError) {
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
|
||||||
} else {
|
|
||||||
console.error("Error sending payment:", error);
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
108
bot/commands/economy/trivia.ts
Normal file
108
bot/commands/economy/trivia.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { createCommand } from "@shared/lib/utils";
|
||||||
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
|
import { triviaService } from "@shared/modules/trivia/trivia.service";
|
||||||
|
import { getTriviaQuestionView } from "@/modules/trivia/trivia.view";
|
||||||
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
import { config } from "@shared/lib/config";
|
||||||
|
import { TriviaCategory } from "@shared/lib/constants";
|
||||||
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
|
|
||||||
|
export const trivia = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("trivia")
|
||||||
|
.setDescription("Play trivia to win currency! Answer correctly within the time limit.")
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('category')
|
||||||
|
.setDescription('Select a specific category')
|
||||||
|
.setRequired(false)
|
||||||
|
.addChoices(
|
||||||
|
{ name: 'General Knowledge', value: String(TriviaCategory.GENERAL_KNOWLEDGE) },
|
||||||
|
{ name: 'Books', value: String(TriviaCategory.BOOKS) },
|
||||||
|
{ name: 'Film', value: String(TriviaCategory.FILM) },
|
||||||
|
{ name: 'Music', value: String(TriviaCategory.MUSIC) },
|
||||||
|
{ name: 'Video Games', value: String(TriviaCategory.VIDEO_GAMES) },
|
||||||
|
{ name: 'Science & Nature', value: String(TriviaCategory.SCIENCE_NATURE) },
|
||||||
|
{ name: 'Computers', value: String(TriviaCategory.COMPUTERS) },
|
||||||
|
{ name: 'Mathematics', value: String(TriviaCategory.MATHEMATICS) },
|
||||||
|
{ name: 'Mythology', value: String(TriviaCategory.MYTHOLOGY) },
|
||||||
|
{ name: 'Sports', value: String(TriviaCategory.SPORTS) },
|
||||||
|
{ name: 'Geography', value: String(TriviaCategory.GEOGRAPHY) },
|
||||||
|
{ name: 'History', value: String(TriviaCategory.HISTORY) },
|
||||||
|
{ name: 'Politics', value: String(TriviaCategory.POLITICS) },
|
||||||
|
{ name: 'Art', value: String(TriviaCategory.ART) },
|
||||||
|
{ name: 'Animals', value: String(TriviaCategory.ANIMALS) },
|
||||||
|
{ name: 'Anime & Manga', value: String(TriviaCategory.ANIME_MANGA) },
|
||||||
|
)
|
||||||
|
),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
try {
|
||||||
|
const categoryId = interaction.options.getString('category');
|
||||||
|
|
||||||
|
// Check if user can play BEFORE deferring
|
||||||
|
const canPlay = await triviaService.canPlayTrivia(interaction.user.id);
|
||||||
|
|
||||||
|
if (!canPlay.canPlay) {
|
||||||
|
// Cooldown error - ephemeral
|
||||||
|
const timestamp = Math.floor(canPlay.nextAvailable!.getTime() / 1000);
|
||||||
|
await interaction.reply({
|
||||||
|
embeds: [createErrorEmbed(
|
||||||
|
`You're on cooldown! Try again <t:${timestamp}:R>.`
|
||||||
|
)],
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User can play - use standardized error handling for the main operation
|
||||||
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
// Start trivia session (deducts entry fee)
|
||||||
|
const session = await triviaService.startTrivia(
|
||||||
|
interaction.user.id,
|
||||||
|
interaction.user.username,
|
||||||
|
categoryId ? parseInt(categoryId) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate Components v2 message
|
||||||
|
const { components, flags } = getTriviaQuestionView(session, interaction.user.username);
|
||||||
|
|
||||||
|
// Reply with Components v2 question
|
||||||
|
await interaction.editReply({
|
||||||
|
components,
|
||||||
|
flags
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up automatic timeout cleanup
|
||||||
|
setTimeout(async () => {
|
||||||
|
const stillActive = triviaService.getSession(session.sessionId);
|
||||||
|
if (stillActive) {
|
||||||
|
// User didn't answer - clean up session with no reward
|
||||||
|
try {
|
||||||
|
await triviaService.submitAnswer(session.sessionId, interaction.user.id, false);
|
||||||
|
} catch (error) {
|
||||||
|
// Session already cleaned up, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, config.trivia.timeoutSeconds * 1000 + 5000); // 5 seconds grace period
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
// Handle errors from the pre-defer canPlayTrivia check
|
||||||
|
if (error instanceof UserError) {
|
||||||
|
await interaction.reply({
|
||||||
|
embeds: [createErrorEmbed(error.message)],
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error("Error in trivia command:", error);
|
||||||
|
await interaction.reply({
|
||||||
|
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { config } from "@shared/lib/config";
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
import { createErrorEmbed } from "@/lib/embeds";
|
import { createErrorEmbed } from "@/lib/embeds";
|
||||||
import { getFeedbackTypeMenu } from "@/modules/feedback/feedback.view";
|
import { getFeedbackTypeMenu } from "@/modules/feedback/feedback.view";
|
||||||
|
|
||||||
@@ -9,8 +9,10 @@ export const feedback = createCommand({
|
|||||||
.setName("feedback")
|
.setName("feedback")
|
||||||
.setDescription("Submit feedback, feature requests, or bug reports"),
|
.setDescription("Submit feedback, feature requests, or bug reports"),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
|
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||||
|
|
||||||
// Check if feedback channel is configured
|
// Check if feedback channel is configured
|
||||||
if (!config.feedbackChannelId) {
|
if (!guildConfig.feedbackChannelId) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
embeds: [createErrorEmbed("Feedback system is not configured. Please contact an administrator.")],
|
embeds: [createErrorEmbed("Feedback system is not configured. Please contact an administrator.")],
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
|||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { createErrorEmbed } from "@lib/embeds";
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
||||||
import type { ItemUsageData } from "@shared/lib/types";
|
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||||
import { UserError } from "@/lib/errors";
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
import { config } from "@shared/lib/config";
|
|
||||||
|
|
||||||
export const use = createCommand({
|
export const use = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -19,54 +18,50 @@ export const use = createCommand({
|
|||||||
.setAutocomplete(true)
|
.setAutocomplete(true)
|
||||||
),
|
),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply();
|
await withCommandErrorHandling(
|
||||||
|
interaction,
|
||||||
|
async () => {
|
||||||
|
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||||
|
const colorRoles = guildConfig.colorRoles ?? [];
|
||||||
|
|
||||||
const itemId = interaction.options.getNumber("item", true);
|
const itemId = interaction.options.getNumber("item", true);
|
||||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] });
|
await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const result = await inventoryService.useItem(user.id.toString(), itemId);
|
||||||
const result = await inventoryService.useItem(user.id.toString(), itemId);
|
|
||||||
|
|
||||||
const usageData = result.usageData;
|
const usageData = result.usageData;
|
||||||
if (usageData) {
|
if (usageData) {
|
||||||
for (const effect of usageData.effects) {
|
for (const effect of usageData.effects) {
|
||||||
if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') {
|
if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') {
|
||||||
try {
|
try {
|
||||||
const member = await interaction.guild?.members.fetch(user.id.toString());
|
const member = await interaction.guild?.members.fetch(user.id.toString());
|
||||||
if (member) {
|
if (member) {
|
||||||
if (effect.type === 'TEMP_ROLE') {
|
if (effect.type === 'TEMP_ROLE') {
|
||||||
await member.roles.add(effect.roleId);
|
await member.roles.add(effect.roleId);
|
||||||
} else if (effect.type === 'COLOR_ROLE') {
|
} else if (effect.type === 'COLOR_ROLE') {
|
||||||
// Remove existing color roles
|
// Remove existing color roles
|
||||||
const rolesToRemove = config.colorRoles.filter(r => member.roles.cache.has(r));
|
const rolesToRemove = colorRoles.filter(r => member.roles.cache.has(r));
|
||||||
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
|
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
|
||||||
await member.roles.add(effect.roleId);
|
await member.roles.add(effect.roleId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to assign role in /use command:", e);
|
||||||
|
result.results.push("⚠️ Failed to assign role (Check bot permissions)");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to assign role in /use command:", e);
|
|
||||||
result.results.push("⚠️ Failed to assign role (Check bot permissions)");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { embed, files } = getItemUseResultEmbed(result.results, result.item);
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed], files });
|
||||||
}
|
}
|
||||||
|
);
|
||||||
const embed = getItemUseResultEmbed(result.results, result.item);
|
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error instanceof UserError) {
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
|
||||||
} else {
|
|
||||||
console.error("Error using item:", error);
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred while using the item.")] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
autocomplete: async (interaction) => {
|
autocomplete: async (interaction) => {
|
||||||
const focusedValue = interaction.options.getFocused();
|
const focusedValue = interaction.options.getFocused();
|
||||||
|
|||||||
@@ -1,25 +1,83 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
||||||
import { questService } from "@shared/modules/quest/quest.service";
|
import { questService } from "@shared/modules/quest/quest.service";
|
||||||
import { createWarningEmbed } from "@lib/embeds";
|
import { createSuccessEmbed } from "@lib/embeds";
|
||||||
import { getQuestListEmbed } from "@/modules/quest/quest.view";
|
import {
|
||||||
|
getQuestListComponents,
|
||||||
|
getAvailableQuestsComponents,
|
||||||
|
getQuestActionRows
|
||||||
|
} from "@/modules/quest/quest.view";
|
||||||
|
|
||||||
export const quests = createCommand({
|
export const quests = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
.setName("quests")
|
.setName("quests")
|
||||||
.setDescription("View your active quests"),
|
.setDescription("View your active and available quests"),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
const response = await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
const userQuests = await questService.getUserQuests(interaction.user.id);
|
const userId = interaction.user.id;
|
||||||
|
|
||||||
if (!userQuests || userQuests.length === 0) {
|
const updateView = async (viewType: 'active' | 'available') => {
|
||||||
await interaction.editReply({ embeds: [createWarningEmbed("You have no active quests.", "Quest Log")] });
|
const userQuests = await questService.getUserQuests(userId);
|
||||||
return;
|
const availableQuests = await questService.getAvailableQuests(userId);
|
||||||
}
|
|
||||||
|
|
||||||
const embed = getQuestListEmbed(userQuests);
|
const containers = viewType === 'active'
|
||||||
|
? getQuestListComponents(userQuests)
|
||||||
|
: getAvailableQuestsComponents(availableQuests);
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
const actionRows = getQuestActionRows(viewType);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: null,
|
||||||
|
embeds: null as any,
|
||||||
|
components: [...containers, ...actionRows] as any,
|
||||||
|
flags: MessageFlags.IsComponentsV2,
|
||||||
|
allowedMentions: { parse: [] }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial view
|
||||||
|
await updateView('active');
|
||||||
|
|
||||||
|
const collector = response.createMessageComponentCollector({
|
||||||
|
time: 120000, // 2 minutes
|
||||||
|
componentType: undefined // Allow buttons
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('collect', async (i) => {
|
||||||
|
if (i.user.id !== interaction.user.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (i.customId === "quest_view_active") {
|
||||||
|
await i.deferUpdate();
|
||||||
|
await updateView('active');
|
||||||
|
} else if (i.customId === "quest_view_available") {
|
||||||
|
await i.deferUpdate();
|
||||||
|
await updateView('available');
|
||||||
|
} else if (i.customId.startsWith("quest_accept:")) {
|
||||||
|
const questIdStr = i.customId.split(":")[1];
|
||||||
|
if (!questIdStr) return;
|
||||||
|
const questId = parseInt(questIdStr);
|
||||||
|
await questService.assignQuest(userId, questId);
|
||||||
|
|
||||||
|
await i.reply({
|
||||||
|
embeds: [createSuccessEmbed(`You have accepted a new quest!`, "Quest Accepted")],
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateView('active');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Quest interaction error:", error);
|
||||||
|
await i.followUp({
|
||||||
|
content: "Something went wrong while processing your quest interaction.",
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('end', () => {
|
||||||
|
interaction.editReply({ components: [] }).catch(() => {});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
import { Events } from "discord.js";
|
import { Events } from "discord.js";
|
||||||
import type { Event } from "@shared/lib/types";
|
import type { Event } from "@shared/lib/types";
|
||||||
import { config } from "@shared/lib/config";
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
|
|
||||||
// Visitor role
|
|
||||||
const event: Event<Events.GuildMemberAdd> = {
|
const event: Event<Events.GuildMemberAdd> = {
|
||||||
name: Events.GuildMemberAdd,
|
name: Events.GuildMemberAdd,
|
||||||
execute: async (member) => {
|
execute: async (member) => {
|
||||||
console.log(`👤 New member joined: ${member.user.tag} (${member.id})`);
|
console.log(`👤 New member joined: ${member.user.tag} (${member.id})`);
|
||||||
|
|
||||||
|
const guildConfig = await getGuildConfig(member.guild.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await userService.getUserById(member.id);
|
const user = await userService.getUserById(member.id);
|
||||||
|
|
||||||
if (user && user.class) {
|
if (user && user.class) {
|
||||||
console.log(`🔄 Returning student detected: ${member.user.tag}`);
|
console.log(`🔄 Returning student detected: ${member.user.tag}`);
|
||||||
await member.roles.remove(config.visitorRole);
|
if (guildConfig.visitorRole) {
|
||||||
await member.roles.add(config.studentRole);
|
await member.roles.remove(guildConfig.visitorRole);
|
||||||
|
}
|
||||||
|
if (guildConfig.studentRole) {
|
||||||
|
await member.roles.add(guildConfig.studentRole);
|
||||||
|
}
|
||||||
|
|
||||||
if (user.class.roleId) {
|
if (user.class.roleId) {
|
||||||
await member.roles.add(user.class.roleId);
|
await member.roles.add(user.class.roleId);
|
||||||
@@ -22,8 +28,10 @@ const event: Event<Events.GuildMemberAdd> = {
|
|||||||
}
|
}
|
||||||
console.log(`Restored student role to ${member.user.tag}`);
|
console.log(`Restored student role to ${member.user.tag}`);
|
||||||
} else {
|
} else {
|
||||||
await member.roles.add(config.visitorRole);
|
if (guildConfig.visitorRole) {
|
||||||
console.log(`Assigned visitor role to ${member.user.tag}`);
|
await member.roles.add(guildConfig.visitorRole);
|
||||||
|
console.log(`Assigned visitor role to ${member.user.tag}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
console.log(`User Roles: ${member.roles.cache.map(role => role.name).join(", ")}`);
|
console.log(`User Roles: ${member.roles.cache.map(role => role.name).join(", ")}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ const event: Event<Events.ClientReady> = {
|
|||||||
console.log(`Ready! Logged in as ${c.user.tag}`);
|
console.log(`Ready! Logged in as ${c.user.tag}`);
|
||||||
schedulerService.start();
|
schedulerService.start();
|
||||||
|
|
||||||
// Handle post-update tasks
|
|
||||||
const { UpdateService } = await import("@shared/modules/admin/update.service");
|
|
||||||
await UpdateService.handlePostRestart(c);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { env } from "@shared/lib/env";
|
import { env } from "@shared/lib/env";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { initializeConfig } from "@shared/lib/config";
|
||||||
|
|
||||||
import { startWebServerFromRoot } from "../web/src/server";
|
import { startWebServerFromRoot } from "../web/src/server";
|
||||||
|
|
||||||
|
// Initialize config from database
|
||||||
|
await initializeConfig();
|
||||||
|
|
||||||
// Load commands & events
|
// Load commands & events
|
||||||
await AuroraClient.loadCommands();
|
await AuroraClient.loadCommands();
|
||||||
await AuroraClient.loadEvents();
|
await AuroraClient.loadEvents();
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ mock.module("discord.js", () => ({
|
|||||||
Routes: {
|
Routes: {
|
||||||
applicationGuildCommands: () => 'guild_route',
|
applicationGuildCommands: () => 'guild_route',
|
||||||
applicationCommands: () => 'global_route'
|
applicationCommands: () => 'global_route'
|
||||||
}
|
},
|
||||||
|
MessageFlags: {}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock loaders to avoid filesystem access during client init
|
// Mock loaders to avoid filesystem access during client init
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js";
|
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes, MessageFlags } from "discord.js";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { Command } from "@shared/lib/types";
|
import type { Command } from "@shared/lib/types";
|
||||||
import { env } from "@shared/lib/env";
|
import { env } from "@shared/lib/env";
|
||||||
@@ -8,6 +8,7 @@ import { EventLoader } from "@lib/loaders/EventLoader";
|
|||||||
export class Client extends DiscordClient {
|
export class Client extends DiscordClient {
|
||||||
|
|
||||||
commands: Collection<string, Command>;
|
commands: Collection<string, Command>;
|
||||||
|
knownCommands: Map<string, string>;
|
||||||
lastCommandTimestamp: number | null = null;
|
lastCommandTimestamp: number | null = null;
|
||||||
maintenanceMode: boolean = false;
|
maintenanceMode: boolean = false;
|
||||||
private commandLoader: CommandLoader;
|
private commandLoader: CommandLoader;
|
||||||
@@ -16,6 +17,7 @@ export class Client extends DiscordClient {
|
|||||||
constructor({ intents }: { intents: number[] }) {
|
constructor({ intents }: { intents: number[] }) {
|
||||||
super({ intents });
|
super({ intents });
|
||||||
this.commands = new Collection<string, Command>();
|
this.commands = new Collection<string, Command>();
|
||||||
|
this.knownCommands = new Map<string, string>();
|
||||||
this.commandLoader = new CommandLoader(this);
|
this.commandLoader = new CommandLoader(this);
|
||||||
this.eventLoader = new EventLoader(this);
|
this.eventLoader = new EventLoader(this);
|
||||||
}
|
}
|
||||||
@@ -72,11 +74,33 @@ export class Client extends DiscordClient {
|
|||||||
console.log(`🛠️ System Action: Maintenance mode ${enabled ? "ON" : "OFF"}${reason ? ` (${reason})` : ""}`);
|
console.log(`🛠️ System Action: Maintenance mode ${enabled ? "ON" : "OFF"}${reason ? ` (${reason})` : ""}`);
|
||||||
this.maintenanceMode = enabled;
|
this.maintenanceMode = enabled;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
systemEvents.on(EVENTS.QUEST.COMPLETED, async (data: { userId: string, quest: any, rewards: any }) => {
|
||||||
|
const { userId, quest, rewards } = data;
|
||||||
|
try {
|
||||||
|
const user = await this.users.fetch(userId);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const { getQuestCompletionComponents } = await import("@/modules/quest/quest.view");
|
||||||
|
const components = getQuestCompletionComponents(quest, rewards);
|
||||||
|
|
||||||
|
// Try to send to the user's DM
|
||||||
|
await user.send({
|
||||||
|
components: components as any,
|
||||||
|
flags: [MessageFlags.IsComponentsV2]
|
||||||
|
}).catch(async () => {
|
||||||
|
console.warn(`Could not DM user ${userId} quest completion message. User might have DMs disabled.`);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send quest completion notification:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadCommands(reload: boolean = false) {
|
async loadCommands(reload: boolean = false) {
|
||||||
if (reload) {
|
if (reload) {
|
||||||
this.commands.clear();
|
this.commands.clear();
|
||||||
|
this.knownCommands.clear();
|
||||||
console.log("♻️ Reloading commands...");
|
console.log("♻️ Reloading commands...");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,4 +197,4 @@ export class Client extends DiscordClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers] });
|
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers, GatewayIntentBits.DirectMessages] });
|
||||||
@@ -20,6 +20,9 @@ mock.module("./BotClient", () => ({
|
|||||||
commands: {
|
commands: {
|
||||||
size: 20,
|
size: 20,
|
||||||
},
|
},
|
||||||
|
knownCommands: {
|
||||||
|
size: 20,
|
||||||
|
},
|
||||||
lastCommandTimestamp: 1641481200000,
|
lastCommandTimestamp: 1641481200000,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -23,11 +23,13 @@ export function getClientStats(): ClientStats {
|
|||||||
bot: {
|
bot: {
|
||||||
name: AuroraClient.user?.username || "Aurora",
|
name: AuroraClient.user?.username || "Aurora",
|
||||||
avatarUrl: AuroraClient.user?.displayAvatarURL() || null,
|
avatarUrl: AuroraClient.user?.displayAvatarURL() || null,
|
||||||
|
status: AuroraClient.user?.presence.activities[0]?.state || AuroraClient.user?.presence.activities[0]?.name || null,
|
||||||
},
|
},
|
||||||
guilds: AuroraClient.guilds.cache.size,
|
guilds: AuroraClient.guilds.cache.size,
|
||||||
ping: AuroraClient.ws.ping,
|
ping: AuroraClient.ws.ping,
|
||||||
cachedUsers: AuroraClient.users.cache.size,
|
cachedUsers: AuroraClient.users.cache.size,
|
||||||
commandsRegistered: AuroraClient.commands.size,
|
commandsRegistered: AuroraClient.commands.size,
|
||||||
|
commandsKnown: AuroraClient.knownCommands.size,
|
||||||
uptime: process.uptime(),
|
uptime: process.uptime(),
|
||||||
lastCommandTimestamp: AuroraClient.lastCommandTimestamp,
|
lastCommandTimestamp: AuroraClient.lastCommandTimestamp,
|
||||||
};
|
};
|
||||||
|
|||||||
147
bot/lib/commandUtils.test.ts
Normal file
147
bot/lib/commandUtils.test.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, it, expect, mock, beforeEach, spyOn } from "bun:test";
|
||||||
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
|
||||||
|
// --- Mocks ---
|
||||||
|
|
||||||
|
const mockDeferReply = mock(() => Promise.resolve());
|
||||||
|
const mockEditReply = mock(() => Promise.resolve());
|
||||||
|
|
||||||
|
const mockInteraction = {
|
||||||
|
deferReply: mockDeferReply,
|
||||||
|
editReply: mockEditReply,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const mockCreateErrorEmbed = mock((msg: string) => ({ description: msg, type: "error" }));
|
||||||
|
|
||||||
|
mock.module("./embeds", () => ({
|
||||||
|
createErrorEmbed: mockCreateErrorEmbed,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import AFTER mocking
|
||||||
|
const { withCommandErrorHandling } = await import("./commandUtils");
|
||||||
|
|
||||||
|
// --- Tests ---
|
||||||
|
|
||||||
|
describe("withCommandErrorHandling", () => {
|
||||||
|
let consoleErrorSpy: ReturnType<typeof spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockDeferReply.mockClear();
|
||||||
|
mockEditReply.mockClear();
|
||||||
|
mockCreateErrorEmbed.mockClear();
|
||||||
|
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => { });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should always call deferReply", async () => {
|
||||||
|
await withCommandErrorHandling(
|
||||||
|
mockInteraction,
|
||||||
|
async () => "result"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockDeferReply).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass ephemeral option to deferReply", async () => {
|
||||||
|
await withCommandErrorHandling(
|
||||||
|
mockInteraction,
|
||||||
|
async () => "result",
|
||||||
|
{ ephemeral: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockDeferReply).toHaveBeenCalledWith({ ephemeral: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return the operation result on success", async () => {
|
||||||
|
const result = await withCommandErrorHandling(
|
||||||
|
mockInteraction,
|
||||||
|
async () => ({ data: "test" })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ data: "test" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onSuccess with the result", async () => {
|
||||||
|
const onSuccess = mock(async (_result: string) => { });
|
||||||
|
|
||||||
|
await withCommandErrorHandling(
|
||||||
|
mockInteraction,
|
||||||
|
async () => "hello",
|
||||||
|
{ onSuccess }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onSuccess).toHaveBeenCalledWith("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should send successMessage when no onSuccess is provided", async () => {
|
||||||
|
await withCommandErrorHandling(
|
||||||
|
mockInteraction,
|
||||||
|
async () => "result",
|
||||||
|
{ successMessage: "It worked!" }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockEditReply).toHaveBeenCalledWith({
|
||||||
|
content: "It worked!",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prefer onSuccess over successMessage", async () => {
|
||||||
|
const onSuccess = mock(async (_result: string) => { });
|
||||||
|
|
||||||
|
await withCommandErrorHandling(
|
||||||
|
mockInteraction,
|
||||||
|
async () => "result",
|
||||||
|
{ successMessage: "This should not be sent", onSuccess }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onSuccess).toHaveBeenCalledTimes(1);
|
||||||
|
// editReply should NOT have been called with the successMessage
|
||||||
|
expect(mockEditReply).not.toHaveBeenCalledWith({
|
||||||
|
content: "This should not be sent",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show error embed for UserError", async () => {
|
||||||
|
const result = await withCommandErrorHandling(
|
||||||
|
mockInteraction,
|
||||||
|
async () => {
|
||||||
|
throw new UserError("You can't do that!");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(mockCreateErrorEmbed).toHaveBeenCalledWith("You can't do that!");
|
||||||
|
expect(mockEditReply).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show generic error and log for unexpected errors", async () => {
|
||||||
|
const unexpectedError = new Error("Database exploded");
|
||||||
|
|
||||||
|
const result = await withCommandErrorHandling(
|
||||||
|
mockInteraction,
|
||||||
|
async () => {
|
||||||
|
throw unexpectedError;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
"Unexpected error in command:",
|
||||||
|
unexpectedError
|
||||||
|
);
|
||||||
|
expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
|
||||||
|
"An unexpected error occurred."
|
||||||
|
);
|
||||||
|
expect(mockEditReply).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return undefined on error", async () => {
|
||||||
|
const result = await withCommandErrorHandling(
|
||||||
|
mockInteraction,
|
||||||
|
async () => {
|
||||||
|
throw new Error("fail");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
79
bot/lib/commandUtils.ts
Normal file
79
bot/lib/commandUtils.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { ChatInputCommandInteraction } from "discord.js";
|
||||||
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
import { createErrorEmbed } from "./embeds";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a command's core logic with standardized error handling.
|
||||||
|
*
|
||||||
|
* - Calls `interaction.deferReply()` automatically
|
||||||
|
* - On success, invokes `onSuccess` callback or sends `successMessage`
|
||||||
|
* - On `UserError`, shows the error message in an error embed
|
||||||
|
* - On unexpected errors, logs to console and shows a generic error embed
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* export const myCommand = createCommand({
|
||||||
|
* execute: async (interaction) => {
|
||||||
|
* await withCommandErrorHandling(
|
||||||
|
* interaction,
|
||||||
|
* async () => {
|
||||||
|
* const result = await doSomething();
|
||||||
|
* await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
||||||
|
* }
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // With deferReply options (e.g. ephemeral)
|
||||||
|
* await withCommandErrorHandling(
|
||||||
|
* interaction,
|
||||||
|
* async () => doSomething(),
|
||||||
|
* {
|
||||||
|
* ephemeral: true,
|
||||||
|
* successMessage: "Done!",
|
||||||
|
* }
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function withCommandErrorHandling<T>(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
options?: {
|
||||||
|
/** Message to send on success (if no onSuccess callback is provided) */
|
||||||
|
successMessage?: string;
|
||||||
|
/** Callback invoked with the operation result on success */
|
||||||
|
onSuccess?: (result: T) => Promise<void>;
|
||||||
|
/** Whether the deferred reply should be ephemeral */
|
||||||
|
ephemeral?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<T | undefined> {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral: options?.ephemeral });
|
||||||
|
const result = await operation();
|
||||||
|
|
||||||
|
if (options?.onSuccess) {
|
||||||
|
await options.onSuccess(result);
|
||||||
|
} else if (options?.successMessage) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: options.successMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UserError) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed(error.message)],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error("Unexpected error in command:", error);
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("An unexpected error occurred.")],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,15 @@
|
|||||||
import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js";
|
import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js";
|
||||||
|
import { BRANDING } from "@shared/lib/constants";
|
||||||
|
import pkg from "../../package.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies standard branding to an embed.
|
||||||
|
*/
|
||||||
|
function applyBranding(embed: EmbedBuilder): EmbedBuilder {
|
||||||
|
return embed.setFooter({
|
||||||
|
text: `${BRANDING.FOOTER_TEXT} v${pkg.version}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a standardized error embed.
|
* Creates a standardized error embed.
|
||||||
@@ -7,11 +18,13 @@ import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js";
|
|||||||
* @returns An EmbedBuilder instance configured as an error.
|
* @returns An EmbedBuilder instance configured as an error.
|
||||||
*/
|
*/
|
||||||
export function createErrorEmbed(message: string, title: string = "Error"): EmbedBuilder {
|
export function createErrorEmbed(message: string, title: string = "Error"): EmbedBuilder {
|
||||||
return new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(`❌ ${title}`)
|
.setTitle(`❌ ${title}`)
|
||||||
.setDescription(message)
|
.setDescription(message)
|
||||||
.setColor(Colors.Red)
|
.setColor(Colors.Red)
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
|
||||||
|
return applyBranding(embed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,11 +34,13 @@ export function createErrorEmbed(message: string, title: string = "Error"): Embe
|
|||||||
* @returns An EmbedBuilder instance configured as a warning.
|
* @returns An EmbedBuilder instance configured as a warning.
|
||||||
*/
|
*/
|
||||||
export function createWarningEmbed(message: string, title: string = "Warning"): EmbedBuilder {
|
export function createWarningEmbed(message: string, title: string = "Warning"): EmbedBuilder {
|
||||||
return new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(`⚠️ ${title}`)
|
.setTitle(`⚠️ ${title}`)
|
||||||
.setDescription(message)
|
.setDescription(message)
|
||||||
.setColor(Colors.Yellow)
|
.setColor(Colors.Yellow)
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
|
||||||
|
return applyBranding(embed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,11 +50,13 @@ export function createWarningEmbed(message: string, title: string = "Warning"):
|
|||||||
* @returns An EmbedBuilder instance configured as a success.
|
* @returns An EmbedBuilder instance configured as a success.
|
||||||
*/
|
*/
|
||||||
export function createSuccessEmbed(message: string, title: string = "Success"): EmbedBuilder {
|
export function createSuccessEmbed(message: string, title: string = "Success"): EmbedBuilder {
|
||||||
return new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(`✅ ${title}`)
|
.setTitle(`✅ ${title}`)
|
||||||
.setDescription(message)
|
.setDescription(message)
|
||||||
.setColor(Colors.Green)
|
.setColor(Colors.Green)
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
|
||||||
|
return applyBranding(embed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,11 +66,13 @@ export function createSuccessEmbed(message: string, title: string = "Success"):
|
|||||||
* @returns An EmbedBuilder instance configured as info.
|
* @returns An EmbedBuilder instance configured as info.
|
||||||
*/
|
*/
|
||||||
export function createInfoEmbed(message: string, title: string = "Info"): EmbedBuilder {
|
export function createInfoEmbed(message: string, title: string = "Info"): EmbedBuilder {
|
||||||
return new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(`ℹ️ ${title}`)
|
.setTitle(`ℹ️ ${title}`)
|
||||||
.setDescription(message)
|
.setDescription(message)
|
||||||
.setColor(Colors.Blue)
|
.setColor(Colors.Blue)
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
|
||||||
|
return applyBranding(embed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,11 +84,12 @@ export function createInfoEmbed(message: string, title: string = "Info"): EmbedB
|
|||||||
*/
|
*/
|
||||||
export function createBaseEmbed(title?: string, description?: string, color?: ColorResolvable): EmbedBuilder {
|
export function createBaseEmbed(title?: string, description?: string, color?: ColorResolvable): EmbedBuilder {
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTimestamp();
|
.setTimestamp()
|
||||||
|
.setColor(color ?? BRANDING.COLOR);
|
||||||
|
|
||||||
if (title) embed.setTitle(title);
|
if (title) embed.setTitle(title);
|
||||||
if (description) embed.setDescription(description);
|
if (description) embed.setDescription(description);
|
||||||
if (color) embed.setColor(color);
|
|
||||||
|
|
||||||
return embed;
|
return applyBranding(embed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
export class ApplicationError extends Error {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(message);
|
|
||||||
this.name = this.constructor.name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UserError extends ApplicationError {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SystemError extends ApplicationError {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AutocompleteInteraction } from "discord.js";
|
import { AutocompleteInteraction } from "discord.js";
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
|
import { logger } from "@shared/lib/logger";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,7 +17,7 @@ export class AutocompleteHandler {
|
|||||||
try {
|
try {
|
||||||
await command.autocomplete(interaction);
|
await command.autocomplete(interaction);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error handling autocomplete for ${interaction.commandName}:`, error);
|
logger.error("bot", `Error handling autocomplete for ${interaction.commandName}`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
|
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
|
import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service";
|
||||||
import { createErrorEmbed } from "@lib/embeds";
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
|
import { logger } from "@shared/lib/logger";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,7 +15,7 @@ export class CommandHandler {
|
|||||||
const command = AuroraClient.commands.get(interaction.commandName);
|
const command = AuroraClient.commands.get(interaction.commandName);
|
||||||
|
|
||||||
if (!command) {
|
if (!command) {
|
||||||
console.error(`No command matching ${interaction.commandName} was found.`);
|
logger.error("bot", `No command matching ${interaction.commandName} was found.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,18 +26,49 @@ export class CommandHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check beta feature access
|
||||||
|
if (command.beta) {
|
||||||
|
const flagName = command.featureFlag || interaction.commandName;
|
||||||
|
let memberRoles: string[] = [];
|
||||||
|
|
||||||
|
if (interaction.member && 'roles' in interaction.member) {
|
||||||
|
const roles = interaction.member.roles;
|
||||||
|
if (typeof roles === 'object' && 'cache' in roles) {
|
||||||
|
memberRoles = [...roles.cache.keys()];
|
||||||
|
} else if (Array.isArray(roles)) {
|
||||||
|
memberRoles = roles;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAccess = await featureFlagsService.hasAccess(flagName, {
|
||||||
|
guildId: interaction.guildId!,
|
||||||
|
userId: interaction.user.id,
|
||||||
|
memberRoles,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
const errorEmbed = createErrorEmbed(
|
||||||
|
"This feature is currently in beta testing and not available to all users. " +
|
||||||
|
"Stay tuned for the official release!",
|
||||||
|
"Beta Feature"
|
||||||
|
);
|
||||||
|
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure user exists in database
|
// Ensure user exists in database
|
||||||
try {
|
try {
|
||||||
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to ensure user exists:", error);
|
logger.error("bot", "Failed to ensure user exists", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await command.execute(interaction);
|
await command.execute(interaction);
|
||||||
AuroraClient.lastCommandTimestamp = Date.now();
|
AuroraClient.lastCommandTimestamp = Date.now();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(String(error));
|
logger.error("bot", `Error executing command ${interaction.commandName}`, error);
|
||||||
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
|
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
|
||||||
|
|
||||||
if (interaction.replied || interaction.deferred) {
|
if (interaction.replied || interaction.deferred) {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js";
|
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js";
|
||||||
|
|
||||||
import { UserError } from "@lib/errors";
|
import { UserError } from "@shared/lib/errors";
|
||||||
import { createErrorEmbed } from "@lib/embeds";
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
|
import { logger } from "@shared/lib/logger";
|
||||||
|
|
||||||
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ export class ComponentInteractionHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error(`Handler method ${route.method} not found in module`);
|
logger.error("bot", `Handler method ${route.method} not found in module`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,7 +53,7 @@ export class ComponentInteractionHandler {
|
|||||||
|
|
||||||
// Log system errors (non-user errors) for debugging
|
// Log system errors (non-user errors) for debugging
|
||||||
if (!isUserError) {
|
if (!isUserError) {
|
||||||
console.error(`Error in ${handlerName}:`, error);
|
logger.error("bot", `Error in ${handlerName}`, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorEmbed = createErrorEmbed(errorMessage);
|
const errorEmbed = createErrorEmbed(errorMessage);
|
||||||
@@ -72,7 +73,7 @@ export class ComponentInteractionHandler {
|
|||||||
}
|
}
|
||||||
} catch (replyError) {
|
} catch (replyError) {
|
||||||
// If we can't send a reply, log it
|
// If we can't send a reply, log it
|
||||||
console.error(`Failed to send error response in ${handlerName}:`, replyError);
|
logger.error("bot", `Failed to send error response in ${handlerName}`, replyError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ export const interactionRoutes: InteractionRoute[] = [
|
|||||||
handler: () => import("@/modules/economy/lootdrop.interaction"),
|
handler: () => import("@/modules/economy/lootdrop.interaction"),
|
||||||
method: 'handleLootdropInteraction'
|
method: 'handleLootdropInteraction'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
predicate: (i) => i.isButton() && i.customId.startsWith("trivia_"),
|
||||||
|
handler: () => import("@/modules/trivia/trivia.interaction"),
|
||||||
|
method: 'handleTriviaInteraction'
|
||||||
|
},
|
||||||
|
|
||||||
// --- ADMIN MODULE ---
|
// --- ADMIN MODULE ---
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ export class CommandLoader {
|
|||||||
if (this.isValidCommand(command)) {
|
if (this.isValidCommand(command)) {
|
||||||
command.category = category;
|
command.category = category;
|
||||||
|
|
||||||
|
// Track all known commands regardless of enabled status
|
||||||
|
this.client.knownCommands.set(command.data.name, category);
|
||||||
|
|
||||||
const isEnabled = config.commands[command.data.name] !== false;
|
const isEnabled = config.commands[command.data.name] !== false;
|
||||||
|
|
||||||
if (!isEnabled) {
|
if (!isEnabled) {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const renderWizard = (userId: string, isDraft = true) => {
|
|||||||
draft = {
|
draft = {
|
||||||
name: "New Item",
|
name: "New Item",
|
||||||
description: "No description",
|
description: "No description",
|
||||||
rarity: "Common",
|
rarity: "C",
|
||||||
type: ItemType.MATERIAL,
|
type: ItemType.MATERIAL,
|
||||||
price: null,
|
price: null,
|
||||||
iconUrl: "",
|
iconUrl: "",
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const getDetailsModal = (current: DraftItem) => {
|
|||||||
modal.addComponents(
|
modal.addComponents(
|
||||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("name").setLabel("Name").setValue(current.name).setStyle(TextInputStyle.Short).setRequired(true)),
|
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("name").setLabel("Name").setValue(current.name).setStyle(TextInputStyle.Short).setRequired(true)),
|
||||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("desc").setLabel("Description").setValue(current.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
|
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("desc").setLabel("Description").setValue(current.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
|
||||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("Common, Rare, Legendary...").setRequired(true))
|
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("C, R, SR, SSR").setRequired(true))
|
||||||
);
|
);
|
||||||
return modal;
|
return modal;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
|
|
||||||
export interface RestartContext {
|
|
||||||
channelId: string;
|
|
||||||
userId: string;
|
|
||||||
timestamp: number;
|
|
||||||
runMigrations: boolean;
|
|
||||||
installDependencies: boolean;
|
|
||||||
previousCommit: string;
|
|
||||||
newCommit: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateCheckResult {
|
|
||||||
needsRootInstall: boolean;
|
|
||||||
needsWebInstall: boolean;
|
|
||||||
needsMigrations: boolean;
|
|
||||||
changedFiles: string[];
|
|
||||||
error?: Error;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateInfo {
|
|
||||||
hasUpdates: boolean;
|
|
||||||
branch: string;
|
|
||||||
currentCommit: string;
|
|
||||||
latestCommit: string;
|
|
||||||
commitCount: number;
|
|
||||||
commits: CommitInfo[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommitInfo {
|
|
||||||
hash: string;
|
|
||||||
message: string;
|
|
||||||
author: string;
|
|
||||||
}
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js";
|
|
||||||
import { createInfoEmbed, createSuccessEmbed, createWarningEmbed, createErrorEmbed } from "@lib/embeds";
|
|
||||||
import type { UpdateInfo, UpdateCheckResult } from "./update.types";
|
|
||||||
|
|
||||||
// Constants for UI
|
|
||||||
const LOG_TRUNCATE_LENGTH = 800;
|
|
||||||
const OUTPUT_TRUNCATE_LENGTH = 400;
|
|
||||||
|
|
||||||
function truncate(text: string, maxLength: number): string {
|
|
||||||
if (!text) return "";
|
|
||||||
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Pre-Update Embeds ============
|
|
||||||
|
|
||||||
export function getCheckingEmbed() {
|
|
||||||
return createInfoEmbed("🔍 Fetching latest changes from remote...", "Checking for Updates");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getNoUpdatesEmbed(currentCommit: string) {
|
|
||||||
return createSuccessEmbed(
|
|
||||||
`You're running the latest version.\n\n**Current:** \`${currentCommit}\``,
|
|
||||||
"✅ Already Up to Date"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getUpdatesAvailableMessage(
|
|
||||||
updateInfo: UpdateInfo,
|
|
||||||
requirements: UpdateCheckResult,
|
|
||||||
changeCategories: Record<string, number>,
|
|
||||||
force: boolean
|
|
||||||
) {
|
|
||||||
const { branch, currentCommit, latestCommit, commitCount, commits } = updateInfo;
|
|
||||||
const { needsRootInstall, needsWebInstall, needsMigrations } = requirements;
|
|
||||||
|
|
||||||
// Build commit list (max 5)
|
|
||||||
const commitList = commits
|
|
||||||
.slice(0, 5)
|
|
||||||
.map(c => `\`${c.hash}\` ${truncate(c.message, 50)}`)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
const moreCommits = commitCount > 5 ? `\n*...and ${commitCount - 5} more*` : "";
|
|
||||||
|
|
||||||
// Build change categories
|
|
||||||
const categoryList = Object.entries(changeCategories)
|
|
||||||
.map(([cat, count]) => `• ${cat}: ${count} file${count > 1 ? "s" : ""}`)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
// Build requirements list
|
|
||||||
const reqs: string[] = [];
|
|
||||||
if (needsRootInstall) reqs.push("📦 Install root dependencies");
|
|
||||||
if (needsWebInstall) reqs.push("🌐 Install web dependencies");
|
|
||||||
if (needsMigrations) reqs.push("🗃️ Run database migrations");
|
|
||||||
if (reqs.length === 0) reqs.push("⚡ Quick update (no extra steps)");
|
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setTitle("📥 Updates Available")
|
|
||||||
.setColor(force ? 0xFF6B6B : 0x5865F2)
|
|
||||||
.addFields(
|
|
||||||
{
|
|
||||||
name: "Version",
|
|
||||||
value: `\`${currentCommit}\` → \`${latestCommit}\``,
|
|
||||||
inline: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Branch",
|
|
||||||
value: `\`${branch}\``,
|
|
||||||
inline: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Commits",
|
|
||||||
value: `${commitCount} new commit${commitCount > 1 ? "s" : ""}`,
|
|
||||||
inline: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Recent Changes",
|
|
||||||
value: commitList + moreCommits || "No commits",
|
|
||||||
inline: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Files Changed",
|
|
||||||
value: categoryList || "Unknown",
|
|
||||||
inline: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Update Actions",
|
|
||||||
value: reqs.join("\n"),
|
|
||||||
inline: true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.setFooter({ text: force ? "⚠️ Force mode enabled" : "This will restart the bot" })
|
|
||||||
.setTimestamp();
|
|
||||||
|
|
||||||
const confirmButton = new ButtonBuilder()
|
|
||||||
.setCustomId("confirm_update")
|
|
||||||
.setLabel(force ? "Force Update" : "Update Now")
|
|
||||||
.setEmoji(force ? "⚠️" : "🚀")
|
|
||||||
.setStyle(force ? ButtonStyle.Danger : ButtonStyle.Success);
|
|
||||||
|
|
||||||
const cancelButton = new ButtonBuilder()
|
|
||||||
.setCustomId("cancel_update")
|
|
||||||
.setLabel("Cancel")
|
|
||||||
.setStyle(ButtonStyle.Secondary);
|
|
||||||
|
|
||||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(confirmButton, cancelButton);
|
|
||||||
|
|
||||||
return { embeds: [embed], components: [row] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Update Progress Embeds ============
|
|
||||||
|
|
||||||
export function getPreparingEmbed() {
|
|
||||||
return createInfoEmbed(
|
|
||||||
"🔒 Saving rollback point...\n📥 Preparing to download updates...",
|
|
||||||
"⏳ Preparing Update"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getUpdatingEmbed(requirements: UpdateCheckResult) {
|
|
||||||
const steps: string[] = ["✅ Rollback point saved"];
|
|
||||||
|
|
||||||
steps.push("📥 Downloading updates...");
|
|
||||||
|
|
||||||
if (requirements.needsRootInstall || requirements.needsWebInstall) {
|
|
||||||
steps.push("📦 Dependencies will be installed after restart");
|
|
||||||
}
|
|
||||||
if (requirements.needsMigrations) {
|
|
||||||
steps.push("🗃️ Migrations will run after restart");
|
|
||||||
}
|
|
||||||
|
|
||||||
steps.push("\n🔄 **Restarting now...**");
|
|
||||||
|
|
||||||
return createWarningEmbed(steps.join("\n"), "🚀 Updating");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCancelledEmbed() {
|
|
||||||
return createInfoEmbed("Update cancelled. No changes were made.", "❌ Cancelled");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTimeoutEmbed() {
|
|
||||||
return createWarningEmbed(
|
|
||||||
"No response received within 30 seconds.\nRun `/update` again when ready.",
|
|
||||||
"⏰ Timed Out"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getErrorEmbed(error: unknown) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
return createErrorEmbed(
|
|
||||||
`The update could not be completed:\n\`\`\`\n${truncate(message, 500)}\n\`\`\``,
|
|
||||||
"❌ Update Failed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Post-Restart Embeds ============
|
|
||||||
|
|
||||||
export interface PostRestartResult {
|
|
||||||
installSuccess: boolean;
|
|
||||||
installOutput: string;
|
|
||||||
migrationSuccess: boolean;
|
|
||||||
migrationOutput: string;
|
|
||||||
ranInstall: boolean;
|
|
||||||
ranMigrations: boolean;
|
|
||||||
previousCommit?: string;
|
|
||||||
newCommit?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPostRestartEmbed(result: PostRestartResult, hasRollback: boolean) {
|
|
||||||
const isSuccess = result.installSuccess && result.migrationSuccess;
|
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setTitle(isSuccess ? "✅ Update Complete" : "⚠️ Update Completed with Issues")
|
|
||||||
.setColor(isSuccess ? 0x57F287 : 0xFEE75C)
|
|
||||||
.setTimestamp();
|
|
||||||
|
|
||||||
// Version info
|
|
||||||
if (result.previousCommit && result.newCommit) {
|
|
||||||
embed.addFields({
|
|
||||||
name: "Version",
|
|
||||||
value: `\`${result.previousCommit}\` → \`${result.newCommit}\``,
|
|
||||||
inline: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Results summary
|
|
||||||
const results: string[] = [];
|
|
||||||
|
|
||||||
if (result.ranInstall) {
|
|
||||||
results.push(result.installSuccess
|
|
||||||
? "✅ Dependencies installed"
|
|
||||||
: "❌ Dependency installation failed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.ranMigrations) {
|
|
||||||
results.push(result.migrationSuccess
|
|
||||||
? "✅ Migrations applied"
|
|
||||||
: "❌ Migration failed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (results.length > 0) {
|
|
||||||
embed.addFields({
|
|
||||||
name: "Actions Performed",
|
|
||||||
value: results.join("\n"),
|
|
||||||
inline: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output details (collapsed if too long)
|
|
||||||
if (result.installOutput && !result.installSuccess) {
|
|
||||||
embed.addFields({
|
|
||||||
name: "Install Output",
|
|
||||||
value: `\`\`\`\n${truncate(result.installOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
|
|
||||||
inline: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.migrationOutput && !result.migrationSuccess) {
|
|
||||||
embed.addFields({
|
|
||||||
name: "Migration Output",
|
|
||||||
value: `\`\`\`\n${truncate(result.migrationOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
|
|
||||||
inline: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Footer with rollback hint
|
|
||||||
if (!isSuccess && hasRollback) {
|
|
||||||
embed.setFooter({ text: "💡 Use /update rollback to revert if needed" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build components
|
|
||||||
const components: ActionRowBuilder<ButtonBuilder>[] = [];
|
|
||||||
|
|
||||||
if (!isSuccess && hasRollback) {
|
|
||||||
const rollbackButton = new ButtonBuilder()
|
|
||||||
.setCustomId("rollback_update")
|
|
||||||
.setLabel("Rollback")
|
|
||||||
.setEmoji("↩️")
|
|
||||||
.setStyle(ButtonStyle.Danger);
|
|
||||||
|
|
||||||
components.push(new ActionRowBuilder<ButtonBuilder>().addComponents(rollbackButton));
|
|
||||||
}
|
|
||||||
|
|
||||||
return { embeds: [embed], components };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getInstallingDependenciesEmbed() {
|
|
||||||
return createInfoEmbed(
|
|
||||||
"📦 Installing dependencies for root and web projects...\nThis may take a moment.",
|
|
||||||
"⏳ Installing Dependencies"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRunningMigrationsEmbed() {
|
|
||||||
return createInfoEmbed(
|
|
||||||
"🗃️ Applying database migrations...",
|
|
||||||
"⏳ Running Migrations"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRollbackSuccessEmbed(commit: string) {
|
|
||||||
return createSuccessEmbed(
|
|
||||||
`Successfully rolled back to commit \`${commit}\`.\nThe bot will restart now.`,
|
|
||||||
"↩️ Rollback Complete"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRollbackFailedEmbed(error: string) {
|
|
||||||
return createErrorEmbed(
|
|
||||||
`Could not rollback:\n\`\`\`\n${error}\n\`\`\``,
|
|
||||||
"❌ Rollback Failed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ButtonInteraction } from "discord.js";
|
import { ButtonInteraction } from "discord.js";
|
||||||
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
|
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
|
||||||
import { UserError } from "@/lib/errors";
|
import { UserError } from "@shared/lib/errors";
|
||||||
import { getLootdropClaimedMessage } from "./lootdrop.view";
|
import { getLootdropClaimedMessage } from "./lootdrop.view";
|
||||||
|
|
||||||
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
|
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ButtonInteraction, MessageFlags } from "discord.js";
|
import { ButtonInteraction, MessageFlags } from "discord.js";
|
||||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { UserError } from "@/lib/errors";
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
|
||||||
export async function handleShopInteraction(interaction: ButtonInteraction) {
|
export async function handleShopInteraction(interaction: ButtonInteraction) {
|
||||||
if (!interaction.customId.startsWith("shop_buy_")) return;
|
if (!interaction.customId.startsWith("shop_buy_")) return;
|
||||||
|
|||||||
@@ -1,20 +1,208 @@
|
|||||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
|
import {
|
||||||
import { createBaseEmbed } from "@/lib/embeds";
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
AttachmentBuilder,
|
||||||
|
Colors,
|
||||||
|
ContainerBuilder,
|
||||||
|
SectionBuilder,
|
||||||
|
TextDisplayBuilder,
|
||||||
|
MediaGalleryBuilder,
|
||||||
|
MediaGalleryItemBuilder,
|
||||||
|
ThumbnailBuilder,
|
||||||
|
SeparatorBuilder,
|
||||||
|
SeparatorSpacingSize,
|
||||||
|
MessageFlags
|
||||||
|
} from "discord.js";
|
||||||
|
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
|
||||||
|
import { join } from "path";
|
||||||
|
import { existsSync } from "fs";
|
||||||
|
import { LootType, EffectType } from "@shared/lib/constants";
|
||||||
|
import type { LootTableItem } from "@shared/lib/types";
|
||||||
|
|
||||||
export function getShopListingMessage(item: { id: number; name: string; description: string | null; formattedPrice: string; iconUrl: string | null; imageUrl: string | null; price: number | bigint }) {
|
// Rarity Color Map
|
||||||
const embed = createBaseEmbed(`Shop: ${item.name}`, item.description || "No description available.", "Green")
|
const RarityColors: Record<string, number> = {
|
||||||
.addFields({ name: "Price", value: item.formattedPrice, inline: true })
|
"C": Colors.LightGrey,
|
||||||
.setThumbnail(item.iconUrl || null)
|
"R": Colors.Blue,
|
||||||
.setImage(item.imageUrl || null)
|
"SR": Colors.Purple,
|
||||||
.setFooter({ text: "Click the button below to purchase instantly." });
|
"SSR": Colors.Gold,
|
||||||
|
"CURRENCY": Colors.Green,
|
||||||
|
"XP": Colors.Aqua,
|
||||||
|
"NOTHING": Colors.DarkButNotBlack
|
||||||
|
};
|
||||||
|
|
||||||
|
const TitleMap: Record<string, string> = {
|
||||||
|
"C": "📦 Common Items",
|
||||||
|
"R": "📦 Rare Items",
|
||||||
|
"SR": "✨ Super Rare Items",
|
||||||
|
"SSR": "🌟 SSR Items",
|
||||||
|
"CURRENCY": "💰 Currency",
|
||||||
|
"XP": "🔮 Experience",
|
||||||
|
"NOTHING": "💨 Empty"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getShopListingMessage(
|
||||||
|
item: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
formattedPrice: string;
|
||||||
|
iconUrl: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
price: number | bigint;
|
||||||
|
usageData?: any;
|
||||||
|
rarity?: string;
|
||||||
|
},
|
||||||
|
context?: { referencedItems: Map<number, { name: string; rarity: string }> }
|
||||||
|
) {
|
||||||
|
const files: AttachmentBuilder[] = [];
|
||||||
|
let thumbnailUrl = resolveAssetUrl(item.iconUrl);
|
||||||
|
let displayImageUrl = resolveAssetUrl(item.imageUrl);
|
||||||
|
|
||||||
|
// Handle local icon
|
||||||
|
if (item.iconUrl && isLocalAssetUrl(item.iconUrl)) {
|
||||||
|
const iconPath = join(process.cwd(), "bot/assets/graphics", item.iconUrl.replace(/^\/?assets\//, ""));
|
||||||
|
if (existsSync(iconPath)) {
|
||||||
|
const iconName = defaultName(item.iconUrl);
|
||||||
|
files.push(new AttachmentBuilder(iconPath, { name: iconName }));
|
||||||
|
thumbnailUrl = `attachment://${iconName}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle local image
|
||||||
|
if (item.imageUrl && isLocalAssetUrl(item.imageUrl)) {
|
||||||
|
if (item.imageUrl === item.iconUrl && thumbnailUrl?.startsWith("attachment://")) {
|
||||||
|
displayImageUrl = thumbnailUrl;
|
||||||
|
} else {
|
||||||
|
const imagePath = join(process.cwd(), "bot/assets/graphics", item.imageUrl.replace(/^\/?assets\//, ""));
|
||||||
|
if (existsSync(imagePath)) {
|
||||||
|
const imageName = defaultName(item.imageUrl);
|
||||||
|
if (!files.find(f => f.name === imageName)) {
|
||||||
|
files.push(new AttachmentBuilder(imagePath, { name: imageName }));
|
||||||
|
}
|
||||||
|
displayImageUrl = `attachment://${imageName}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const containers: ContainerBuilder[] = [];
|
||||||
|
|
||||||
|
// 1. Main Container
|
||||||
|
const mainContainer = new ContainerBuilder()
|
||||||
|
.setAccentColor(RarityColors[item.rarity || "C"] || Colors.Green);
|
||||||
|
|
||||||
|
// Header Section
|
||||||
|
const infoSection = new SectionBuilder()
|
||||||
|
.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(`# ${item.name}`),
|
||||||
|
new TextDisplayBuilder().setContent(item.description || "_No description available._"),
|
||||||
|
new TextDisplayBuilder().setContent(`### 🏷️ Price: ${item.formattedPrice}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set Thumbnail Accessory if we have an icon
|
||||||
|
if (thumbnailUrl) {
|
||||||
|
infoSection.setThumbnailAccessory(new ThumbnailBuilder().setURL(thumbnailUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
mainContainer.addSectionComponents(infoSection);
|
||||||
|
|
||||||
|
// Media Gallery for additional images (if multiple)
|
||||||
|
const mediaSources: string[] = [];
|
||||||
|
if (thumbnailUrl) mediaSources.push(thumbnailUrl);
|
||||||
|
if (displayImageUrl && displayImageUrl !== thumbnailUrl) mediaSources.push(displayImageUrl);
|
||||||
|
|
||||||
|
if (mediaSources.length > 1) {
|
||||||
|
mainContainer.addMediaGalleryComponents(
|
||||||
|
new MediaGalleryBuilder().addItems(
|
||||||
|
...mediaSources.map(src => new MediaGalleryItemBuilder().setURL(src))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Loot Table (if applicable)
|
||||||
|
if (item.usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX)) {
|
||||||
|
const lootboxEffect = item.usageData.effects.find((e: any) => e.type === EffectType.LOOTBOX);
|
||||||
|
const pool = lootboxEffect.pool as LootTableItem[];
|
||||||
|
const totalWeight = pool.reduce((sum, i) => sum + i.weight, 0);
|
||||||
|
|
||||||
|
mainContainer.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||||
|
mainContainer.addTextDisplayComponents(new TextDisplayBuilder().setContent("## 🎁 Potential Rewards"));
|
||||||
|
|
||||||
|
const groups: Record<string, string[]> = {};
|
||||||
|
for (const drop of pool) {
|
||||||
|
const chance = ((drop.weight / totalWeight) * 100).toFixed(1);
|
||||||
|
let line = "";
|
||||||
|
let rarity = "C";
|
||||||
|
|
||||||
|
switch (drop.type as any) {
|
||||||
|
case LootType.CURRENCY:
|
||||||
|
const currAmount = (drop.minAmount != null && drop.maxAmount != null)
|
||||||
|
? `${drop.minAmount} - ${drop.maxAmount}`
|
||||||
|
: (Array.isArray(drop.amount) ? `${drop.amount[0]} - ${drop.amount[1]}` : drop.amount || 0);
|
||||||
|
line = `**${currAmount} 🪙** (${chance}%)`;
|
||||||
|
rarity = "CURRENCY";
|
||||||
|
break;
|
||||||
|
case LootType.XP:
|
||||||
|
const xpAmount = (drop.minAmount != null && drop.maxAmount != null)
|
||||||
|
? `${drop.minAmount} - ${drop.maxAmount}`
|
||||||
|
: (Array.isArray(drop.amount) ? `${drop.amount[0]} - ${drop.amount[1]}` : drop.amount || 0);
|
||||||
|
line = `**${xpAmount} XP** (${chance}%)`;
|
||||||
|
rarity = "XP";
|
||||||
|
break;
|
||||||
|
case LootType.ITEM:
|
||||||
|
const referencedItems = context?.referencedItems;
|
||||||
|
if (drop.itemId && referencedItems?.has(drop.itemId)) {
|
||||||
|
const i = referencedItems.get(drop.itemId)!;
|
||||||
|
line = `**${i.name}** x${drop.amount || 1} (${chance}%)`;
|
||||||
|
rarity = i.rarity;
|
||||||
|
} else {
|
||||||
|
line = `**Unknown Item** (${chance}%)`;
|
||||||
|
rarity = "C";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case LootType.NOTHING:
|
||||||
|
line = `**Nothing** (${chance}%)`;
|
||||||
|
rarity = "NOTHING";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line) {
|
||||||
|
if (!groups[rarity]) groups[rarity] = [];
|
||||||
|
groups[rarity]!.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = ["SSR", "SR", "R", "C", "CURRENCY", "XP", "NOTHING"];
|
||||||
|
for (const rarity of order) {
|
||||||
|
if (groups[rarity] && groups[rarity]!.length > 0) {
|
||||||
|
mainContainer.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(`### ${TitleMap[rarity] || rarity}`),
|
||||||
|
new TextDisplayBuilder().setContent(groups[rarity]!.join("\n"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purchase Row
|
||||||
const buyButton = new ButtonBuilder()
|
const buyButton = new ButtonBuilder()
|
||||||
.setCustomId(`shop_buy_${item.id}`)
|
.setCustomId(`shop_buy_${item.id}`)
|
||||||
.setLabel(`Buy for ${item.price} 🪙`)
|
.setLabel(`Purchase for ${item.price} 🪙`)
|
||||||
.setStyle(ButtonStyle.Success)
|
.setStyle(ButtonStyle.Success)
|
||||||
.setEmoji("🛒");
|
.setEmoji("🛒");
|
||||||
|
|
||||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton);
|
mainContainer.addActionRowComponents(
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton)
|
||||||
|
);
|
||||||
|
|
||||||
return { embeds: [embed], components: [row] };
|
containers.push(mainContainer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
components: containers as any,
|
||||||
|
files,
|
||||||
|
flags: MessageFlags.IsComponentsV2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultName(path: string): string {
|
||||||
|
return path.split("/").pop() || "image.png";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { Interaction } from "discord.js";
|
import type { Interaction } from "discord.js";
|
||||||
import { TextChannel, MessageFlags } from "discord.js";
|
import { TextChannel, MessageFlags } from "discord.js";
|
||||||
import { config } from "@shared/lib/config";
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
|
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
|
||||||
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
|
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
|
||||||
import { UserError } from "@/lib/errors";
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
|
||||||
export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
||||||
// Handle select menu for choosing feedback type
|
// Handle select menu for choosing feedback type
|
||||||
@@ -33,7 +33,13 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
|||||||
throw new UserError("An error occurred processing your feedback. Please try again.");
|
throw new UserError("An error occurred processing your feedback. Please try again.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!config.feedbackChannelId) {
|
if (!interaction.guildId) {
|
||||||
|
throw new UserError("This action can only be performed in a server.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const guildConfig = await getGuildConfig(interaction.guildId);
|
||||||
|
|
||||||
|
if (!guildConfig.feedbackChannelId) {
|
||||||
throw new UserError("Feedback channel is not configured. Please contact an administrator.");
|
throw new UserError("Feedback channel is not configured. Please contact an administrator.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +58,7 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Get feedback channel
|
// Get feedback channel
|
||||||
const channel = await AuroraClient.channels.fetch(config.feedbackChannelId).catch(() => null) as TextChannel | null;
|
const channel = await AuroraClient.channels.fetch(guildConfig.feedbackChannelId).catch(() => null) as TextChannel | null;
|
||||||
|
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
throw new UserError("Feedback channel not found. Please contact an administrator.");
|
throw new UserError("Feedback channel not found. Please contact an administrator.");
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { levelingService } from "@shared/modules/leveling/leveling.service";
|
import { levelingService } from "@shared/modules/leveling/leveling.service";
|
||||||
import { economyService } from "@shared/modules/economy/economy.service";
|
import { economyService } from "@shared/modules/economy/economy.service";
|
||||||
import { userTimers } from "@db/schema";
|
import { userTimers } from "@db/schema";
|
||||||
import type { EffectHandler } from "./types";
|
import type { EffectHandler, ValidatedEffectPayload } from "./effect.types";
|
||||||
|
import { EffectType } from "@shared/lib/constants";
|
||||||
import type { LootTableItem } from "@shared/lib/types";
|
import type { LootTableItem } from "@shared/lib/types";
|
||||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { inventory, items } from "@db/schema";
|
import { inventory, items } from "@db/schema";
|
||||||
@@ -15,21 +16,21 @@ const getDuration = (effect: any): number => {
|
|||||||
return effect.durationSeconds || 60; // Default to 60s if nothing provided
|
return effect.durationSeconds || 60; // Default to 60s if nothing provided
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleAddXp: EffectHandler = async (userId, effect, txFn) => {
|
export const handleAddXp: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.ADD_XP }>, txFn) => {
|
||||||
await levelingService.addXp(userId, BigInt(effect.amount), txFn);
|
await levelingService.addXp(userId, BigInt(effect.amount), txFn);
|
||||||
return `Gained ${effect.amount} XP`;
|
return `Gained ${effect.amount} XP`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleAddBalance: EffectHandler = async (userId, effect, txFn) => {
|
export const handleAddBalance: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.ADD_BALANCE }>, txFn) => {
|
||||||
await economyService.modifyUserBalance(userId, BigInt(effect.amount), TransactionType.ITEM_USE, `Used Item`, null, txFn);
|
await economyService.modifyUserBalance(userId, BigInt(effect.amount), TransactionType.ITEM_USE, `Used Item`, null, txFn);
|
||||||
return `Gained ${effect.amount} 🪙`;
|
return `Gained ${effect.amount} 🪙`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleReplyMessage: EffectHandler = async (_userId, effect, _txFn) => {
|
export const handleReplyMessage: EffectHandler = async (_userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.REPLY_MESSAGE }>, _txFn) => {
|
||||||
return effect.message;
|
return effect.message;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
|
export const handleXpBoost: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.XP_BOOST }>, txFn) => {
|
||||||
const boostDuration = getDuration(effect);
|
const boostDuration = getDuration(effect);
|
||||||
const expiresAt = new Date(Date.now() + boostDuration * 1000);
|
const expiresAt = new Date(Date.now() + boostDuration * 1000);
|
||||||
await txFn.insert(userTimers).values({
|
await txFn.insert(userTimers).values({
|
||||||
@@ -45,7 +46,7 @@ export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
return `XP Boost (${effect.multiplier}x) active for ${Math.floor(boostDuration / 60)}m`;
|
return `XP Boost (${effect.multiplier}x) active for ${Math.floor(boostDuration / 60)}m`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
|
export const handleTempRole: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.TEMP_ROLE }>, txFn) => {
|
||||||
const roleDuration = getDuration(effect);
|
const roleDuration = getDuration(effect);
|
||||||
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
|
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
|
||||||
await txFn.insert(userTimers).values({
|
await txFn.insert(userTimers).values({
|
||||||
@@ -62,11 +63,11 @@ export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
return `Temporary Role granted for ${Math.floor(roleDuration / 60)}m`;
|
return `Temporary Role granted for ${Math.floor(roleDuration / 60)}m`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleColorRole: EffectHandler = async (_userId, _effect, _txFn) => {
|
export const handleColorRole: EffectHandler = async (_userId, _effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.COLOR_ROLE }>, _txFn) => {
|
||||||
return "Color Role Equipped";
|
return "Color Role Equipped";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
export const handleLootbox: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.LOOTBOX }>, txFn) => {
|
||||||
const pool = effect.pool as LootTableItem[];
|
const pool = effect.pool as LootTableItem[];
|
||||||
if (!pool || pool.length === 0) return "The box is empty...";
|
if (!pool || pool.length === 0) return "The box is empty...";
|
||||||
|
|
||||||
@@ -86,7 +87,11 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
|
|
||||||
// Process Winner
|
// Process Winner
|
||||||
if (winner.type === LootType.NOTHING) {
|
if (winner.type === LootType.NOTHING) {
|
||||||
return winner.message || "You found nothing inside.";
|
return {
|
||||||
|
type: 'LOOTBOX_RESULT',
|
||||||
|
rewardType: 'NOTHING',
|
||||||
|
message: winner.message || "You found nothing inside."
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (winner.type === LootType.CURRENCY) {
|
if (winner.type === LootType.CURRENCY) {
|
||||||
@@ -96,7 +101,12 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
}
|
}
|
||||||
if (amount > 0) {
|
if (amount > 0) {
|
||||||
await economyService.modifyUserBalance(userId, BigInt(amount), TransactionType.LOOTBOX, 'Lootbox Reward', null, txFn);
|
await economyService.modifyUserBalance(userId, BigInt(amount), TransactionType.LOOTBOX, 'Lootbox Reward', null, txFn);
|
||||||
return winner.message || `You found ${amount} 🪙!`;
|
return {
|
||||||
|
type: 'LOOTBOX_RESULT',
|
||||||
|
rewardType: 'CURRENCY',
|
||||||
|
amount: amount,
|
||||||
|
message: winner.message || `You found ${amount} 🪙!`
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +117,12 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
}
|
}
|
||||||
if (amount > 0) {
|
if (amount > 0) {
|
||||||
await levelingService.addXp(userId, BigInt(amount), txFn);
|
await levelingService.addXp(userId, BigInt(amount), txFn);
|
||||||
return winner.message || `You gained ${amount} XP!`;
|
return {
|
||||||
|
type: 'LOOTBOX_RESULT',
|
||||||
|
rewardType: 'XP',
|
||||||
|
amount: amount,
|
||||||
|
message: winner.message || `You gained ${amount} XP!`
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +138,18 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
where: (items: any, { eq }: any) => eq(items.id, winner.itemId!)
|
where: (items: any, { eq }: any) => eq(items.id, winner.itemId!)
|
||||||
});
|
});
|
||||||
if (item) {
|
if (item) {
|
||||||
return winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`;
|
return {
|
||||||
|
type: 'LOOTBOX_RESULT',
|
||||||
|
rewardType: 'ITEM',
|
||||||
|
amount: Number(quantity),
|
||||||
|
item: {
|
||||||
|
name: item.name,
|
||||||
|
rarity: item.rarity,
|
||||||
|
description: item.description,
|
||||||
|
image: item.imageUrl || item.iconUrl
|
||||||
|
},
|
||||||
|
message: winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch item name for lootbox message", e);
|
console.error("Failed to fetch item name for lootbox message", e);
|
||||||
41
bot/modules/inventory/effect.registry.ts
Normal file
41
bot/modules/inventory/effect.registry.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
handleAddXp,
|
||||||
|
handleAddBalance,
|
||||||
|
handleReplyMessage,
|
||||||
|
handleXpBoost,
|
||||||
|
handleTempRole,
|
||||||
|
handleColorRole,
|
||||||
|
handleLootbox
|
||||||
|
} from "./effect.handlers";
|
||||||
|
import type { EffectHandler, ValidatedEffectPayload } from "./effect.types";
|
||||||
|
import { EffectPayloadSchema } from "./effect.types";
|
||||||
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
import type { Transaction } from "@shared/lib/types";
|
||||||
|
|
||||||
|
export const effectHandlers: Record<string, EffectHandler> = {
|
||||||
|
'ADD_XP': handleAddXp,
|
||||||
|
'ADD_BALANCE': handleAddBalance,
|
||||||
|
'REPLY_MESSAGE': handleReplyMessage,
|
||||||
|
'XP_BOOST': handleXpBoost,
|
||||||
|
'TEMP_ROLE': handleTempRole,
|
||||||
|
'COLOR_ROLE': handleColorRole,
|
||||||
|
'LOOTBOX': handleLootbox
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function validateAndExecuteEffect(
|
||||||
|
effect: unknown,
|
||||||
|
userId: string,
|
||||||
|
tx: Transaction
|
||||||
|
) {
|
||||||
|
const result = EffectPayloadSchema.safeParse(effect);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new UserError(`Invalid effect configuration: ${result.error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = effectHandlers[result.data.type];
|
||||||
|
if (!handler) {
|
||||||
|
throw new UserError(`Unknown effect type: ${result.data.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler(userId, result.data, tx);
|
||||||
|
}
|
||||||
71
bot/modules/inventory/effect.types.ts
Normal file
71
bot/modules/inventory/effect.types.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { Transaction } from "@shared/lib/types";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { EffectType, LootType } from "@shared/lib/constants";
|
||||||
|
|
||||||
|
// Helper Schemas
|
||||||
|
const LootTableItemSchema = z.object({
|
||||||
|
type: z.nativeEnum(LootType),
|
||||||
|
weight: z.number(),
|
||||||
|
amount: z.number().optional(),
|
||||||
|
itemId: z.number().optional(),
|
||||||
|
minAmount: z.number().optional(),
|
||||||
|
maxAmount: z.number().optional(),
|
||||||
|
message: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const DurationSchema = z.object({
|
||||||
|
durationSeconds: z.number().optional(),
|
||||||
|
durationMinutes: z.number().optional(),
|
||||||
|
durationHours: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Effect Schemas
|
||||||
|
const AddXpSchema = z.object({
|
||||||
|
type: z.literal(EffectType.ADD_XP),
|
||||||
|
amount: z.number().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const AddBalanceSchema = z.object({
|
||||||
|
type: z.literal(EffectType.ADD_BALANCE),
|
||||||
|
amount: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ReplyMessageSchema = z.object({
|
||||||
|
type: z.literal(EffectType.REPLY_MESSAGE),
|
||||||
|
message: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const XpBoostSchema = DurationSchema.extend({
|
||||||
|
type: z.literal(EffectType.XP_BOOST),
|
||||||
|
multiplier: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const TempRoleSchema = DurationSchema.extend({
|
||||||
|
type: z.literal(EffectType.TEMP_ROLE),
|
||||||
|
roleId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ColorRoleSchema = z.object({
|
||||||
|
type: z.literal(EffectType.COLOR_ROLE),
|
||||||
|
roleId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const LootboxSchema = z.object({
|
||||||
|
type: z.literal(EffectType.LOOTBOX),
|
||||||
|
pool: z.array(LootTableItemSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Union Schema
|
||||||
|
export const EffectPayloadSchema = z.discriminatedUnion('type', [
|
||||||
|
AddXpSchema,
|
||||||
|
AddBalanceSchema,
|
||||||
|
ReplyMessageSchema,
|
||||||
|
XpBoostSchema,
|
||||||
|
TempRoleSchema,
|
||||||
|
ColorRoleSchema,
|
||||||
|
LootboxSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type ValidatedEffectPayload = z.infer<typeof EffectPayloadSchema>;
|
||||||
|
|
||||||
|
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<any>;
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import {
|
|
||||||
handleAddXp,
|
|
||||||
handleAddBalance,
|
|
||||||
handleReplyMessage,
|
|
||||||
handleXpBoost,
|
|
||||||
handleTempRole,
|
|
||||||
handleColorRole,
|
|
||||||
handleLootbox
|
|
||||||
} from "./handlers";
|
|
||||||
import type { EffectHandler } from "./types";
|
|
||||||
|
|
||||||
export const effectHandlers: Record<string, EffectHandler> = {
|
|
||||||
'ADD_XP': handleAddXp,
|
|
||||||
'ADD_BALANCE': handleAddBalance,
|
|
||||||
'REPLY_MESSAGE': handleReplyMessage,
|
|
||||||
'XP_BOOST': handleXpBoost,
|
|
||||||
'TEMP_ROLE': handleTempRole,
|
|
||||||
'COLOR_ROLE': handleColorRole,
|
|
||||||
'LOOTBOX': handleLootbox
|
|
||||||
};
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
|
|
||||||
import type { Transaction } from "@shared/lib/types";
|
|
||||||
|
|
||||||
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<string>;
|
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { EmbedBuilder } from "discord.js";
|
import { EmbedBuilder, AttachmentBuilder } from "discord.js";
|
||||||
import type { ItemUsageData } from "@shared/lib/types";
|
import type { ItemUsageData } from "@shared/lib/types";
|
||||||
import { EffectType } from "@shared/lib/constants";
|
import { EffectType } from "@shared/lib/constants";
|
||||||
|
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
|
||||||
|
import { join } from "path";
|
||||||
|
import { existsSync } from "fs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inventory entry with item details
|
* Inventory entry with item details
|
||||||
@@ -31,24 +34,107 @@ export function getInventoryEmbed(items: InventoryEntry[], username: string): Em
|
|||||||
/**
|
/**
|
||||||
* Creates an embed showing the results of using an item
|
* Creates an embed showing the results of using an item
|
||||||
*/
|
*/
|
||||||
export function getItemUseResultEmbed(results: string[], item?: { name: string, iconUrl: string | null, usageData: any }): EmbedBuilder {
|
export function getItemUseResultEmbed(results: any[], item?: { name: string, iconUrl: string | null, usageData: any }): { embed: EmbedBuilder, files: AttachmentBuilder[] } {
|
||||||
const description = results.map(r => `• ${r}`).join("\n");
|
const embed = new EmbedBuilder();
|
||||||
|
const files: AttachmentBuilder[] = [];
|
||||||
|
const otherMessages: string[] = [];
|
||||||
|
let lootResult: any = null;
|
||||||
|
|
||||||
// Check if it was a lootbox
|
for (const res of results) {
|
||||||
const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === EffectType.LOOTBOX);
|
if (typeof res === 'object' && res.type === 'LOOTBOX_RESULT') {
|
||||||
|
lootResult = res;
|
||||||
const embed = new EmbedBuilder()
|
} else {
|
||||||
.setDescription(description)
|
otherMessages.push(typeof res === 'string' ? `• ${res}` : `• ${JSON.stringify(res)}`);
|
||||||
.setColor(isLootbox ? 0xFFD700 : 0x2ecc71); // Gold for lootbox, Green otherwise
|
|
||||||
|
|
||||||
if (isLootbox && item) {
|
|
||||||
embed.setTitle(`🎁 ${item.name} Opened!`);
|
|
||||||
if (item.iconUrl) {
|
|
||||||
embed.setThumbnail(item.iconUrl);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return embed;
|
// Default Configuration
|
||||||
|
const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === EffectType.LOOTBOX);
|
||||||
|
embed.setColor(isLootbox ? 0xFFD700 : 0x2ecc71); // Gold for lootbox, Green otherwise by default
|
||||||
|
embed.setTimestamp();
|
||||||
|
|
||||||
|
if (lootResult) {
|
||||||
|
embed.setTitle(`🎁 ${item?.name || "Lootbox"} Opened!`);
|
||||||
|
|
||||||
|
if (lootResult.rewardType === 'ITEM' && lootResult.item) {
|
||||||
|
const i = lootResult.item;
|
||||||
|
const amountStr = lootResult.amount > 1 ? `x${lootResult.amount}` : '';
|
||||||
|
|
||||||
|
// Rarity Colors
|
||||||
|
const rarityColors: Record<string, number> = {
|
||||||
|
'C': 0x95A5A6, // Gray
|
||||||
|
'R': 0x3498DB, // Blue
|
||||||
|
'SR': 0x9B59B6, // Purple
|
||||||
|
'SSR': 0xF1C40F // Gold
|
||||||
|
};
|
||||||
|
|
||||||
|
const rarityKey = i.rarity || 'C';
|
||||||
|
if (rarityKey in rarityColors) {
|
||||||
|
embed.setColor(rarityColors[rarityKey] ?? 0x95A5A6);
|
||||||
|
} else {
|
||||||
|
embed.setColor(0x95A5A6);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i.image) {
|
||||||
|
if (isLocalAssetUrl(i.image)) {
|
||||||
|
const imagePath = join(process.cwd(), "bot/assets/graphics", i.image.replace(/^\/?assets\//, ""));
|
||||||
|
if (existsSync(imagePath)) {
|
||||||
|
const imageName = defaultName(i.image);
|
||||||
|
if (!files.find(f => f.name === imageName)) {
|
||||||
|
files.push(new AttachmentBuilder(imagePath, { name: imageName }));
|
||||||
|
}
|
||||||
|
embed.setImage(`attachment://${imageName}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const imgUrl = resolveAssetUrl(i.image);
|
||||||
|
if (imgUrl) embed.setImage(imgUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
embed.setDescription(`**You found ${i.name} ${amountStr}!**\n${i.description || '_'}`);
|
||||||
|
embed.addFields({ name: 'Rarity', value: rarityKey, inline: true });
|
||||||
|
|
||||||
|
} else if (lootResult.rewardType === 'CURRENCY') {
|
||||||
|
embed.setColor(0xF1C40F);
|
||||||
|
embed.setDescription(`**You found ${lootResult.amount.toLocaleString()} 🪙 AU!**`);
|
||||||
|
} else if (lootResult.rewardType === 'XP') {
|
||||||
|
embed.setColor(0x2ECC71); // Green
|
||||||
|
embed.setDescription(`**You gained ${lootResult.amount.toLocaleString()} XP!**`);
|
||||||
|
} else {
|
||||||
|
// Nothing or Message
|
||||||
|
embed.setDescription(lootResult.message);
|
||||||
|
embed.setColor(0x95A5A6); // Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Standard item usage
|
||||||
|
embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!");
|
||||||
|
embed.setDescription(otherMessages.join("\n") || "Effect applied.");
|
||||||
|
|
||||||
|
if (isLootbox && item && item.iconUrl) {
|
||||||
|
if (isLocalAssetUrl(item.iconUrl)) {
|
||||||
|
const iconPath = join(process.cwd(), "bot/assets/graphics", item.iconUrl.replace(/^\/?assets\//, ""));
|
||||||
|
if (existsSync(iconPath)) {
|
||||||
|
const iconName = defaultName(item.iconUrl);
|
||||||
|
if (!files.find(f => f.name === iconName)) {
|
||||||
|
files.push(new AttachmentBuilder(iconPath, { name: iconName }));
|
||||||
|
}
|
||||||
|
embed.setThumbnail(`attachment://${iconName}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const resolvedIconUrl = resolveAssetUrl(item.iconUrl);
|
||||||
|
if (resolvedIconUrl) embed.setThumbnail(resolvedIconUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (otherMessages.length > 0 && lootResult) {
|
||||||
|
embed.addFields({ name: "Other Effects", value: otherMessages.join("\n") });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { embed, files };
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultName(path: string): string {
|
||||||
|
return path.split("/").pop() || "image.png";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { EmbedBuilder } from "discord.js";
|
import {
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
ContainerBuilder,
|
||||||
|
TextDisplayBuilder,
|
||||||
|
SeparatorBuilder,
|
||||||
|
SeparatorSpacingSize,
|
||||||
|
MessageFlags
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Quest entry with quest details and progress
|
* Quest entry with quest details and progress
|
||||||
@@ -7,12 +16,33 @@ interface QuestEntry {
|
|||||||
progress: number | null;
|
progress: number | null;
|
||||||
completedAt: Date | null;
|
completedAt: Date | null;
|
||||||
quest: {
|
quest: {
|
||||||
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
triggerEvent: string;
|
||||||
|
requirements: any;
|
||||||
rewards: any;
|
rewards: any;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available quest interface
|
||||||
|
*/
|
||||||
|
interface AvailableQuest {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
rewards: any;
|
||||||
|
requirements: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color palette for containers
|
||||||
|
const COLORS = {
|
||||||
|
ACTIVE: 0x3498db, // Blue - in progress
|
||||||
|
AVAILABLE: 0x2ecc71, // Green - available
|
||||||
|
COMPLETED: 0xf1c40f // Gold - completed
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats quest rewards object into a human-readable string
|
* Formats quest rewards object into a human-readable string
|
||||||
*/
|
*/
|
||||||
@@ -20,35 +50,169 @@ function formatQuestRewards(rewards: { xp?: number, balance?: number }): string
|
|||||||
const rewardStr: string[] = [];
|
const rewardStr: string[] = [];
|
||||||
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
|
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
|
||||||
if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`);
|
if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`);
|
||||||
return rewardStr.join(", ");
|
return rewardStr.join(" • ") || "None";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the quest status display string
|
* Renders a simple progress bar
|
||||||
*/
|
*/
|
||||||
function getQuestStatus(completedAt: Date | null): string {
|
function renderProgressBar(current: number, total: number, size: number = 10): string {
|
||||||
return completedAt ? "✅ Completed" : "📝 In Progress";
|
const percentage = Math.min(current / total, 1);
|
||||||
|
const progress = Math.round(size * percentage);
|
||||||
|
const empty = size - progress;
|
||||||
|
|
||||||
|
const progressText = "▰".repeat(progress);
|
||||||
|
const emptyText = "▱".repeat(empty);
|
||||||
|
|
||||||
|
return `${progressText}${emptyText} ${Math.round(percentage * 100)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an embed displaying a user's quest log
|
* Creates Components v2 containers for the quest list (active quests only)
|
||||||
*/
|
*/
|
||||||
export function getQuestListEmbed(userQuests: QuestEntry[]): EmbedBuilder {
|
export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuilder[] {
|
||||||
const embed = new EmbedBuilder()
|
// Filter to only show in-progress quests (not completed)
|
||||||
.setTitle("📜 Quest Log")
|
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
|
||||||
.setColor(0x3498db); // Blue
|
|
||||||
|
const container = new ContainerBuilder()
|
||||||
|
.setAccentColor(COLORS.ACTIVE)
|
||||||
|
.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent("# 📜 Quest Log"),
|
||||||
|
new TextDisplayBuilder().setContent("-# Your active quests")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (activeQuests.length === 0) {
|
||||||
|
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||||
|
container.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent("*You have no active quests. Check available quests!*")
|
||||||
|
);
|
||||||
|
return [container];
|
||||||
|
}
|
||||||
|
|
||||||
|
activeQuests.forEach((entry) => {
|
||||||
|
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||||
|
|
||||||
userQuests.forEach(entry => {
|
|
||||||
const status = getQuestStatus(entry.completedAt);
|
|
||||||
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
||||||
const rewardsText = formatQuestRewards(rewards);
|
const rewardsText = formatQuestRewards(rewards);
|
||||||
|
|
||||||
embed.addFields({
|
const requirements = entry.quest.requirements as { target?: number };
|
||||||
name: `${entry.quest.name} (${status})`,
|
const target = requirements?.target || 1;
|
||||||
value: `${entry.quest.description}\n**Rewards:** ${rewardsText}\n**Progress:** ${entry.progress}%`,
|
const progress = entry.progress || 0;
|
||||||
inline: false
|
const progressBar = renderProgressBar(progress, target);
|
||||||
});
|
|
||||||
|
container.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(`**${entry.quest.name}**`),
|
||||||
|
new TextDisplayBuilder().setContent(entry.quest.description || "*No description*"),
|
||||||
|
new TextDisplayBuilder().setContent(`📊 ${progressBar} \`${progress}/${target}\` • 🎁 ${rewardsText}`)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return embed;
|
return [container];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates Components v2 containers for available quests with inline accept buttons
|
||||||
|
*/
|
||||||
|
export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]): ContainerBuilder[] {
|
||||||
|
const container = new ContainerBuilder()
|
||||||
|
.setAccentColor(COLORS.AVAILABLE)
|
||||||
|
.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent("# 🗺️ Available Quests"),
|
||||||
|
new TextDisplayBuilder().setContent("-# Quests you can accept")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (availableQuests.length === 0) {
|
||||||
|
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||||
|
container.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent("*No new quests available at the moment.*")
|
||||||
|
);
|
||||||
|
return [container];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit to 10 quests (5 action rows max with 2 added for navigation)
|
||||||
|
const questsToShow = availableQuests.slice(0, 10);
|
||||||
|
|
||||||
|
questsToShow.forEach((quest) => {
|
||||||
|
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||||
|
|
||||||
|
const rewards = quest.rewards as { xp?: number, balance?: number };
|
||||||
|
const rewardsText = formatQuestRewards(rewards);
|
||||||
|
|
||||||
|
const requirements = quest.requirements as { target?: number };
|
||||||
|
const target = requirements?.target || 1;
|
||||||
|
|
||||||
|
container.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(`**${quest.name}**`),
|
||||||
|
new TextDisplayBuilder().setContent(quest.description || "*No description*"),
|
||||||
|
new TextDisplayBuilder().setContent(`🎯 Goal: \`${target}\` • 🎁 ${rewardsText}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add accept button inline within the container
|
||||||
|
container.addActionRowComponents(
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`quest_accept:${quest.id}`)
|
||||||
|
.setLabel("Accept Quest")
|
||||||
|
.setStyle(ButtonStyle.Success)
|
||||||
|
.setEmoji("✅")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [container];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns action rows for navigation only
|
||||||
|
*/
|
||||||
|
export function getQuestActionRows(viewType: 'active' | 'available'): ActionRowBuilder<ButtonBuilder>[] {
|
||||||
|
// Navigation row
|
||||||
|
const navRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId("quest_view_active")
|
||||||
|
.setLabel("📜 Active")
|
||||||
|
.setStyle(viewType === 'active' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
||||||
|
.setDisabled(viewType === 'active'),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId("quest_view_available")
|
||||||
|
.setLabel("🗺️ Available")
|
||||||
|
.setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
||||||
|
.setDisabled(viewType === 'available')
|
||||||
|
);
|
||||||
|
|
||||||
|
return [navRow];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates Components v2 celebratory message for quest completion
|
||||||
|
*/
|
||||||
|
export function getQuestCompletionComponents(quest: any, rewards: { xp: bigint, balance: bigint }): ContainerBuilder[] {
|
||||||
|
const rewardsText = formatQuestRewards({
|
||||||
|
xp: Number(rewards.xp),
|
||||||
|
balance: Number(rewards.balance)
|
||||||
|
});
|
||||||
|
|
||||||
|
const container = new ContainerBuilder()
|
||||||
|
.setAccentColor(COLORS.COMPLETED)
|
||||||
|
.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent("# 🎉 Quest Completed!"),
|
||||||
|
new TextDisplayBuilder().setContent(`Congratulations! You've completed **${quest.name}**`)
|
||||||
|
)
|
||||||
|
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small))
|
||||||
|
.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(`📝 ${quest.description || "No description provided."}`),
|
||||||
|
new TextDisplayBuilder().setContent(`🎁 **Rewards Earned:** ${rewardsText}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
return [container];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets MessageFlags and allowedMentions for Components v2 messages
|
||||||
|
*/
|
||||||
|
export function getComponentsV2MessageFlags() {
|
||||||
|
return {
|
||||||
|
flags: MessageFlags.IsComponentsV2,
|
||||||
|
allowedMentions: { parse: [] as const }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { temporaryRoleService } from "@shared/modules/system/temp-role.service";
|
import { temporaryRoleService } from "@shared/modules/system/temp-role.service";
|
||||||
|
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
||||||
|
|
||||||
export const schedulerService = {
|
export const schedulerService = {
|
||||||
start: () => {
|
start: () => {
|
||||||
@@ -10,7 +11,6 @@ export const schedulerService = {
|
|||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|
||||||
// 2. Terminal Update Loop (every 60s)
|
// 2. Terminal Update Loop (every 60s)
|
||||||
const { terminalService } = require("@shared/modules/terminal/terminal.service");
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
terminalService.update();
|
terminalService.update();
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { tradeService } from "@shared/modules/trade/trade.service";
|
import { tradeService } from "@shared/modules/trade/trade.service";
|
||||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
|
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";
|
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 { ButtonInteraction, MessageFlags } from "discord.js";
|
||||||
import { config } from "@shared/lib/config";
|
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
|
||||||
import { getEnrollmentSuccessMessage } from "./enrollment.view";
|
import { getEnrollmentSuccessMessage } from "./enrollment.view";
|
||||||
import { classService } from "@shared/modules/class/class.service";
|
import { classService } from "@shared/modules/class/class.service";
|
||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { UserError } from "@/lib/errors";
|
import { UserError } from "@shared/lib/errors";
|
||||||
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
||||||
|
|
||||||
export async function handleEnrollmentInteraction(interaction: ButtonInteraction) {
|
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.");
|
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) {
|
if (!studentRole || !visitorRole) {
|
||||||
throw new UserError("No student or visitor role configured for enrollment.");
|
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)
|
// 5. Send Welcome Message (if configured)
|
||||||
if (config.welcomeChannelId) {
|
if (welcomeChannelId) {
|
||||||
const welcomeChannel = interaction.guild.channels.cache.get(config.welcomeChannelId);
|
const welcomeChannel = interaction.guild.channels.cache.get(welcomeChannelId);
|
||||||
if (welcomeChannel && welcomeChannel.isTextBased()) {
|
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
|
const processedMessage = rawMessage
|
||||||
.replace(/{user}/g, member.toString())
|
.replace(/{user}/g, member.toString())
|
||||||
|
|||||||
44
bun.lock
44
bun.lock
@@ -5,19 +5,19 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "app",
|
"name": "app",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@napi-rs/canvas": "^0.1.84",
|
"@napi-rs/canvas": "^0.1.89",
|
||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
"zod": "^4.1.13",
|
"postgres": "^3.4.8",
|
||||||
|
"zod": "^4.3.6",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"drizzle-kit": "^0.31.7",
|
"drizzle-kit": "^0.31.8",
|
||||||
"postgres": "^3.4.7",
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"@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
|
||||||
|
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_PASSWORD=${DB_PASSWORD}
|
||||||
- POSTGRES_DB=${DB_NAME}
|
- POSTGRES_DB=${DB_NAME}
|
||||||
# Uncomment to access DB from host (for debugging/drizzle-kit studio)
|
# Uncomment to access DB from host (for debugging/drizzle-kit studio)
|
||||||
# ports:
|
ports:
|
||||||
# - "127.0.0.1:${DB_PORT}:5432"
|
- "127.0.0.1:${DB_PORT}:5432"
|
||||||
volumes:
|
volumes:
|
||||||
|
# Host-mounted to preserve existing VPS data
|
||||||
- ./shared/db/data:/var/lib/postgresql/data
|
- ./shared/db/data:/var/lib/postgresql/data
|
||||||
- ./shared/db/log:/var/log/postgresql
|
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
|
- web
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}" ]
|
test: [ "CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}" ]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -23,17 +24,18 @@ services:
|
|||||||
app:
|
app:
|
||||||
container_name: aurora_app
|
container_name: aurora_app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
image: aurora-app
|
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
target: development # Use development stage
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3000:3000"
|
- "127.0.0.1:3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
|
# Mount source code for hot reloading
|
||||||
- .:/app
|
- .:/app
|
||||||
- /app/node_modules
|
# Use named volumes for node_modules (prevents host overwrite + caches deps)
|
||||||
- /app/web/node_modules
|
- app_node_modules:/app/node_modules
|
||||||
environment:
|
environment:
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
- DB_USER=${DB_USER}
|
- DB_USER=${DB_USER}
|
||||||
@@ -61,30 +63,21 @@ services:
|
|||||||
|
|
||||||
studio:
|
studio:
|
||||||
container_name: aurora_studio
|
container_name: aurora_studio
|
||||||
image: aurora-app
|
# Reuse the same built image as app (no duplicate builds!)
|
||||||
build:
|
extends:
|
||||||
context: .
|
service: app
|
||||||
dockerfile: Dockerfile
|
# Clear inherited ports from app and only expose studio port
|
||||||
working_dir: /app
|
ports: !override
|
||||||
ports:
|
|
||||||
- "127.0.0.1:4983:4983"
|
- "127.0.0.1:4983:4983"
|
||||||
volumes:
|
# Override healthcheck since studio doesn't serve on port 3000
|
||||||
- .:/app
|
healthcheck:
|
||||||
- /app/node_modules
|
test: [ "CMD", "bun", "-e", "fetch('http://localhost:4983').then(r => process.exit(0)).catch(() => process.exit(1))" ]
|
||||||
- /app/web/node_modules
|
interval: 30s
|
||||||
environment:
|
timeout: 10s
|
||||||
- DB_USER=${DB_USER}
|
retries: 3
|
||||||
- DB_PASSWORD=${DB_PASSWORD}
|
start_period: 10s
|
||||||
- DB_NAME=${DB_NAME}
|
# Disable restart for studio (it's an on-demand tool)
|
||||||
- DB_PORT=5432
|
restart: "no"
|
||||||
- DB_HOST=db
|
|
||||||
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
|
||||||
- internal
|
|
||||||
- web
|
|
||||||
command: [ "bun", "x", "drizzle-kit", "studio", "--port", "4983", "--host", "0.0.0.0" ]
|
command: [ "bun", "x", "drizzle-kit", "studio", "--port", "4983", "--host", "0.0.0.0" ]
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
@@ -93,3 +86,8 @@ networks:
|
|||||||
internal: true # No external access
|
internal: true # No external access
|
||||||
web:
|
web:
|
||||||
driver: bridge # Can be accessed from host
|
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`.
|
||||||
21
package.json
21
package.json
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "app",
|
"name": "app",
|
||||||
|
"version": "1.1.4-pre",
|
||||||
"module": "bot/index.ts",
|
"module": "bot/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"drizzle-kit": "^0.31.7"
|
"drizzle-kit": "^0.31.8"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate": "docker compose run --rm app drizzle-kit generate",
|
"generate": "docker compose run --rm app drizzle-kit generate",
|
||||||
@@ -17,17 +18,21 @@
|
|||||||
"db:push:local": "drizzle-kit push",
|
"db:push:local": "drizzle-kit push",
|
||||||
"dev": "bun --watch bot/index.ts",
|
"dev": "bun --watch bot/index.ts",
|
||||||
"db:studio": "drizzle-kit studio --port 4983 --host 0.0.0.0",
|
"db:studio": "drizzle-kit studio --port 4983 --host 0.0.0.0",
|
||||||
"studio:remote": "bash shared/scripts/remote-studio.sh",
|
"db:migrate-config": "docker compose run --rm app bun shared/scripts/migrate-config-to-db.ts",
|
||||||
"dashboard:remote": "bash shared/scripts/remote-dashboard.sh",
|
"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",
|
"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": {
|
"dependencies": {
|
||||||
"@napi-rs/canvas": "^0.1.84",
|
"@napi-rs/canvas": "^0.1.89",
|
||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
"postgres": "^3.4.7",
|
"postgres": "^3.4.8",
|
||||||
"zod": "^4.1.13"
|
"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 { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import { SQL } from "bun";
|
import postgresJs from "postgres"; // Renamed import
|
||||||
import * as schema from "./schema";
|
import * as schema from "./schema";
|
||||||
import { env } from "@shared/lib/env";
|
import { env } from "@shared/lib/env";
|
||||||
|
|
||||||
const connectionString = env.DATABASE_URL;
|
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 () => {
|
export const closeDatabase = async () => {
|
||||||
await postgres.close();
|
await client.end();
|
||||||
};
|
};
|
||||||
@@ -1,42 +1,43 @@
|
|||||||
import { expect, test, describe } from "bun:test";
|
import { expect, test, describe } from "bun:test";
|
||||||
import { postgres } from "./DrizzleClient";
|
import { DrizzleClient } from "./DrizzleClient";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
describe("Database Indexes", () => {
|
describe("Database Indexes", () => {
|
||||||
test("should have indexes on users table", async () => {
|
test("should have indexes on users table", async () => {
|
||||||
const result = await postgres`
|
const result = await DrizzleClient.execute(sql`
|
||||||
SELECT indexname FROM pg_indexes
|
SELECT indexname FROM pg_indexes
|
||||||
WHERE tablename = 'users'
|
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_balance_idx");
|
||||||
expect(indexNames).toContain("users_level_xp_idx");
|
expect(indexNames).toContain("users_level_xp_idx");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should have index on transactions table", async () => {
|
test("should have index on transactions table", async () => {
|
||||||
const result = await postgres`
|
const result = await DrizzleClient.execute(sql`
|
||||||
SELECT indexname FROM pg_indexes
|
SELECT indexname FROM pg_indexes
|
||||||
WHERE tablename = 'transactions'
|
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");
|
expect(indexNames).toContain("transactions_created_at_idx");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should have indexes on moderation_cases table", async () => {
|
test("should have indexes on moderation_cases table", async () => {
|
||||||
const result = await postgres`
|
const result = await DrizzleClient.execute(sql`
|
||||||
SELECT indexname FROM pg_indexes
|
SELECT indexname FROM pg_indexes
|
||||||
WHERE tablename = 'moderation_cases'
|
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_user_id_idx");
|
||||||
expect(indexNames).toContain("moderation_cases_case_id_idx");
|
expect(indexNames).toContain("moderation_cases_case_id_idx");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should have indexes on user_timers table", async () => {
|
test("should have indexes on user_timers table", async () => {
|
||||||
const result = await postgres`
|
const result = await DrizzleClient.execute(sql`
|
||||||
SELECT indexname FROM pg_indexes
|
SELECT indexname FROM pg_indexes
|
||||||
WHERE tablename = 'user_timers'
|
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_expires_at_idx");
|
||||||
expect(indexNames).toContain("user_timers_lookup_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,
|
"when": 1767716705797,
|
||||||
"tag": "0002_fancy_forge",
|
"tag": "0002_fancy_forge",
|
||||||
"breakpoints": true
|
"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,270 +1,3 @@
|
|||||||
import {
|
// Re-export all schema definitions from domain modules
|
||||||
pgTable,
|
// This file is kept for backward compatibility
|
||||||
bigint,
|
export * from './schema/index';
|
||||||
varchar,
|
|
||||||
boolean,
|
|
||||||
jsonb,
|
|
||||||
timestamp,
|
|
||||||
serial,
|
|
||||||
text,
|
|
||||||
integer,
|
|
||||||
primaryKey,
|
|
||||||
index,
|
|
||||||
bigserial,
|
|
||||||
check
|
|
||||||
} from 'drizzle-orm/pg-core';
|
|
||||||
import { relations, sql, type InferSelectModel } from 'drizzle-orm';
|
|
||||||
|
|
||||||
export type User = InferSelectModel<typeof users>;
|
|
||||||
export type Transaction = InferSelectModel<typeof transactions>;
|
|
||||||
export type ModerationCase = InferSelectModel<typeof moderationCases>;
|
|
||||||
export type Item = InferSelectModel<typeof items>;
|
|
||||||
export type Inventory = InferSelectModel<typeof inventory>;
|
|
||||||
|
|
||||||
// --- 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],
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|||||||
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],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user