Compare commits
170 Commits
feat/web-s
...
bf20c61190
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf20c61190 | ||
|
|
099601ce6d | ||
|
|
55d2376ca1 | ||
|
|
6eb4a32a12 | ||
|
|
2d35a5eabb | ||
|
|
570cdc69c1 | ||
|
|
c2b1fb6db1 | ||
|
|
d15d53e839 | ||
|
|
58374d1746 | ||
|
|
ae6a068197 | ||
|
|
43d32918ab | ||
|
|
0bc254b728 | ||
|
|
610d97bde3 | ||
|
|
babccfd08a | ||
|
|
ee7d63df3e | ||
|
|
5f107d03a7 | ||
|
|
1ff24b0f7f | ||
|
|
a5e3534260 | ||
|
|
228005322e | ||
|
|
67a3aa4b0f | ||
|
|
64804f7066 | ||
|
|
73ad889018 | ||
|
|
9c7f1e4418 | ||
|
|
efb50916b2 | ||
|
|
6abb52694e | ||
|
|
76968e31a6 | ||
|
|
29bf0e6f1c | ||
|
|
8c306fbd23 | ||
|
|
b0c3baf5b7 | ||
|
|
f575588b9a | ||
|
|
553b9b4952 | ||
|
|
073348fa55 | ||
|
|
4232674494 | ||
|
|
fbf1e52c28 | ||
|
|
20284dc57b | ||
|
|
36f9c76fa9 | ||
|
|
46e95ce7b3 | ||
|
|
9acd3f3d76 | ||
|
|
5e8683a19f | ||
|
|
ee088ad84b | ||
|
|
b18b5fab62 | ||
|
|
0b56486ab2 | ||
|
|
11c589b01c | ||
|
|
e4169d9dd5 | ||
|
|
1929f0dd1f | ||
|
|
db4e7313c3 | ||
|
|
1ffe397fbb | ||
|
|
34958aa220 | ||
|
|
109b36ffe2 | ||
|
|
cd954afe36 | ||
|
|
2b60883173 | ||
|
|
c2d67d7435 | ||
|
|
e252d6e00a | ||
|
|
95f1b4e04a | ||
|
|
62c6ca5e87 | ||
|
|
aac9be19f2 | ||
|
|
bb823c86c1 | ||
|
|
119301f1c3 | ||
|
|
9a2fc101da | ||
|
|
7049cbfd9d | ||
|
|
db859e8f12 | ||
|
|
5ff3fa9ab5 | ||
|
|
c8bf69a969 | ||
|
|
fee4969910 | ||
|
|
dabcb4cab3 | ||
|
|
1a3f5c6654 | ||
|
|
422db6479b | ||
|
|
35ecea16f7 | ||
|
|
9ff679ee5c | ||
|
|
ebefd8c0df | ||
|
|
73531f38ae | ||
|
|
5a6356d271 | ||
|
|
f9dafeac3b | ||
|
|
1a2bbb011c | ||
|
|
2ead35789d | ||
|
|
c1da71227d | ||
|
|
17e636c4e5 | ||
|
|
d7543d9f48 | ||
|
|
afe82c449b | ||
|
|
3c1334b30e | ||
|
|
58f261562a | ||
|
|
4ecbffd617 | ||
|
|
5491551544 | ||
|
|
7d658bbef9 | ||
|
|
d117bcb697 | ||
|
|
94e332ba57 | ||
|
|
3ef9773990 | ||
|
|
d243a11bd3 | ||
|
|
47ce0f12e6 | ||
|
|
f2caa1a3ee | ||
|
|
2a72beb0ef | ||
|
|
2f73f38877 | ||
|
|
9e5c6b5ac3 | ||
|
|
eb108695d3 | ||
|
|
7d541825d8 | ||
|
|
52f8ab11f0 | ||
|
|
f8436e9755 | ||
|
|
194a032c7f | ||
|
|
94a5a183d0 | ||
|
|
c7730b9355 | ||
|
|
1e20a5a7a0 | ||
|
|
54944283a3 | ||
|
|
f79ee6fbc7 | ||
|
|
915f1bc4ad | ||
|
|
4af2690bab | ||
|
|
6e57ab07e4 | ||
|
|
3a620a84c5 | ||
|
|
7d68652ea5 | ||
|
|
35bd1f58dd | ||
|
|
1cd3dbcd72 | ||
|
|
c97249f2ca | ||
|
|
0d923491b5 | ||
|
|
d870ef69d5 | ||
|
|
682e9d208e | ||
|
|
4a691ac71d | ||
|
|
1b84dbd36d | ||
|
|
a5b8d922e3 | ||
|
|
238d9a8803 | ||
|
|
713ea07040 | ||
|
|
bea6c33024 | ||
|
|
8fe300c8a2 | ||
|
|
9caa95a0d8 | ||
|
|
c6fd23b5fa | ||
|
|
d46434de18 | ||
|
|
cf4c28e1df | ||
|
|
39e405afde | ||
|
|
6763e3c543 | ||
|
|
11e07a0068 | ||
|
|
5d2d4bb0c6 | ||
|
|
19206b5cc7 | ||
|
|
0f6cce9b6e | ||
|
|
3f3a6c88e8 | ||
|
|
8253de9f73 | ||
|
|
1251df286e | ||
|
|
fff90804c0 | ||
|
|
8ebaf7b4ee | ||
|
|
17cb70ec00 | ||
|
|
a207d511be | ||
|
|
cf4f180124 | ||
|
|
5df1396b3f | ||
|
|
daad7be01c | ||
|
|
05f27ca604 | ||
|
|
d37059d50f | ||
|
|
caafe6b34d | ||
|
|
017f5ad818 | ||
|
|
f92415b89c | ||
|
|
3f028eb76a | ||
|
|
2b641c952d | ||
|
|
88b266f81b | ||
|
|
53a2f1ff0c | ||
|
|
dc15212ecf | ||
|
|
99e847175e | ||
|
|
b2c7fa6e83 | ||
|
|
9e7f18787b | ||
| 47507dd65a | |||
|
|
e6f94c3e71 | ||
|
|
66af870aa9 | ||
|
|
8047bce755 | ||
|
|
9804456257 | ||
|
|
259b8d6875 | ||
|
|
a2cb684b71 | ||
|
|
9c2098bc46 | ||
|
|
618d973863 | ||
|
|
63f55b6dfd | ||
|
|
ac4025e179 | ||
|
|
ff23f22337 | ||
|
|
292991c605 | ||
|
|
4640cd11a7 | ||
|
|
43a003f641 | ||
|
|
6f4426e49d |
@@ -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
@@ -0,0 +1,39 @@
|
|||||||
|
# Dependencies - handled inside container
|
||||||
|
node_modules
|
||||||
|
web/node_modules
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Logs and data
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
shared/db/data
|
||||||
|
shared/db/log
|
||||||
|
|
||||||
|
# Development tools
|
||||||
|
.env
|
||||||
|
.env.example
|
||||||
|
.opencode
|
||||||
|
.agent
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
docs
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
dist
|
||||||
|
.cache
|
||||||
|
*.tsbuildinfo
|
||||||
18
.env.example
@@ -1,12 +1,26 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Aurora Environment Configuration
|
||||||
|
# =============================================================================
|
||||||
|
# Copy this file to .env and update with your values
|
||||||
|
# For production, see .env.prod.example with security recommendations
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# For production: use a strong password (openssl rand -base64 32)
|
||||||
DB_USER=aurora
|
DB_USER=aurora
|
||||||
DB_PASSWORD=aurora
|
DB_PASSWORD=aurora
|
||||||
DB_NAME=aurora
|
DB_NAME=aurora
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
DB_HOST=db
|
DB_HOST=db
|
||||||
|
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
|
||||||
|
|
||||||
|
# Discord
|
||||||
|
# Get from: https://discord.com/developers/applications
|
||||||
DISCORD_BOT_TOKEN=your-discord-bot-token
|
DISCORD_BOT_TOKEN=your-discord-bot-token
|
||||||
DISCORD_CLIENT_ID=your-discord-client-id
|
DISCORD_CLIENT_ID=your-discord-client-id
|
||||||
DISCORD_GUILD_ID=your-discord-guild-id
|
DISCORD_GUILD_ID=your-discord-guild-id
|
||||||
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
|
|
||||||
|
|
||||||
VPS_USER=your-vps-user
|
# 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
@@ -0,0 +1,38 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Aurora Production Environment Template
|
||||||
|
# =============================================================================
|
||||||
|
# Copy this file to .env and fill in the values
|
||||||
|
# IMPORTANT: Use strong, unique passwords in production!
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Database Configuration
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Generate strong password: openssl rand -base64 32
|
||||||
|
DB_USER=aurora_prod
|
||||||
|
DB_PASSWORD=CHANGE_ME_USE_STRONG_PASSWORD
|
||||||
|
DB_NAME=aurora_prod
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_HOST=localhost
|
||||||
|
|
||||||
|
# Constructed database URL (used by Drizzle)
|
||||||
|
DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_NAME}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Discord Configuration
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Get these from Discord Developer Portal: https://discord.com/developers
|
||||||
|
DISCORD_BOT_TOKEN=your_bot_token_here
|
||||||
|
DISCORD_CLIENT_ID=your_client_id_here
|
||||||
|
DISCORD_GUILD_ID=your_guild_id_here
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Server Configuration (for SSH deployment scripts)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Use a non-root user for security!
|
||||||
|
VPS_USER=deploy
|
||||||
|
VPS_HOST=your_server_ip_here
|
||||||
|
|
||||||
|
# Optional: Custom ports for remote access
|
||||||
|
# DASHBOARD_PORT=3000
|
||||||
|
# STUDIO_PORT=4983
|
||||||
6
.env.test
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
DATABASE_URL="postgresql://auroradev:auroradev123@localhost:5432/aurora_test"
|
||||||
|
DISCORD_BOT_TOKEN="test_token"
|
||||||
|
DISCORD_CLIENT_ID="123456789"
|
||||||
|
DISCORD_GUILD_ID="123456789"
|
||||||
|
ADMIN_TOKEN="admin_token_123"
|
||||||
|
LOG_LEVEL="error"
|
||||||
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
|
||||||
9
.gitignore
vendored
@@ -1,7 +1,9 @@
|
|||||||
.env
|
.env
|
||||||
node_modules
|
node_modules
|
||||||
db-logs
|
docker-compose.override.yml
|
||||||
db-data
|
shared/db-logs
|
||||||
|
shared/db/data
|
||||||
|
shared/db/loga
|
||||||
.cursor
|
.cursor
|
||||||
# dependencies (bun install)
|
# dependencies (bun install)
|
||||||
|
|
||||||
@@ -44,4 +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/
|
||||||
|
|||||||
242
AGENTS.md
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
# AGENTS.md - AI Coding Agent Guidelines
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
AuroraBot is a Discord bot with a REST API built using Bun, Discord.js, and PostgreSQL with Drizzle ORM.
|
||||||
|
|
||||||
|
## Build/Lint/Test Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
bun --watch bot/index.ts # Run bot + API server with hot reload
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
bun test # Run all tests
|
||||||
|
bun test path/to/file.test.ts # Run single test file
|
||||||
|
bun test --watch # Watch mode
|
||||||
|
bun test shared/modules/economy # Run tests in directory
|
||||||
|
|
||||||
|
# Database
|
||||||
|
bun run generate # Generate Drizzle migrations (Docker)
|
||||||
|
bun run migrate # Run migrations (Docker)
|
||||||
|
bun run db:push # Push schema changes (Docker)
|
||||||
|
bun run db:push:local # Push schema changes (local)
|
||||||
|
bun run db:studio # Open Drizzle Studio
|
||||||
|
|
||||||
|
# Docker (recommended for local dev)
|
||||||
|
docker compose up # Start bot, API, and database
|
||||||
|
docker compose up app # Start just the app (bot + API)
|
||||||
|
docker compose up db # Start just the database
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
bot/ # Discord bot
|
||||||
|
├── commands/ # Slash commands by category
|
||||||
|
├── events/ # Discord event handlers
|
||||||
|
├── lib/ # Bot core (BotClient, handlers, loaders)
|
||||||
|
├── modules/ # Feature modules (views, interactions)
|
||||||
|
└── graphics/ # Canvas image generation
|
||||||
|
|
||||||
|
shared/ # Shared between bot and web
|
||||||
|
├── db/ # Database schema and migrations
|
||||||
|
├── lib/ # Utils, config, errors, types
|
||||||
|
└── modules/ # Domain services (economy, user, etc.)
|
||||||
|
|
||||||
|
web/ # API server
|
||||||
|
└── src/routes/ # API route handlers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Import Conventions
|
||||||
|
|
||||||
|
Use path aliases defined in tsconfig.json:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// External packages first
|
||||||
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
// Path aliases second
|
||||||
|
import { economyService } from "@shared/modules/economy/economy.service";
|
||||||
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
import { users } from "@db/schema";
|
||||||
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
|
import { handleTradeInteraction } from "@modules/trade/trade.interaction";
|
||||||
|
|
||||||
|
// Relative imports last
|
||||||
|
import { localHelper } from "./helper";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available Aliases:**
|
||||||
|
|
||||||
|
- `@/*` - bot/
|
||||||
|
- `@shared/*` - shared/
|
||||||
|
- `@db/*` - shared/db/
|
||||||
|
- `@lib/*` - bot/lib/
|
||||||
|
- `@modules/*` - bot/modules/
|
||||||
|
- `@commands/*` - bot/commands/
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
| Element | Convention | Example |
|
||||||
|
| ---------------- | ----------------------- | ---------------------------------------- |
|
||||||
|
| Files | camelCase or kebab-case | `BotClient.ts`, `economy.service.ts` |
|
||||||
|
| Classes | PascalCase | `CommandHandler`, `UserError` |
|
||||||
|
| Functions | camelCase | `createCommand`, `handleShopInteraction` |
|
||||||
|
| Constants | UPPER_SNAKE_CASE | `EVENTS`, `BRANDING` |
|
||||||
|
| Enums | PascalCase | `TimerType`, `TransactionType` |
|
||||||
|
| Services | camelCase singleton | `economyService`, `userService` |
|
||||||
|
| Types/Interfaces | PascalCase | `Command`, `Event`, `GameConfigType` |
|
||||||
|
| DB tables | snake_case | `users`, `moderation_cases` |
|
||||||
|
| Custom IDs | snake_case with prefix | `shop_buy_`, `trade_accept_` |
|
||||||
|
|
||||||
|
## Code Patterns
|
||||||
|
|
||||||
|
### Command Definition
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const commandName = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("commandname")
|
||||||
|
.setDescription("Description"),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
await interaction.deferReply();
|
||||||
|
// Implementation
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Pattern (Singleton Object)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const serviceName = {
|
||||||
|
methodName: async (params: ParamType): Promise<ReturnType> => {
|
||||||
|
return await withTransaction(async (tx) => {
|
||||||
|
// Database operations
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Module File Organization
|
||||||
|
|
||||||
|
- `*.view.ts` - Creates Discord embeds/components
|
||||||
|
- `*.interaction.ts` - Handles button/select/modal interactions
|
||||||
|
- `*.types.ts` - Module-specific TypeScript types
|
||||||
|
- `*.service.ts` - Business logic (in shared/modules/)
|
||||||
|
- `*.test.ts` - Test files (co-located with source)
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Custom Error Classes
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { UserError, SystemError } from "@shared/lib/errors";
|
||||||
|
|
||||||
|
// User-facing errors (shown to user)
|
||||||
|
throw new UserError("You don't have enough coins!");
|
||||||
|
|
||||||
|
// System errors (logged, generic message shown)
|
||||||
|
throw new SystemError("Database connection failed");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Standard Error Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
const result = await service.method();
|
||||||
|
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UserError) {
|
||||||
|
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||||
|
} else {
|
||||||
|
console.error("Unexpected error:", error);
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("An unexpected error occurred.")],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Patterns
|
||||||
|
|
||||||
|
### Transaction Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { withTransaction } from "@/lib/db";
|
||||||
|
|
||||||
|
return await withTransaction(async (tx) => {
|
||||||
|
const user = await tx.query.users.findFirst({
|
||||||
|
where: eq(users.id, discordId),
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(users)
|
||||||
|
.set({ coins: newBalance })
|
||||||
|
.where(eq(users.id, discordId));
|
||||||
|
await tx.insert(transactions).values({ userId: discordId, amount, type });
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}, existingTx); // Pass existing tx if in nested transaction
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schema Notes
|
||||||
|
|
||||||
|
- Use `bigint` mode for Discord IDs and currency amounts
|
||||||
|
- Relations defined separately from table definitions
|
||||||
|
- Schema modules: `shared/db/schema/*.ts` (users, inventory, economy, quests, moderation)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test File Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||||
|
|
||||||
|
// Mock modules BEFORE imports
|
||||||
|
mock.module("@shared/db/DrizzleClient", () => ({
|
||||||
|
DrizzleClient: { query: mockQuery },
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("serviceName", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFn.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle expected case", async () => {
|
||||||
|
// Arrange
|
||||||
|
mockFn.mockResolvedValue(testData);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await service.method(input);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
expect(mockFn).toHaveBeenCalledWith(expectedArgs);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Runtime:** Bun 1.0+
|
||||||
|
- **Bot:** Discord.js 14.x
|
||||||
|
- **Web:** Bun HTTP Server (REST API)
|
||||||
|
- **Database:** PostgreSQL 16+ with Drizzle ORM
|
||||||
|
- **UI:** Discord embeds and components
|
||||||
|
- **Validation:** Zod
|
||||||
|
- **Testing:** Bun Test
|
||||||
|
- **Container:** Docker
|
||||||
|
|
||||||
|
## Key Files Reference
|
||||||
|
|
||||||
|
| Purpose | File |
|
||||||
|
| ------------- | ---------------------- |
|
||||||
|
| Bot entry | `bot/index.ts` |
|
||||||
|
| DB schema | `shared/db/schema.ts` |
|
||||||
|
| Error classes | `shared/lib/errors.ts` |
|
||||||
|
| Config loader | `shared/lib/config.ts` |
|
||||||
|
| Environment | `shared/lib/env.ts` |
|
||||||
|
| Embed helpers | `bot/lib/embeds.ts` |
|
||||||
|
| Command utils | `shared/lib/utils.ts` |
|
||||||
29
Dockerfile
@@ -1,17 +1,34 @@
|
|||||||
|
# ============================================
|
||||||
|
# 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
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends git && \
|
||||||
|
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Dependencies stage - installs all deps
|
||||||
|
# ============================================
|
||||||
|
FROM base AS deps
|
||||||
|
|
||||||
|
# Copy only package files first (better layer caching)
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
COPY package.json bun.lock ./
|
|
||||||
RUN bun install --frozen-lockfile
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
# Copy source code
|
# ============================================
|
||||||
COPY . .
|
# Development stage - for local dev with volume mounts
|
||||||
|
# ============================================
|
||||||
|
FROM base AS development
|
||||||
|
|
||||||
# Expose port
|
# Copy dependencies from deps stage
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
# Expose ports
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Default command
|
# Default command
|
||||||
|
|||||||
48
Dockerfile.prod
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Stage 1: Dependencies & Build
|
||||||
|
# =============================================================================
|
||||||
|
FROM oven/bun:latest AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies needed for build
|
||||||
|
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install root project dependencies
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Stage 2: Production Runtime
|
||||||
|
# =============================================================================
|
||||||
|
FROM oven/bun:latest AS production
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create non-root user for security (bun user already exists with 1000:1000)
|
||||||
|
# No need to create user/group
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Copy only what's needed for production
|
||||||
|
COPY --from=builder --chown=bun:bun /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder --chown=bun:bun /app/web/src ./web/src
|
||||||
|
COPY --from=builder --chown=bun:bun /app/bot ./bot
|
||||||
|
COPY --from=builder --chown=bun:bun /app/shared ./shared
|
||||||
|
COPY --from=builder --chown=bun:bun /app/package.json .
|
||||||
|
COPY --from=builder --chown=bun:bun /app/drizzle.config.ts .
|
||||||
|
COPY --from=builder --chown=bun:bun /app/tsconfig.json .
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER bun
|
||||||
|
|
||||||
|
# Expose web dashboard port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
|
CMD bun -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
|
||||||
|
|
||||||
|
# Run in production mode
|
||||||
|
CMD ["bun", "run", "bot/index.ts"]
|
||||||
67
README.md
@@ -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
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@/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 "@/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";
|
||||||
|
|
||||||
export const moderationCase = createCommand({
|
export const moderationCase = createCommand({
|
||||||
@@ -30,7 +30,7 @@ export const moderationCase = createCommand({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get the case
|
// Get the case
|
||||||
const moderationCase = await ModerationService.getCaseById(caseId);
|
const moderationCase = await moderationService.getCaseById(caseId);
|
||||||
|
|
||||||
if (!moderationCase) {
|
if (!moderationCase) {
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@/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 "@/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
export const cases = createCommand({
|
export const cases = createCommand({
|
||||||
@@ -29,7 +29,7 @@ export const cases = createCommand({
|
|||||||
const activeOnly = interaction.options.getBoolean("active_only") || false;
|
const activeOnly = interaction.options.getBoolean("active_only") || false;
|
||||||
|
|
||||||
// Get cases for the user
|
// Get cases for the user
|
||||||
const userCases = await ModerationService.getUserCases(targetUser.id, activeOnly);
|
const userCases = await moderationService.getUserCases(targetUser.id, activeOnly);
|
||||||
|
|
||||||
const title = activeOnly
|
const title = activeOnly
|
||||||
? `⚠️ Active Cases for ${targetUser.username}`
|
? `⚠️ Active Cases for ${targetUser.username}`
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@/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 "@/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";
|
||||||
|
|
||||||
export const clearwarning = createCommand({
|
export const clearwarning = createCommand({
|
||||||
@@ -38,7 +38,7 @@ export const clearwarning = createCommand({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if case exists and is active
|
// Check if case exists and is active
|
||||||
const existingCase = await ModerationService.getCaseById(caseId);
|
const existingCase = await moderationService.getCaseById(caseId);
|
||||||
|
|
||||||
if (!existingCase) {
|
if (!existingCase) {
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
@@ -62,7 +62,7 @@ export const clearwarning = createCommand({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear the warning
|
// Clear the warning
|
||||||
await ModerationService.clearCase({
|
await moderationService.clearCase({
|
||||||
caseId,
|
caseId,
|
||||||
clearedBy: interaction.user.id,
|
clearedBy: interaction.user.id,
|
||||||
clearedByName: interaction.user.username,
|
clearedByName: interaction.user.username,
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js";
|
||||||
import { config, saveConfig } from "@/lib/config";
|
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
|
||||||
import { items } from "@/db/schema";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
|
import { items } from "@db/schema";
|
||||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
export const createColor = createCommand({
|
export const createColor = createCommand({
|
||||||
@@ -49,7 +50,7 @@ export const createColor = createCommand({
|
|||||||
// 2. Create Role
|
// 2. Create Role
|
||||||
const role = await interaction.guild?.roles.create({
|
const role = await interaction.guild?.roles.create({
|
||||||
name: name,
|
name: name,
|
||||||
color: colorInput as any, // Discord.js types are a bit strict on ColorResolvable, but string generally works or needs parsing
|
color: colorInput as any,
|
||||||
reason: `Created via /createcolor by ${interaction.user.tag}`
|
reason: `Created via /createcolor by ${interaction.user.tag}`
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,11 +58,9 @@ export const createColor = createCommand({
|
|||||||
throw new Error("Failed to create role.");
|
throw new Error("Failed to create role.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Update Config
|
// 3. Add to guild settings
|
||||||
if (!config.colorRoles.includes(role.id)) {
|
await guildSettingsService.addColorRole(interaction.guildId!, role.id);
|
||||||
config.colorRoles.push(role.id);
|
invalidateGuildConfigCache(interaction.guildId!);
|
||||||
saveConfig(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Create Item
|
// 4. Create Item
|
||||||
await DrizzleClient.insert(items).values({
|
await DrizzleClient.insert(items).values({
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { renderWizard } from "@/modules/admin/item_wizard";
|
import { renderWizard } from "@/modules/admin/item_wizard";
|
||||||
|
|
||||||
297
bot/commands/admin/featureflags.ts
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import { createCommand } from "@shared/lib/utils";
|
||||||
|
import { SlashCommandBuilder, PermissionFlagsBits, Colors, userMention, roleMention, ChatInputCommandInteraction } from "discord.js";
|
||||||
|
import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service";
|
||||||
|
import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
|
||||||
|
export const featureflags = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("featureflags")
|
||||||
|
.setDescription("Manage feature flags for beta testing")
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("list")
|
||||||
|
.setDescription("List all feature flags")
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("create")
|
||||||
|
.setDescription("Create a new feature flag")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("name")
|
||||||
|
.setDescription("Name of the feature flag")
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("description")
|
||||||
|
.setDescription("Description of the feature flag")
|
||||||
|
.setRequired(false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("delete")
|
||||||
|
.setDescription("Delete a feature flag")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("name")
|
||||||
|
.setDescription("Name of the feature flag")
|
||||||
|
.setRequired(true)
|
||||||
|
.setAutocomplete(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("enable")
|
||||||
|
.setDescription("Enable a feature flag")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("name")
|
||||||
|
.setDescription("Name of the feature flag")
|
||||||
|
.setRequired(true)
|
||||||
|
.setAutocomplete(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("disable")
|
||||||
|
.setDescription("Disable a feature flag")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("name")
|
||||||
|
.setDescription("Name of the feature flag")
|
||||||
|
.setRequired(true)
|
||||||
|
.setAutocomplete(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("grant")
|
||||||
|
.setDescription("Grant access to a feature flag")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("name")
|
||||||
|
.setDescription("Name of the feature flag")
|
||||||
|
.setRequired(true)
|
||||||
|
.setAutocomplete(true)
|
||||||
|
)
|
||||||
|
.addUserOption(opt =>
|
||||||
|
opt.setName("user")
|
||||||
|
.setDescription("User to grant access to")
|
||||||
|
.setRequired(false)
|
||||||
|
)
|
||||||
|
.addRoleOption(opt =>
|
||||||
|
opt.setName("role")
|
||||||
|
.setDescription("Role to grant access to")
|
||||||
|
.setRequired(false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("revoke")
|
||||||
|
.setDescription("Revoke access from a feature flag")
|
||||||
|
.addIntegerOption(opt =>
|
||||||
|
opt.setName("id")
|
||||||
|
.setDescription("Access record ID to revoke")
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("access")
|
||||||
|
.setDescription("List access records for a feature flag")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("name")
|
||||||
|
.setDescription("Name of the feature flag")
|
||||||
|
.setRequired(true)
|
||||||
|
.setAutocomplete(true)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
autocomplete: async (interaction) => {
|
||||||
|
const focused = interaction.options.getFocused(true);
|
||||||
|
|
||||||
|
if (focused.name === "name") {
|
||||||
|
const flags = await featureFlagsService.listFlags();
|
||||||
|
const filtered = flags
|
||||||
|
.filter(f => f.name.toLowerCase().includes(focused.value.toLowerCase()))
|
||||||
|
.slice(0, 25);
|
||||||
|
|
||||||
|
await interaction.respond(
|
||||||
|
filtered.map(f => ({ name: `${f.name} (${f.enabled ? "enabled" : "disabled"})`, value: f.name }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
execute: async (interaction) => {
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (subcommand) {
|
||||||
|
case "list":
|
||||||
|
await handleList(interaction);
|
||||||
|
break;
|
||||||
|
case "create":
|
||||||
|
await handleCreate(interaction);
|
||||||
|
break;
|
||||||
|
case "delete":
|
||||||
|
await handleDelete(interaction);
|
||||||
|
break;
|
||||||
|
case "enable":
|
||||||
|
await handleEnable(interaction);
|
||||||
|
break;
|
||||||
|
case "disable":
|
||||||
|
await handleDisable(interaction);
|
||||||
|
break;
|
||||||
|
case "grant":
|
||||||
|
await handleGrant(interaction);
|
||||||
|
break;
|
||||||
|
case "revoke":
|
||||||
|
await handleRevoke(interaction);
|
||||||
|
break;
|
||||||
|
case "access":
|
||||||
|
await handleAccess(interaction);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UserError) {
|
||||||
|
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleList(interaction: ChatInputCommandInteraction) {
|
||||||
|
const flags = await featureFlagsService.listFlags();
|
||||||
|
|
||||||
|
if (flags.length === 0) {
|
||||||
|
await interaction.editReply({ embeds: [createBaseEmbed("Feature Flags", "No feature flags have been created yet.", Colors.Blue)] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = createBaseEmbed("Feature Flags", undefined, Colors.Blue)
|
||||||
|
.addFields(
|
||||||
|
flags.map(f => ({
|
||||||
|
name: f.name,
|
||||||
|
value: `${f.enabled ? "✅ Enabled" : "❌ Disabled"}\n${f.description || "*No description*"}`,
|
||||||
|
inline: false,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate(interaction: ChatInputCommandInteraction) {
|
||||||
|
const name = interaction.options.getString("name", true);
|
||||||
|
const description = interaction.options.getString("description");
|
||||||
|
|
||||||
|
const flag = await featureFlagsService.createFlag(name, description ?? undefined);
|
||||||
|
|
||||||
|
if (!flag) {
|
||||||
|
await interaction.editReply({ embeds: [createErrorEmbed("Failed to create feature flag.")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" created successfully. Use \`/featureflags enable\` to enable it.`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(interaction: ChatInputCommandInteraction) {
|
||||||
|
const name = interaction.options.getString("name", true);
|
||||||
|
|
||||||
|
const flag = await featureFlagsService.deleteFlag(name);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been deleted.`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEnable(interaction: ChatInputCommandInteraction) {
|
||||||
|
const name = interaction.options.getString("name", true);
|
||||||
|
|
||||||
|
const flag = await featureFlagsService.setFlagEnabled(name, true);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been enabled.`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDisable(interaction: ChatInputCommandInteraction) {
|
||||||
|
const name = interaction.options.getString("name", true);
|
||||||
|
|
||||||
|
const flag = await featureFlagsService.setFlagEnabled(name, false);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been disabled.`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGrant(interaction: ChatInputCommandInteraction) {
|
||||||
|
const name = interaction.options.getString("name", true);
|
||||||
|
const user = interaction.options.getUser("user");
|
||||||
|
const role = interaction.options.getRole("role");
|
||||||
|
|
||||||
|
if (!user && !role) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("You must specify either a user or a role to grant access to.")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await featureFlagsService.grantAccess(name, {
|
||||||
|
userId: user?.id,
|
||||||
|
roleId: role?.id,
|
||||||
|
guildId: interaction.guildId!,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!access) {
|
||||||
|
await interaction.editReply({ embeds: [createErrorEmbed("Failed to grant access.")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target: string;
|
||||||
|
if (user) {
|
||||||
|
target = userMention(user.id);
|
||||||
|
} else if (role) {
|
||||||
|
target = roleMention(role.id);
|
||||||
|
} else {
|
||||||
|
target = "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Access to "**${name}**" granted to ${target} (ID: ${access.id})`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevoke(interaction: ChatInputCommandInteraction) {
|
||||||
|
const id = interaction.options.getInteger("id", true);
|
||||||
|
|
||||||
|
const access = await featureFlagsService.revokeAccess(id);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Access record #${access.id} has been revoked.`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAccess(interaction: ChatInputCommandInteraction) {
|
||||||
|
const name = interaction.options.getString("name", true);
|
||||||
|
|
||||||
|
const accessRecords = await featureFlagsService.listAccess(name);
|
||||||
|
|
||||||
|
if (accessRecords.length === 0) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createBaseEmbed("Feature Flag Access", `No access records for "**${name}**".`, Colors.Blue)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = accessRecords.map(a => {
|
||||||
|
let target = "Unknown";
|
||||||
|
if (a.userId) target = `User: ${userMention(a.userId.toString())}`;
|
||||||
|
else if (a.roleId) target = `Role: ${roleMention(a.roleId.toString())}`;
|
||||||
|
else if (a.guildId) target = `Guild: ${a.guildId.toString()}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: `ID: ${a.id}`,
|
||||||
|
value: target,
|
||||||
|
inline: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const embed = createBaseEmbed(`Feature Flag Access: ${name}`, undefined, Colors.Blue)
|
||||||
|
.addFields(fields);
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { AuroraClient } from "@/lib/BotClient";
|
|||||||
|
|
||||||
// Mock DrizzleClient
|
// Mock DrizzleClient
|
||||||
const executeMock = mock(() => Promise.resolve());
|
const executeMock = mock(() => Promise.resolve());
|
||||||
mock.module("@/lib/DrizzleClient", () => ({
|
mock.module("@shared/db/DrizzleClient", () => ({
|
||||||
DrizzleClient: {
|
DrizzleClient: {
|
||||||
execute: executeMock
|
execute: executeMock
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createCommand } from "@lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, Colors } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, Colors } from "discord.js";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { createBaseEmbed } from "@lib/embeds";
|
import { createBaseEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
@@ -1,20 +1,18 @@
|
|||||||
import { createCommand } from "@/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 "@/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 { UserError } from "@shared/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 "@/lib/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";
|
||||||
|
|
||||||
export const listing = createCommand({
|
export const listing = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -54,21 +52,49 @@ export const listing = createCommand({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prepare context for lootboxes
|
||||||
|
const context: { referencedItems: Map<number, { name: string; rarity: string }> } = { referencedItems: new Map() };
|
||||||
|
|
||||||
|
const usageData = item.usageData as any;
|
||||||
|
const lootboxEffect = usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX);
|
||||||
|
|
||||||
|
if (lootboxEffect && lootboxEffect.pool) {
|
||||||
|
const itemIds = lootboxEffect.pool
|
||||||
|
.filter((drop: any) => drop.type === LootType.ITEM && drop.itemId)
|
||||||
|
.map((drop: any) => drop.itemId);
|
||||||
|
|
||||||
|
if (itemIds.length > 0) {
|
||||||
|
// Remove duplicates
|
||||||
|
const uniqueIds = [...new Set(itemIds)] as number[];
|
||||||
|
|
||||||
|
const referencedItems = await DrizzleClient.select({
|
||||||
|
id: items.id,
|
||||||
|
name: items.name,
|
||||||
|
rarity: items.rarity
|
||||||
|
}).from(items).where(inArray(items.id, uniqueIds));
|
||||||
|
|
||||||
|
for (const ref of referencedItems) {
|
||||||
|
context.referencedItems.set(ref.id, { name: ref.name, rarity: ref.rarity || 'C' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const listingMessage = getShopListingMessage({
|
const listingMessage = getShopListingMessage({
|
||||||
...item,
|
...item,
|
||||||
|
rarity: item.rarity || undefined,
|
||||||
formattedPrice: `${item.price} 🪙`,
|
formattedPrice: `${item.price} 🪙`,
|
||||||
price: item.price
|
price: item.price
|
||||||
});
|
}, context);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await targetChannel.send(listingMessage);
|
await targetChannel.send(listingMessage as any);
|
||||||
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
|
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error instanceof UserError) {
|
if (error instanceof UserError) {
|
||||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||||
} else {
|
} else {
|
||||||
console.error("Error creating listing:", error);
|
console.error("Error creating listing:", error);
|
||||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createCommand } from "@/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 "@/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { CaseType } from "@/lib/constants";
|
import { CaseType } from "@shared/lib/constants";
|
||||||
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
export const note = createCommand({
|
export const note = createCommand({
|
||||||
@@ -31,7 +31,7 @@ export const note = createCommand({
|
|||||||
const noteText = interaction.options.getString("note", true);
|
const noteText = interaction.options.getString("note", true);
|
||||||
|
|
||||||
// Create the note case
|
// Create the note case
|
||||||
const moderationCase = await ModerationService.createCase({
|
const moderationCase = await moderationService.createCase({
|
||||||
type: CaseType.NOTE,
|
type: CaseType.NOTE,
|
||||||
userId: targetUser.id,
|
userId: targetUser.id,
|
||||||
username: targetUser.username,
|
username: targetUser.username,
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@/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 "@/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
export const notes = createCommand({
|
export const notes = createCommand({
|
||||||
@@ -22,7 +22,7 @@ export const notes = createCommand({
|
|||||||
const targetUser = interaction.options.getUser("user", true);
|
const targetUser = interaction.options.getUser("user", true);
|
||||||
|
|
||||||
// Get all notes for the user
|
// Get all notes for the user
|
||||||
const userNotes = await ModerationService.getUserNotes(targetUser.id);
|
const userNotes = await moderationService.getUserNotes(targetUser.id);
|
||||||
|
|
||||||
// Display the notes
|
// Display the notes
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createCommand } from "@/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 "@/lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
import { PruneService } from "@/modules/moderation/prune.service";
|
import { pruneService } from "@shared/modules/moderation/prune.service";
|
||||||
import {
|
import {
|
||||||
getConfirmationMessage,
|
getConfirmationMessage,
|
||||||
getProgressEmbed,
|
getProgressEmbed,
|
||||||
@@ -66,7 +66,7 @@ export const prune = createCommand({
|
|||||||
let estimatedCount: number | undefined;
|
let estimatedCount: number | undefined;
|
||||||
if (all) {
|
if (all) {
|
||||||
try {
|
try {
|
||||||
estimatedCount = await PruneService.estimateMessageCount(interaction.channel!);
|
estimatedCount = await pruneService.estimateMessageCount(interaction.channel!);
|
||||||
} catch {
|
} catch {
|
||||||
estimatedCount = undefined;
|
estimatedCount = undefined;
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@ export const prune = createCommand({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Execute deletion with progress callback for 'all' mode
|
// Execute deletion with progress callback for 'all' mode
|
||||||
const result = await PruneService.deleteMessages(
|
const result = await pruneService.deleteMessages(
|
||||||
interaction.channel!,
|
interaction.channel!,
|
||||||
{
|
{
|
||||||
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
|
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
|
||||||
@@ -129,7 +129,7 @@ export const prune = createCommand({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No confirmation needed, proceed directly
|
// No confirmation needed, proceed directly
|
||||||
const result = await PruneService.deleteMessages(
|
const result = await pruneService.deleteMessages(
|
||||||
interaction.channel!,
|
interaction.channel!,
|
||||||
{
|
{
|
||||||
amount: finalAmount as number,
|
amount: finalAmount as number,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createCommand } from "@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, MessageFlags } from "discord.js";
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
247
bot/commands/admin/settings.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import { createCommand } from "@shared/lib/utils";
|
||||||
|
import { SlashCommandBuilder, PermissionFlagsBits, Colors, ChatInputCommandInteraction } from "discord.js";
|
||||||
|
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
|
||||||
|
import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
|
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
|
||||||
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
|
||||||
|
export const settings = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("settings")
|
||||||
|
.setDescription("Manage guild settings")
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("show")
|
||||||
|
.setDescription("Show current guild settings"))
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("set")
|
||||||
|
.setDescription("Set a guild setting")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("key")
|
||||||
|
.setDescription("Setting to change")
|
||||||
|
.setRequired(true)
|
||||||
|
.addChoices(
|
||||||
|
{ name: "Student Role", value: "studentRole" },
|
||||||
|
{ name: "Visitor Role", value: "visitorRole" },
|
||||||
|
{ name: "Welcome Channel", value: "welcomeChannel" },
|
||||||
|
{ name: "Welcome Message", value: "welcomeMessage" },
|
||||||
|
{ name: "Feedback Channel", value: "feedbackChannel" },
|
||||||
|
{ name: "Terminal Channel", value: "terminalChannel" },
|
||||||
|
{ name: "Terminal Message", value: "terminalMessage" },
|
||||||
|
{ name: "Moderation Log Channel", value: "moderationLogChannel" },
|
||||||
|
{ name: "DM on Warn", value: "moderationDmOnWarn" },
|
||||||
|
{ name: "Auto Timeout Threshold", value: "moderationAutoTimeoutThreshold" },
|
||||||
|
))
|
||||||
|
.addRoleOption(opt =>
|
||||||
|
opt.setName("role")
|
||||||
|
.setDescription("Role value"))
|
||||||
|
.addChannelOption(opt =>
|
||||||
|
opt.setName("channel")
|
||||||
|
.setDescription("Channel value"))
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("text")
|
||||||
|
.setDescription("Text value"))
|
||||||
|
.addIntegerOption(opt =>
|
||||||
|
opt.setName("number")
|
||||||
|
.setDescription("Number value"))
|
||||||
|
.addBooleanOption(opt =>
|
||||||
|
opt.setName("boolean")
|
||||||
|
.setDescription("Boolean value (true/false)")))
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("reset")
|
||||||
|
.setDescription("Reset a setting to default")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("key")
|
||||||
|
.setDescription("Setting to reset")
|
||||||
|
.setRequired(true)
|
||||||
|
.addChoices(
|
||||||
|
{ name: "Student Role", value: "studentRole" },
|
||||||
|
{ name: "Visitor Role", value: "visitorRole" },
|
||||||
|
{ name: "Welcome Channel", value: "welcomeChannel" },
|
||||||
|
{ name: "Welcome Message", value: "welcomeMessage" },
|
||||||
|
{ name: "Feedback Channel", value: "feedbackChannel" },
|
||||||
|
{ name: "Terminal Channel", value: "terminalChannel" },
|
||||||
|
{ name: "Terminal Message", value: "terminalMessage" },
|
||||||
|
{ name: "Moderation Log Channel", value: "moderationLogChannel" },
|
||||||
|
{ name: "DM on Warn", value: "moderationDmOnWarn" },
|
||||||
|
{ name: "Auto Timeout Threshold", value: "moderationAutoTimeoutThreshold" },
|
||||||
|
)))
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("colors")
|
||||||
|
.setDescription("Manage color roles")
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt.setName("action")
|
||||||
|
.setDescription("Action to perform")
|
||||||
|
.setRequired(true)
|
||||||
|
.addChoices(
|
||||||
|
{ name: "List", value: "list" },
|
||||||
|
{ name: "Add", value: "add" },
|
||||||
|
{ name: "Remove", value: "remove" },
|
||||||
|
))
|
||||||
|
.addRoleOption(opt =>
|
||||||
|
opt.setName("role")
|
||||||
|
.setDescription("Role to add/remove")
|
||||||
|
.setRequired(false))),
|
||||||
|
|
||||||
|
execute: async (interaction) => {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
const guildId = interaction.guildId!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (subcommand) {
|
||||||
|
case "show":
|
||||||
|
await handleShow(interaction, guildId);
|
||||||
|
break;
|
||||||
|
case "set":
|
||||||
|
await handleSet(interaction, guildId);
|
||||||
|
break;
|
||||||
|
case "reset":
|
||||||
|
await handleReset(interaction, guildId);
|
||||||
|
break;
|
||||||
|
case "colors":
|
||||||
|
await handleColors(interaction, guildId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UserError) {
|
||||||
|
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleShow(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||||
|
const settings = await getGuildConfig(guildId);
|
||||||
|
|
||||||
|
const colorRolesDisplay = settings.colorRoles?.length
|
||||||
|
? settings.colorRoles.map(id => `<@&${id}>`).join(", ")
|
||||||
|
: "None";
|
||||||
|
|
||||||
|
const embed = createBaseEmbed("Guild Settings", undefined, Colors.Blue)
|
||||||
|
.addFields(
|
||||||
|
{ name: "Student Role", value: settings.studentRole ? `<@&${settings.studentRole}>` : "Not set", inline: true },
|
||||||
|
{ name: "Visitor Role", value: settings.visitorRole ? `<@&${settings.visitorRole}>` : "Not set", inline: true },
|
||||||
|
{ name: "\u200b", value: "\u200b", inline: true },
|
||||||
|
{ name: "Welcome Channel", value: settings.welcomeChannelId ? `<#${settings.welcomeChannelId}>` : "Not set", inline: true },
|
||||||
|
{ name: "Feedback Channel", value: settings.feedbackChannelId ? `<#${settings.feedbackChannelId}>` : "Not set", inline: true },
|
||||||
|
{ name: "Moderation Log", value: settings.moderation?.cases?.logChannelId ? `<#${settings.moderation.cases.logChannelId}>` : "Not set", inline: true },
|
||||||
|
{ name: "Terminal Channel", value: settings.terminal?.channelId ? `<#${settings.terminal.channelId}>` : "Not set", inline: true },
|
||||||
|
{ name: "DM on Warn", value: settings.moderation?.cases?.dmOnWarn !== false ? "Enabled" : "Disabled", inline: true },
|
||||||
|
{ name: "Auto Timeout", value: settings.moderation?.cases?.autoTimeoutThreshold ? `${settings.moderation.cases.autoTimeoutThreshold} warnings` : "Disabled", inline: true },
|
||||||
|
{ name: "Color Roles", value: colorRolesDisplay, inline: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (settings.welcomeMessage) {
|
||||||
|
embed.addFields({ name: "Welcome Message", value: settings.welcomeMessage.substring(0, 1024), inline: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSet(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||||
|
const key = interaction.options.getString("key", true);
|
||||||
|
const role = interaction.options.getRole("role");
|
||||||
|
const channel = interaction.options.getChannel("channel");
|
||||||
|
const text = interaction.options.getString("text");
|
||||||
|
const number = interaction.options.getInteger("number");
|
||||||
|
const boolean = interaction.options.getBoolean("boolean");
|
||||||
|
|
||||||
|
let value: string | number | boolean | null = null;
|
||||||
|
|
||||||
|
if (role) value = role.id;
|
||||||
|
else if (channel) value = channel.id;
|
||||||
|
else if (text) value = text;
|
||||||
|
else if (number !== null) value = number;
|
||||||
|
else if (boolean !== null) value = boolean;
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("Please provide a role, channel, text, number, or boolean value")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await guildSettingsService.updateSetting(guildId, key, value);
|
||||||
|
invalidateGuildConfigCache(guildId);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Setting "${key}" updated`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReset(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||||
|
const key = interaction.options.getString("key", true);
|
||||||
|
|
||||||
|
await guildSettingsService.updateSetting(guildId, key, null);
|
||||||
|
invalidateGuildConfigCache(guildId);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Setting "${key}" reset to default`)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleColors(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||||
|
const action = interaction.options.getString("action", true);
|
||||||
|
const role = interaction.options.getRole("role");
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case "list": {
|
||||||
|
const settings = await getGuildConfig(guildId);
|
||||||
|
const colorRoles = settings.colorRoles ?? [];
|
||||||
|
|
||||||
|
if (colorRoles.length === 0) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createBaseEmbed("Color Roles", "No color roles configured.", Colors.Blue)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = createBaseEmbed("Color Roles", undefined, Colors.Blue)
|
||||||
|
.addFields({
|
||||||
|
name: `Configured Roles (${colorRoles.length})`,
|
||||||
|
value: colorRoles.map(id => `<@&${id}>`).join("\n"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "add": {
|
||||||
|
if (!role) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("Please specify a role to add.")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await guildSettingsService.addColorRole(guildId, role.id);
|
||||||
|
invalidateGuildConfigCache(guildId);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Added <@&${role.id}> to color roles.`)]
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "remove": {
|
||||||
|
if (!role) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("Please specify a role to remove.")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await guildSettingsService.removeColorRole(guildId, role.id);
|
||||||
|
invalidateGuildConfigCache(guildId);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(`Removed <@&${role.id}> from color roles.`)]
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import { createCommand } from "@/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 "@/modules/terminal/terminal.service";
|
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
||||||
import { createBaseEmbed, createErrorEmbed } from "@/lib/embeds";
|
import { createBaseEmbed, createErrorEmbed } from "@/lib/embeds";
|
||||||
|
|
||||||
export const terminal = createCommand({
|
export const terminal = createCommand({
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { createCommand } from "@/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 "@/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import {
|
import {
|
||||||
getWarnSuccessEmbed,
|
getWarnSuccessEmbed,
|
||||||
getModerationErrorEmbed,
|
getModerationErrorEmbed,
|
||||||
getUserWarningEmbed
|
getUserWarningEmbed
|
||||||
} from "@/modules/moderation/moderation.view";
|
} from "@/modules/moderation/moderation.view";
|
||||||
import { config } from "@/lib/config";
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
|
|
||||||
export const warn = createCommand({
|
export const warn = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -50,8 +50,11 @@ export const warn = createCommand({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch guild config for moderation settings
|
||||||
|
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||||
|
|
||||||
// Issue the warning via service
|
// Issue the warning via service
|
||||||
const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({
|
const { moderationCase, warningCount, autoTimeoutIssued } = await moderationService.issueWarning({
|
||||||
userId: targetUser.id,
|
userId: targetUser.id,
|
||||||
username: targetUser.username,
|
username: targetUser.username,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -59,7 +62,11 @@ export const warn = createCommand({
|
|||||||
reason,
|
reason,
|
||||||
guildName: interaction.guild?.name || undefined,
|
guildName: interaction.guild?.name || undefined,
|
||||||
dmTarget: targetUser,
|
dmTarget: targetUser,
|
||||||
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id)
|
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id),
|
||||||
|
config: {
|
||||||
|
dmOnWarn: guildConfig.moderation?.cases?.dmOnWarn,
|
||||||
|
autoTimeoutThreshold: guildConfig.moderation?.cases?.autoTimeoutThreshold,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send success message to moderator
|
// Send success message to moderator
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@/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 "@/modules/moderation/moderation.service";
|
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
export const warnings = createCommand({
|
export const warnings = createCommand({
|
||||||
@@ -22,7 +22,7 @@ export const warnings = createCommand({
|
|||||||
const targetUser = interaction.options.getUser("user", true);
|
const targetUser = interaction.options.getUser("user", true);
|
||||||
|
|
||||||
// Get active warnings for the user
|
// Get active warnings for the user
|
||||||
const activeWarnings = await ModerationService.getUserWarnings(targetUser.id);
|
const activeWarnings = await moderationService.getUserWarnings(targetUser.id);
|
||||||
|
|
||||||
// Display the warnings
|
// Display the warnings
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
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";
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { createBaseEmbed } from "@lib/embeds";
|
import { createBaseEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
export const balance = createCommand({
|
export const balance = createCommand({
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
|
|
||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { economyService } from "@/modules/economy/economy.service";
|
import { economyService } from "@shared/modules/economy/economy.service";
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
import { UserError } from "@/lib/errors";
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
|
||||||
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) => {
|
||||||
|
await interaction.deferReply();
|
||||||
try {
|
try {
|
||||||
const result = await economyService.claimDaily(interaction.user.id);
|
const result = await economyService.claimDaily(interaction.user.id);
|
||||||
|
|
||||||
@@ -21,14 +22,14 @@ export const daily = createCommand({
|
|||||||
)
|
)
|
||||||
.setColor("Gold");
|
.setColor("Gold");
|
||||||
|
|
||||||
await interaction.reply({ embeds: [embed] });
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error instanceof UserError) {
|
if (error instanceof UserError) {
|
||||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||||
} else {
|
} else {
|
||||||
console.error("Error claiming daily:", error);
|
console.error("Error claiming daily:", error);
|
||||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
75
bot/commands/economy/exam.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { createCommand } from "@shared/lib/utils";
|
||||||
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
|
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
|
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
|
||||||
|
|
||||||
|
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||||
|
|
||||||
|
export const exam = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("exam")
|
||||||
|
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First, try to take the exam or check status
|
||||||
|
const result = await examService.takeExam(interaction.user.id);
|
||||||
|
|
||||||
|
if (result.status === ExamStatus.NOT_REGISTERED) {
|
||||||
|
// Register the user
|
||||||
|
const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username);
|
||||||
|
const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(
|
||||||
|
`You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` +
|
||||||
|
`Come back on <t:${nextRegTimestamp}:D> (<t:${nextRegTimestamp}:R>) to take your first exam!`,
|
||||||
|
"Exam Registration Successful"
|
||||||
|
)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000);
|
||||||
|
|
||||||
|
if (result.status === ExamStatus.COOLDOWN) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed(
|
||||||
|
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
||||||
|
`Next exam available: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`
|
||||||
|
)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === ExamStatus.MISSED) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed(
|
||||||
|
`You missed your exam day! Your exam day is **${DAYS[result.examDay!]}** (Server Time).\n` +
|
||||||
|
`You verify your attendance but score a **0**.\n` +
|
||||||
|
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
|
||||||
|
"Exam Failed"
|
||||||
|
)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it reached here with AVAILABLE, it means they passed
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(
|
||||||
|
`**XP Gained:** ${result.xpDiff?.toString()}\n` +
|
||||||
|
`**Multiplier:** x${result.multiplier?.toFixed(2)}\n` +
|
||||||
|
`**Reward:** ${result.reward?.toString()} Currency\n\n` +
|
||||||
|
`See you next week: <t:${nextExamTimestamp}:D>`,
|
||||||
|
"Exam Passed!"
|
||||||
|
)]
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error in exam command:", error);
|
||||||
|
await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An unexpected error occurred.")] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
|
|
||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
||||||
import { economyService } from "@/modules/economy/economy.service";
|
import { economyService } from "@shared/modules/economy/economy.service";
|
||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { config } from "@/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 { UserError } from "@shared/lib/errors";
|
||||||
|
|
||||||
export const pay = createCommand({
|
export const pay = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags } from "discord.js";
|
||||||
import { tradeService } from "@/modules/trade/trade.service";
|
import { tradeService } from "@shared/modules/trade/trade.service";
|
||||||
import { getTradeDashboard } from "@/modules/trade/trade.view";
|
import { getTradeDashboard } from "@/modules/trade/trade.view";
|
||||||
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
117
bot/commands/economy/trivia.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { createCommand } from "@shared/lib/utils";
|
||||||
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
|
import { triviaService } from "@shared/modules/trivia/trivia.service";
|
||||||
|
import { getTriviaQuestionView } from "@/modules/trivia/trivia.view";
|
||||||
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
|
import { UserError } from "@shared/lib/errors";
|
||||||
|
import { config } from "@shared/lib/config";
|
||||||
|
import { TriviaCategory } from "@shared/lib/constants";
|
||||||
|
|
||||||
|
export const trivia = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("trivia")
|
||||||
|
.setDescription("Play trivia to win currency! Answer correctly within the time limit.")
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('category')
|
||||||
|
.setDescription('Select a specific category')
|
||||||
|
.setRequired(false)
|
||||||
|
.addChoices(
|
||||||
|
{ name: 'General Knowledge', value: String(TriviaCategory.GENERAL_KNOWLEDGE) },
|
||||||
|
{ name: 'Books', value: String(TriviaCategory.BOOKS) },
|
||||||
|
{ name: 'Film', value: String(TriviaCategory.FILM) },
|
||||||
|
{ name: 'Music', value: String(TriviaCategory.MUSIC) },
|
||||||
|
{ name: 'Video Games', value: String(TriviaCategory.VIDEO_GAMES) },
|
||||||
|
{ name: 'Science & Nature', value: String(TriviaCategory.SCIENCE_NATURE) },
|
||||||
|
{ name: 'Computers', value: String(TriviaCategory.COMPUTERS) },
|
||||||
|
{ name: 'Mathematics', value: String(TriviaCategory.MATHEMATICS) },
|
||||||
|
{ name: 'Mythology', value: String(TriviaCategory.MYTHOLOGY) },
|
||||||
|
{ name: 'Sports', value: String(TriviaCategory.SPORTS) },
|
||||||
|
{ name: 'Geography', value: String(TriviaCategory.GEOGRAPHY) },
|
||||||
|
{ name: 'History', value: String(TriviaCategory.HISTORY) },
|
||||||
|
{ name: 'Politics', value: String(TriviaCategory.POLITICS) },
|
||||||
|
{ name: 'Art', value: String(TriviaCategory.ART) },
|
||||||
|
{ name: 'Animals', value: String(TriviaCategory.ANIMALS) },
|
||||||
|
{ name: 'Anime & Manga', value: String(TriviaCategory.ANIME_MANGA) },
|
||||||
|
)
|
||||||
|
),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
try {
|
||||||
|
const categoryId = interaction.options.getString('category');
|
||||||
|
|
||||||
|
// Check if user can play BEFORE deferring
|
||||||
|
const canPlay = await triviaService.canPlayTrivia(interaction.user.id);
|
||||||
|
|
||||||
|
if (!canPlay.canPlay) {
|
||||||
|
// Cooldown error - ephemeral
|
||||||
|
const timestamp = Math.floor(canPlay.nextAvailable!.getTime() / 1000);
|
||||||
|
await interaction.reply({
|
||||||
|
embeds: [createErrorEmbed(
|
||||||
|
`You're on cooldown! Try again <t:${timestamp}:R>.`
|
||||||
|
)],
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User can play - defer publicly for trivia question
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
// Start trivia session (deducts entry fee)
|
||||||
|
const session = await triviaService.startTrivia(
|
||||||
|
interaction.user.id,
|
||||||
|
interaction.user.username,
|
||||||
|
categoryId ? parseInt(categoryId) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate Components v2 message
|
||||||
|
const { components, flags } = getTriviaQuestionView(session, interaction.user.username);
|
||||||
|
|
||||||
|
// Reply with Components v2 question
|
||||||
|
await interaction.editReply({
|
||||||
|
components,
|
||||||
|
flags
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up automatic timeout cleanup
|
||||||
|
setTimeout(async () => {
|
||||||
|
const stillActive = triviaService.getSession(session.sessionId);
|
||||||
|
if (stillActive) {
|
||||||
|
// User didn't answer - clean up session with no reward
|
||||||
|
try {
|
||||||
|
await triviaService.submitAnswer(session.sessionId, interaction.user.id, false);
|
||||||
|
} catch (error) {
|
||||||
|
// Session already cleaned up, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, config.trivia.timeoutSeconds * 1000 + 5000); // 5 seconds grace period
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error instanceof UserError) {
|
||||||
|
// Check if we've already deferred
|
||||||
|
if (interaction.deferred) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed(error.message)]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await interaction.reply({
|
||||||
|
embeds: [createErrorEmbed(error.message)],
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error("Error in trivia command:", error);
|
||||||
|
// Check if we've already deferred
|
||||||
|
if (interaction.deferred) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await interaction.reply({
|
||||||
|
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { config } from "@/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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { createWarningEmbed } from "@lib/embeds";
|
import { createWarningEmbed } from "@lib/embeds";
|
||||||
import { getInventoryEmbed } from "@/modules/inventory/inventory.view";
|
import { getInventoryEmbed } from "@/modules/inventory/inventory.view";
|
||||||
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { userService } from "@/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 "@/lib/types";
|
import type { ItemUsageData } from "@shared/lib/types";
|
||||||
import { UserError } from "@/lib/errors";
|
import { UserError } from "@shared/lib/errors";
|
||||||
import { config } from "@/lib/config";
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
|
|
||||||
export const use = createCommand({
|
export const use = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -21,6 +21,9 @@ export const use = createCommand({
|
|||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply();
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
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) {
|
||||||
@@ -42,7 +45,7 @@ export const use = createCommand({
|
|||||||
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);
|
||||||
}
|
}
|
||||||
@@ -55,9 +58,9 @@ export const use = createCommand({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const embed = getItemUseResultEmbed(result.results, result.item);
|
const { embed, files } = getItemUseResultEmbed(result.results, result.item);
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
await interaction.editReply({ embeds: [embed], files });
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error instanceof UserError) {
|
if (error instanceof UserError) {
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
import { users, items, inventory } from "@/db/schema";
|
import { users, items, inventory } from "@db/schema";
|
||||||
import { desc, sql, eq } from "drizzle-orm";
|
import { desc, sql, eq } from "drizzle-orm";
|
||||||
import { createWarningEmbed } from "@lib/embeds";
|
import { createWarningEmbed } from "@lib/embeds";
|
||||||
import { getLeaderboardEmbed } from "@/modules/leveling/leveling.view";
|
import { getLeaderboardEmbed } from "@/modules/leveling/leveling.view";
|
||||||
83
bot/commands/quest/quests.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { createCommand } from "@shared/lib/utils";
|
||||||
|
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
||||||
|
import { questService } from "@shared/modules/quest/quest.service";
|
||||||
|
import { createSuccessEmbed } from "@lib/embeds";
|
||||||
|
import {
|
||||||
|
getQuestListComponents,
|
||||||
|
getAvailableQuestsComponents,
|
||||||
|
getQuestActionRows
|
||||||
|
} from "@/modules/quest/quest.view";
|
||||||
|
|
||||||
|
export const quests = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("quests")
|
||||||
|
.setDescription("View your active and available quests"),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
const response = await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
|
const userId = interaction.user.id;
|
||||||
|
|
||||||
|
const updateView = async (viewType: 'active' | 'available') => {
|
||||||
|
const userQuests = await questService.getUserQuests(userId);
|
||||||
|
const availableQuests = await questService.getAvailableQuests(userId);
|
||||||
|
|
||||||
|
const containers = viewType === 'active'
|
||||||
|
? getQuestListComponents(userQuests)
|
||||||
|
: getAvailableQuestsComponents(availableQuests);
|
||||||
|
|
||||||
|
const 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,6 +1,6 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, AttachmentBuilder } from "discord.js";
|
import { SlashCommandBuilder, AttachmentBuilder } from "discord.js";
|
||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { generateStudentIdCard } from "@/graphics/studentID";
|
import { generateStudentIdCard } from "@/graphics/studentID";
|
||||||
import { createWarningEmbed } from "@/lib/embeds";
|
import { createWarningEmbed } from "@/lib/embeds";
|
||||||
|
|
||||||
@@ -1,20 +1,26 @@
|
|||||||
import { Events } from "discord.js";
|
import { Events } from "discord.js";
|
||||||
import type { Event } from "@lib/types";
|
import type { Event } from "@shared/lib/types";
|
||||||
import { config } from "@lib/config";
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
import { userService } from "@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) {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Events } from "discord.js";
|
import { Events } from "discord.js";
|
||||||
import { ComponentInteractionHandler, AutocompleteHandler, CommandHandler } from "@/lib/handlers";
|
import { ComponentInteractionHandler, AutocompleteHandler, CommandHandler } from "@/lib/handlers";
|
||||||
import type { Event } from "@lib/types";
|
import type { Event } from "@shared/lib/types";
|
||||||
|
|
||||||
const event: Event<Events.InteractionCreate> = {
|
const event: Event<Events.InteractionCreate> = {
|
||||||
name: Events.InteractionCreate,
|
name: Events.InteractionCreate,
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Events } from "discord.js";
|
import { Events } from "discord.js";
|
||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { levelingService } from "@/modules/leveling/leveling.service";
|
import { levelingService } from "@shared/modules/leveling/leveling.service";
|
||||||
import type { Event } from "@lib/types";
|
import type { Event } from "@shared/lib/types";
|
||||||
|
|
||||||
const event: Event<Events.MessageCreate> = {
|
const event: Event<Events.MessageCreate> = {
|
||||||
name: Events.MessageCreate,
|
name: Events.MessageCreate,
|
||||||
@@ -15,7 +15,7 @@ const event: Event<Events.MessageCreate> = {
|
|||||||
levelingService.processChatXp(message.author.id);
|
levelingService.processChatXp(message.author.id);
|
||||||
|
|
||||||
// Activity Tracking for Lootdrops
|
// Activity Tracking for Lootdrops
|
||||||
import("@/modules/economy/lootdrop.service").then(m => m.lootdropService.processMessage(message));
|
import("@shared/modules/economy/lootdrop.service").then(m => m.lootdropService.processMessage(message));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Events } from "discord.js";
|
import { Events } from "discord.js";
|
||||||
import { schedulerService } from "@/modules/system/scheduler";
|
import { schedulerService } from "@/modules/system/scheduler";
|
||||||
import type { Event } from "@lib/types";
|
import type { Event } from "@shared/lib/types";
|
||||||
|
|
||||||
const event: Event<Events.ClientReady> = {
|
const event: Event<Events.ClientReady> = {
|
||||||
name: Events.ClientReady,
|
name: Events.ClientReady,
|
||||||
@@ -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("@/modules/admin/update.service");
|
|
||||||
await UpdateService.handlePostRestart(c);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2,12 +2,12 @@ import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
// Register Fonts (same as studentID.ts)
|
// Register Fonts (same as studentID.ts)
|
||||||
const fontDir = path.join(process.cwd(), 'src', 'assets', 'fonts');
|
const fontDir = path.join(process.cwd(), 'bot', 'assets', 'fonts');
|
||||||
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold');
|
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold');
|
||||||
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
|
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
|
||||||
|
|
||||||
export async function generateLootdropCard(amount: number, currency: string): Promise<Buffer> {
|
export async function generateLootdropCard(amount: number, currency: string): Promise<Buffer> {
|
||||||
const templatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'lootdrop', 'template.png');
|
const templatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'lootdrop', 'template.png');
|
||||||
const template = await loadImage(templatePath);
|
const template = await loadImage(templatePath);
|
||||||
|
|
||||||
const canvas = createCanvas(template.width, template.height);
|
const canvas = createCanvas(template.width, template.height);
|
||||||
@@ -50,7 +50,7 @@ export async function generateLootdropCard(amount: number, currency: string): Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateClaimedLootdropCard(amount: number, currency: string, username: string, avatarUrl: string): Promise<Buffer> {
|
export async function generateClaimedLootdropCard(amount: number, currency: string, username: string, avatarUrl: string): Promise<Buffer> {
|
||||||
const templatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'lootdrop', 'template.png');
|
const templatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'lootdrop', 'template.png');
|
||||||
const template = await loadImage(templatePath);
|
const template = await loadImage(templatePath);
|
||||||
|
|
||||||
const canvas = createCanvas(template.width, template.height);
|
const canvas = createCanvas(template.width, template.height);
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas';
|
import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas';
|
||||||
import { levelingService } from '@/modules/leveling/leveling.service';
|
import { levelingService } from '@shared/modules/leveling/leveling.service';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
// Register Fonts
|
// Register Fonts
|
||||||
const fontDir = path.join(process.cwd(), 'src', 'assets', 'fonts');
|
const fontDir = path.join(process.cwd(), 'bot', 'assets', 'fonts');
|
||||||
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold');
|
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold');
|
||||||
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
|
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
|
||||||
|
|
||||||
@@ -18,8 +18,8 @@ interface StudentCardData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateStudentIdCard(data: StudentCardData): Promise<Buffer> {
|
export async function generateStudentIdCard(data: StudentCardData): Promise<Buffer> {
|
||||||
const templatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'studentID', 'template.png');
|
const templatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'studentID', 'template.png');
|
||||||
const classTemplatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'studentID', `Constellation-${data.className}.png`);
|
const classTemplatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'studentID', `Constellation-${data.className}.png`);
|
||||||
|
|
||||||
const template = await loadImage(templatePath);
|
const template = await loadImage(templatePath);
|
||||||
const classTemplate = await loadImage(classTemplatePath);
|
const classTemplate = await loadImage(classTemplatePath);
|
||||||
53
bot/index.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
|
import { env } from "@shared/lib/env";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { initializeConfig } from "@shared/lib/config";
|
||||||
|
|
||||||
|
import { startWebServerFromRoot } from "../web/src/server";
|
||||||
|
|
||||||
|
// Initialize config from database
|
||||||
|
await initializeConfig();
|
||||||
|
|
||||||
|
// Load commands & events
|
||||||
|
await AuroraClient.loadCommands();
|
||||||
|
await AuroraClient.loadEvents();
|
||||||
|
await AuroraClient.deployCommands();
|
||||||
|
await AuroraClient.setupSystemEvents();
|
||||||
|
|
||||||
|
console.log("🌐 Starting web server...");
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
|
||||||
|
const webProjectPath = join(import.meta.dir, "../web");
|
||||||
|
const webPort = Number(process.env.WEB_PORT) || 3000;
|
||||||
|
const webHost = process.env.HOST || "0.0.0.0";
|
||||||
|
|
||||||
|
// Start web server in the same process
|
||||||
|
const webServer = await startWebServerFromRoot(webProjectPath, {
|
||||||
|
port: webPort,
|
||||||
|
hostname: webHost,
|
||||||
|
});
|
||||||
|
|
||||||
|
// login with the token from .env
|
||||||
|
if (!env.DISCORD_BOT_TOKEN) {
|
||||||
|
throw new Error("❌ DISCORD_BOT_TOKEN is not set in environment variables.");
|
||||||
|
}
|
||||||
|
AuroraClient.login(env.DISCORD_BOT_TOKEN);
|
||||||
|
|
||||||
|
// Handle graceful shutdown
|
||||||
|
const shutdownHandler = async () => {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
console.log("🛑 Shutdown signal received. Stopping services...");
|
||||||
|
|
||||||
|
// Stop web server
|
||||||
|
await webServer.stop();
|
||||||
|
|
||||||
|
// Stop bot
|
||||||
|
AuroraClient.shutdown();
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on("SIGINT", shutdownHandler);
|
||||||
|
process.on("SIGTERM", shutdownHandler);
|
||||||
112
bot/lib/BotClient.test.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, expect, test, mock, beforeEach, spyOn } from "bun:test";
|
||||||
|
import { systemEvents, EVENTS } from "@shared/lib/events";
|
||||||
|
|
||||||
|
// Mock Discord.js Client and related classes
|
||||||
|
mock.module("discord.js", () => ({
|
||||||
|
Client: class {
|
||||||
|
constructor() { }
|
||||||
|
on() { }
|
||||||
|
once() { }
|
||||||
|
login() { }
|
||||||
|
destroy() { }
|
||||||
|
removeAllListeners() { }
|
||||||
|
},
|
||||||
|
Collection: Map,
|
||||||
|
GatewayIntentBits: { Guilds: 1, MessageContent: 1, GuildMessages: 1, GuildMembers: 1 },
|
||||||
|
REST: class {
|
||||||
|
setToken() { return this; }
|
||||||
|
put() { return Promise.resolve([]); }
|
||||||
|
},
|
||||||
|
Routes: {
|
||||||
|
applicationGuildCommands: () => 'guild_route',
|
||||||
|
applicationCommands: () => 'global_route'
|
||||||
|
},
|
||||||
|
MessageFlags: {}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock loaders to avoid filesystem access during client init
|
||||||
|
mock.module("../lib/loaders/CommandLoader", () => ({
|
||||||
|
CommandLoader: class {
|
||||||
|
constructor() { }
|
||||||
|
loadFromDirectory() { return Promise.resolve({ loaded: 0, skipped: 0, errors: [] }); }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
mock.module("../lib/loaders/EventLoader", () => ({
|
||||||
|
EventLoader: class {
|
||||||
|
constructor() { }
|
||||||
|
loadFromDirectory() { return Promise.resolve({ loaded: 0, skipped: 0, errors: [] }); }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock dashboard service to prevent network/db calls during event handling
|
||||||
|
mock.module("@shared/modules/economy/lootdrop.service", () => ({
|
||||||
|
lootdropService: { clearCaches: mock(async () => { }) }
|
||||||
|
}));
|
||||||
|
mock.module("@shared/modules/trade/trade.service", () => ({
|
||||||
|
tradeService: { clearSessions: mock(() => { }) }
|
||||||
|
}));
|
||||||
|
mock.module("@/modules/admin/item_wizard", () => ({
|
||||||
|
clearDraftSessions: mock(() => { })
|
||||||
|
}));
|
||||||
|
mock.module("@shared/modules/dashboard/dashboard.service", () => ({
|
||||||
|
dashboardService: {
|
||||||
|
recordEvent: mock(() => Promise.resolve())
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("AuroraClient System Events", () => {
|
||||||
|
let AuroraClient: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
systemEvents.removeAllListeners();
|
||||||
|
const module = await import("./BotClient");
|
||||||
|
AuroraClient = module.AuroraClient;
|
||||||
|
AuroraClient.maintenanceMode = false;
|
||||||
|
// MUST call explicitly now
|
||||||
|
await AuroraClient.setupSystemEvents();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Case: Maintenance Mode Toggle
|
||||||
|
* Requirement: Client state should update when event is received
|
||||||
|
*/
|
||||||
|
test("should toggle maintenanceMode when MAINTENANCE_MODE event is received", async () => {
|
||||||
|
systemEvents.emit(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled: true, reason: "Testing" });
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 30));
|
||||||
|
expect(AuroraClient.maintenanceMode).toBe(true);
|
||||||
|
|
||||||
|
systemEvents.emit(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled: false });
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 30));
|
||||||
|
expect(AuroraClient.maintenanceMode).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Case: Command Reload
|
||||||
|
* Requirement: loadCommands and deployCommands should be called
|
||||||
|
*/
|
||||||
|
test("should reload commands when RELOAD_COMMANDS event is received", async () => {
|
||||||
|
const loadSpy = spyOn(AuroraClient, "loadCommands").mockImplementation(() => Promise.resolve());
|
||||||
|
const deploySpy = spyOn(AuroraClient, "deployCommands").mockImplementation(() => Promise.resolve());
|
||||||
|
|
||||||
|
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(loadSpy).toHaveBeenCalled();
|
||||||
|
expect(deploySpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Case: Cache Clearance
|
||||||
|
* Requirement: Service clear methods should be triggered
|
||||||
|
*/
|
||||||
|
test("should trigger service cache clearance when CLEAR_CACHE is received", async () => {
|
||||||
|
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||||
|
const { tradeService } = await import("@shared/modules/trade/trade.service");
|
||||||
|
|
||||||
|
systemEvents.emit(EVENTS.ACTIONS.CLEAR_CACHE);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(lootdropService.clearCaches).toHaveBeenCalled();
|
||||||
|
expect(tradeService.clearSessions).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
200
bot/lib/BotClient.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes, MessageFlags } from "discord.js";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { Command } from "@shared/lib/types";
|
||||||
|
import { env } from "@shared/lib/env";
|
||||||
|
import { CommandLoader } from "@lib/loaders/CommandLoader";
|
||||||
|
import { EventLoader } from "@lib/loaders/EventLoader";
|
||||||
|
|
||||||
|
export class Client extends DiscordClient {
|
||||||
|
|
||||||
|
commands: Collection<string, Command>;
|
||||||
|
knownCommands: Map<string, string>;
|
||||||
|
lastCommandTimestamp: number | null = null;
|
||||||
|
maintenanceMode: boolean = false;
|
||||||
|
private commandLoader: CommandLoader;
|
||||||
|
private eventLoader: EventLoader;
|
||||||
|
|
||||||
|
constructor({ intents }: { intents: number[] }) {
|
||||||
|
super({ intents });
|
||||||
|
this.commands = new Collection<string, Command>();
|
||||||
|
this.knownCommands = new Map<string, string>();
|
||||||
|
this.commandLoader = new CommandLoader(this);
|
||||||
|
this.eventLoader = new EventLoader(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setupSystemEvents() {
|
||||||
|
const { systemEvents, EVENTS } = await import("@shared/lib/events");
|
||||||
|
|
||||||
|
systemEvents.on(EVENTS.ACTIONS.RELOAD_COMMANDS, async () => {
|
||||||
|
console.log("🔄 System Action: Reloading commands...");
|
||||||
|
try {
|
||||||
|
await this.loadCommands(true);
|
||||||
|
await this.deployCommands();
|
||||||
|
|
||||||
|
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||||
|
await dashboardService.recordEvent({
|
||||||
|
type: "success",
|
||||||
|
message: "Bot: Commands reloaded and redeployed",
|
||||||
|
icon: "✅"
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to reload commands:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
systemEvents.on(EVENTS.ACTIONS.CLEAR_CACHE, async () => {
|
||||||
|
console.log("<22> System Action: Clearing all internal caches...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Lootdrop Service
|
||||||
|
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||||
|
await lootdropService.clearCaches();
|
||||||
|
|
||||||
|
// 2. Trade Service
|
||||||
|
const { tradeService } = await import("@shared/modules/trade/trade.service");
|
||||||
|
tradeService.clearSessions();
|
||||||
|
|
||||||
|
// 3. Item Wizard
|
||||||
|
const { clearDraftSessions } = await import("@/modules/admin/item_wizard");
|
||||||
|
clearDraftSessions();
|
||||||
|
|
||||||
|
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||||
|
await dashboardService.recordEvent({
|
||||||
|
type: "success",
|
||||||
|
message: "Bot: All internal caches and sessions cleared",
|
||||||
|
icon: "🧼"
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to clear caches:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
systemEvents.on(EVENTS.ACTIONS.MAINTENANCE_MODE, async (data: { enabled: boolean, reason?: string }) => {
|
||||||
|
const { enabled, reason } = data;
|
||||||
|
console.log(`🛠️ System Action: Maintenance mode ${enabled ? "ON" : "OFF"}${reason ? ` (${reason})` : ""}`);
|
||||||
|
this.maintenanceMode = enabled;
|
||||||
|
});
|
||||||
|
|
||||||
|
systemEvents.on(EVENTS.QUEST.COMPLETED, async (data: { userId: string, quest: any, rewards: any }) => {
|
||||||
|
const { userId, quest, rewards } = data;
|
||||||
|
try {
|
||||||
|
const user = await this.users.fetch(userId);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const { getQuestCompletionComponents } = await import("@/modules/quest/quest.view");
|
||||||
|
const components = getQuestCompletionComponents(quest, rewards);
|
||||||
|
|
||||||
|
// Try to send to the user's DM
|
||||||
|
await user.send({
|
||||||
|
components: components as any,
|
||||||
|
flags: [MessageFlags.IsComponentsV2]
|
||||||
|
}).catch(async () => {
|
||||||
|
console.warn(`Could not DM user ${userId} quest completion message. User might have DMs disabled.`);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send quest completion notification:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadCommands(reload: boolean = false) {
|
||||||
|
if (reload) {
|
||||||
|
this.commands.clear();
|
||||||
|
this.knownCommands.clear();
|
||||||
|
console.log("♻️ Reloading commands...");
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandsPath = join(import.meta.dir, '../commands');
|
||||||
|
const result = await this.commandLoader.loadFromDirectory(commandsPath, reload);
|
||||||
|
|
||||||
|
console.log(`📦 Command loading complete: ${result.loaded} loaded, ${result.skipped} skipped, ${result.errors.length} errors`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadEvents(reload: boolean = false) {
|
||||||
|
if (reload) {
|
||||||
|
this.removeAllListeners();
|
||||||
|
console.log("♻️ Reloading events...");
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventsPath = join(import.meta.dir, '../events');
|
||||||
|
const result = await this.eventLoader.loadFromDirectory(eventsPath, reload);
|
||||||
|
|
||||||
|
console.log(`📦 Event loading complete: ${result.loaded} loaded, ${result.skipped} skipped, ${result.errors.length} errors`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async deployCommands() {
|
||||||
|
// We use env.DISCORD_BOT_TOKEN directly so this can run without client.login()
|
||||||
|
const token = env.DISCORD_BOT_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
console.error("DISCORD_BOT_TOKEN is not set.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rest = new REST().setToken(token);
|
||||||
|
const commandsData = this.commands.map(c => c.data.toJSON());
|
||||||
|
const guildId = env.DISCORD_GUILD_ID;
|
||||||
|
const clientId = env.DISCORD_CLIENT_ID;
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
console.error("DISCORD_CLIENT_ID is not set.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Started refreshing ${commandsData.length} application (/) commands.`);
|
||||||
|
|
||||||
|
let data;
|
||||||
|
if (guildId) {
|
||||||
|
console.log(`Registering commands to guild: ${guildId}`);
|
||||||
|
data = await rest.put(
|
||||||
|
Routes.applicationGuildCommands(clientId, guildId),
|
||||||
|
{ body: commandsData },
|
||||||
|
);
|
||||||
|
// Clear global commands to avoid duplicates
|
||||||
|
await rest.put(Routes.applicationCommands(clientId), { body: [] });
|
||||||
|
} else {
|
||||||
|
console.log('Registering commands globally');
|
||||||
|
data = await rest.put(
|
||||||
|
Routes.applicationCommands(clientId),
|
||||||
|
{ body: commandsData },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Successfully reloaded ${(data as any).length} application (/) commands.`);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 50001) {
|
||||||
|
console.warn("Missing Access: The bot is not in the guild or lacks 'applications.commands' scope.");
|
||||||
|
console.warn(" If you are testing locally, make sure you invited the bot with scope 'bot applications.commands'.");
|
||||||
|
} else {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdown() {
|
||||||
|
const { setShuttingDown, waitForTransactions } = await import("./shutdown");
|
||||||
|
const { closeDatabase } = await import("@shared/db/DrizzleClient");
|
||||||
|
|
||||||
|
console.log("🛑 Shutdown signal received. Starting graceful shutdown...");
|
||||||
|
setShuttingDown(true);
|
||||||
|
|
||||||
|
// Wait for transactions to complete
|
||||||
|
console.log("⏳ Waiting for active transactions to complete...");
|
||||||
|
await waitForTransactions(10000);
|
||||||
|
|
||||||
|
// Destroy Discord client
|
||||||
|
console.log("🔌 Disconnecting from Discord...");
|
||||||
|
this.destroy();
|
||||||
|
|
||||||
|
// Close database
|
||||||
|
console.log("🗄️ Closing database connection...");
|
||||||
|
await closeDatabase();
|
||||||
|
|
||||||
|
console.log("👋 Graceful shutdown complete. Exiting.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers, GatewayIntentBits.DirectMessages] });
|
||||||
77
bot/lib/clientStats.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, test, expect, beforeEach, mock, afterEach } from "bun:test";
|
||||||
|
import { getClientStats, clearStatsCache } from "./clientStats";
|
||||||
|
|
||||||
|
// Mock AuroraClient
|
||||||
|
mock.module("./BotClient", () => ({
|
||||||
|
AuroraClient: {
|
||||||
|
guilds: {
|
||||||
|
cache: {
|
||||||
|
size: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ws: {
|
||||||
|
ping: 42,
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
cache: {
|
||||||
|
size: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
commands: {
|
||||||
|
size: 20,
|
||||||
|
},
|
||||||
|
knownCommands: {
|
||||||
|
size: 20,
|
||||||
|
},
|
||||||
|
lastCommandTimestamp: 1641481200000,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("clientStats", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearStatsCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return client stats", () => {
|
||||||
|
const stats = getClientStats();
|
||||||
|
|
||||||
|
expect(stats.guilds).toBe(5);
|
||||||
|
expect(stats.ping).toBe(42);
|
||||||
|
expect(stats.cachedUsers).toBe(100);
|
||||||
|
expect(stats.commandsRegistered).toBe(20);
|
||||||
|
expect(typeof stats.uptime).toBe("number"); // Can't mock process.uptime easily
|
||||||
|
expect(stats.lastCommandTimestamp).toBe(1641481200000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should cache stats for 30 seconds", () => {
|
||||||
|
const stats1 = getClientStats();
|
||||||
|
const stats2 = getClientStats();
|
||||||
|
|
||||||
|
// Should return same object (cached)
|
||||||
|
expect(stats1).toBe(stats2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should refresh cache after TTL expires", async () => {
|
||||||
|
const stats1 = getClientStats();
|
||||||
|
|
||||||
|
// Wait for cache to expire (simulate by clearing and waiting)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 35));
|
||||||
|
clearStatsCache();
|
||||||
|
|
||||||
|
const stats2 = getClientStats();
|
||||||
|
|
||||||
|
// Should be different objects (new fetch)
|
||||||
|
expect(stats1).not.toBe(stats2);
|
||||||
|
// But values should be the same (mocked client)
|
||||||
|
expect(stats1.guilds).toBe(stats2.guilds);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearStatsCache should invalidate cache", () => {
|
||||||
|
const stats1 = getClientStats();
|
||||||
|
clearStatsCache();
|
||||||
|
const stats2 = getClientStats();
|
||||||
|
|
||||||
|
// Should be different objects
|
||||||
|
expect(stats1).not.toBe(stats2);
|
||||||
|
});
|
||||||
|
});
|
||||||
50
bot/lib/clientStats.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { AuroraClient } from "./BotClient";
|
||||||
|
import type { ClientStats } from "@shared/modules/dashboard/dashboard.types";
|
||||||
|
|
||||||
|
// Cache for client stats (30 second TTL)
|
||||||
|
let cachedStats: ClientStats | null = null;
|
||||||
|
let lastFetchTime: number = 0;
|
||||||
|
const CACHE_TTL_MS = 30 * 1000; // 30 seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Discord client statistics with caching
|
||||||
|
* Respects rate limits by caching for 30 seconds
|
||||||
|
*/
|
||||||
|
export function getClientStats(): ClientStats {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Return cached stats if still valid
|
||||||
|
if (cachedStats && (now - lastFetchTime) < CACHE_TTL_MS) {
|
||||||
|
return cachedStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh stats
|
||||||
|
const stats: ClientStats = {
|
||||||
|
bot: {
|
||||||
|
name: AuroraClient.user?.username || "Aurora",
|
||||||
|
avatarUrl: AuroraClient.user?.displayAvatarURL() || null,
|
||||||
|
status: AuroraClient.user?.presence.activities[0]?.state || AuroraClient.user?.presence.activities[0]?.name || null,
|
||||||
|
},
|
||||||
|
guilds: AuroraClient.guilds.cache.size,
|
||||||
|
ping: AuroraClient.ws.ping,
|
||||||
|
cachedUsers: AuroraClient.users.cache.size,
|
||||||
|
commandsRegistered: AuroraClient.commands.size,
|
||||||
|
commandsKnown: AuroraClient.knownCommands.size,
|
||||||
|
uptime: process.uptime(),
|
||||||
|
lastCommandTimestamp: AuroraClient.lastCommandTimestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
cachedStats = stats;
|
||||||
|
lastFetchTime = now;
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the stats cache (useful for testing)
|
||||||
|
*/
|
||||||
|
export function clearStatsCache(): void {
|
||||||
|
cachedStats = null;
|
||||||
|
lastFetchTime = 0;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { DrizzleClient } from "./DrizzleClient";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
import type { Transaction } from "./types";
|
import type { Transaction } from "@shared/lib/types";
|
||||||
import { isShuttingDown, incrementTransactions, decrementTransactions } from "./shutdown";
|
import { isShuttingDown, incrementTransactions, decrementTransactions } from "./shutdown";
|
||||||
|
|
||||||
export const withTransaction = async <T>(
|
export const withTransaction = async <T>(
|
||||||
@@ -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,6 +1,7 @@
|
|||||||
import { AutocompleteInteraction } from "discord.js";
|
import { AutocompleteInteraction } from "discord.js";
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { logger } from "@lib/logger";
|
import { logger } from "@shared/lib/logger";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles autocomplete interactions for slash commands
|
* Handles autocomplete interactions for slash commands
|
||||||
@@ -16,7 +17,7 @@ export class AutocompleteHandler {
|
|||||||
try {
|
try {
|
||||||
await command.autocomplete(interaction);
|
await command.autocomplete(interaction);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error handling autocomplete for ${interaction.commandName}:`, error);
|
logger.error("bot", `Error handling autocomplete for ${interaction.commandName}`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ import { AuroraClient } from "@/lib/BotClient";
|
|||||||
import { ChatInputCommandInteraction } from "discord.js";
|
import { ChatInputCommandInteraction } from "discord.js";
|
||||||
|
|
||||||
// Mock UserService
|
// Mock UserService
|
||||||
mock.module("@/modules/user/user.service", () => ({
|
mock.module("@shared/modules/user/user.service", () => ({
|
||||||
userService: {
|
userService: {
|
||||||
getOrCreateUser: mock(() => Promise.resolve())
|
getOrCreateUser: mock(() => Promise.resolve())
|
||||||
}
|
}
|
||||||
@@ -56,4 +56,28 @@ describe("CommandHandler", () => {
|
|||||||
expect(executeError).toHaveBeenCalled();
|
expect(executeError).toHaveBeenCalled();
|
||||||
expect(AuroraClient.lastCommandTimestamp).toBeNull();
|
expect(AuroraClient.lastCommandTimestamp).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should block execution when maintenance mode is active", async () => {
|
||||||
|
AuroraClient.maintenanceMode = true;
|
||||||
|
const executeSpy = mock(() => Promise.resolve());
|
||||||
|
AuroraClient.commands.set("maint-test", {
|
||||||
|
data: { name: "maint-test" } as any,
|
||||||
|
execute: executeSpy
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const interaction = {
|
||||||
|
commandName: "maint-test",
|
||||||
|
user: { id: "123", username: "testuser" },
|
||||||
|
reply: mock(() => Promise.resolve())
|
||||||
|
} as unknown as ChatInputCommandInteraction;
|
||||||
|
|
||||||
|
await CommandHandler.handle(interaction);
|
||||||
|
|
||||||
|
expect(executeSpy).not.toHaveBeenCalled();
|
||||||
|
expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
flags: expect.anything()
|
||||||
|
}));
|
||||||
|
|
||||||
|
AuroraClient.maintenanceMode = false; // Reset for other tests
|
||||||
|
});
|
||||||
});
|
});
|
||||||
81
bot/lib/handlers/CommandHandler.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
|
||||||
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
|
import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service";
|
||||||
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
|
import { logger } from "@shared/lib/logger";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles slash command execution
|
||||||
|
* Includes user validation and comprehensive error handling
|
||||||
|
*/
|
||||||
|
export class CommandHandler {
|
||||||
|
static async handle(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
|
const command = AuroraClient.commands.get(interaction.commandName);
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
logger.error("bot", `No command matching ${interaction.commandName} was found.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check maintenance mode
|
||||||
|
if (AuroraClient.maintenanceMode) {
|
||||||
|
const errorEmbed = createErrorEmbed('The bot is currently undergoing maintenance. Please try again later.');
|
||||||
|
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check beta feature access
|
||||||
|
if (command.beta) {
|
||||||
|
const flagName = command.featureFlag || interaction.commandName;
|
||||||
|
let memberRoles: string[] = [];
|
||||||
|
|
||||||
|
if (interaction.member && 'roles' in interaction.member) {
|
||||||
|
const roles = interaction.member.roles;
|
||||||
|
if (typeof roles === 'object' && 'cache' in roles) {
|
||||||
|
memberRoles = [...roles.cache.keys()];
|
||||||
|
} else if (Array.isArray(roles)) {
|
||||||
|
memberRoles = roles;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAccess = await featureFlagsService.hasAccess(flagName, {
|
||||||
|
guildId: interaction.guildId!,
|
||||||
|
userId: interaction.user.id,
|
||||||
|
memberRoles,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
const errorEmbed = createErrorEmbed(
|
||||||
|
"This feature is currently in beta testing and not available to all users. " +
|
||||||
|
"Stay tuned for the official release!",
|
||||||
|
"Beta Feature"
|
||||||
|
);
|
||||||
|
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure user exists in database
|
||||||
|
try {
|
||||||
|
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("bot", "Failed to ensure user exists", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await command.execute(interaction);
|
||||||
|
AuroraClient.lastCommandTimestamp = Date.now();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("bot", `Error executing command ${interaction.commandName}`, error);
|
||||||
|
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
|
||||||
|
|
||||||
|
if (interaction.replied || interaction.deferred) {
|
||||||
|
await interaction.followUp({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
|
||||||
|
} else {
|
||||||
|
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js";
|
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js";
|
||||||
import { logger } from "@lib/logger";
|
|
||||||
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 {
|
||||||
logger.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) {
|
||||||
logger.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
|
||||||
logger.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 ---
|
||||||
{
|
{
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { readdir } from "node:fs/promises";
|
import { readdir } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { Command } from "@lib/types";
|
import type { Command } from "@shared/lib/types";
|
||||||
import { config } from "@lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
import type { LoadResult, LoadError } from "./types";
|
import type { LoadResult, LoadError } from "./types";
|
||||||
import type { Client } from "../BotClient";
|
import type { Client } from "../BotClient";
|
||||||
import { logger } from "@lib/logger";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles loading commands from the file system
|
* Handles loading commands from the file system
|
||||||
@@ -45,7 +45,7 @@ export class CommandLoader {
|
|||||||
await this.loadCommandFile(filePath, reload, result);
|
await this.loadCommandFile(filePath, reload, result);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error reading directory ${dir}:`, error);
|
console.error(`Error reading directory ${dir}:`, error);
|
||||||
result.errors.push({ file: dir, error });
|
result.errors.push({ file: dir, error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ export class CommandLoader {
|
|||||||
const commands = Object.values(commandModule);
|
const commands = Object.values(commandModule);
|
||||||
|
|
||||||
if (commands.length === 0) {
|
if (commands.length === 0) {
|
||||||
logger.warn(`No commands found in ${filePath}`);
|
console.warn(`No commands found in ${filePath}`);
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -71,24 +71,27 @@ 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) {
|
||||||
logger.info(`🚫 Skipping disabled command: ${command.data.name}`);
|
console.log(`🚫 Skipping disabled command: ${command.data.name}`);
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.client.commands.set(command.data.name, command);
|
this.client.commands.set(command.data.name, command);
|
||||||
logger.success(`Loaded command: ${command.data.name}`);
|
console.log(`Loaded command: ${command.data.name}`);
|
||||||
result.loaded++;
|
result.loaded++;
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`Skipping invalid command in ${filePath}`);
|
console.warn(`Skipping invalid command in ${filePath}`);
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to load command from ${filePath}:`, error);
|
console.error(`Failed to load command from ${filePath}:`, error);
|
||||||
result.errors.push({ file: filePath, error });
|
result.errors.push({ file: filePath, error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { readdir } from "node:fs/promises";
|
import { readdir } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { Event } from "@lib/types";
|
import type { Event } from "@shared/lib/types";
|
||||||
import type { LoadResult } from "./types";
|
import type { LoadResult } from "./types";
|
||||||
import type { Client } from "../BotClient";
|
import type { Client } from "../BotClient";
|
||||||
import { logger } from "@lib/logger";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles loading events from the file system
|
* Handles loading events from the file system
|
||||||
@@ -44,7 +44,7 @@ export class EventLoader {
|
|||||||
await this.loadEventFile(filePath, reload, result);
|
await this.loadEventFile(filePath, reload, result);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error reading directory ${dir}:`, error);
|
console.error(`Error reading directory ${dir}:`, error);
|
||||||
result.errors.push({ file: dir, error });
|
result.errors.push({ file: dir, error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,14 +64,14 @@ export class EventLoader {
|
|||||||
} else {
|
} else {
|
||||||
this.client.on(event.name, (...args) => event.execute(...args));
|
this.client.on(event.name, (...args) => event.execute(...args));
|
||||||
}
|
}
|
||||||
logger.success(`Loaded event: ${event.name}`);
|
console.log(`Loaded event: ${event.name}`);
|
||||||
result.loaded++;
|
result.loaded++;
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`Skipping invalid event in ${filePath}`);
|
console.warn(`Skipping invalid event in ${filePath}`);
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to load event from ${filePath}:`, error);
|
console.error(`Failed to load event from ${filePath}:`, error);
|
||||||
result.errors.push({ file: filePath, error });
|
result.errors.push({ file: filePath, error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { logger } from "@lib/logger";
|
|
||||||
|
|
||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
let activeTransactions = 0;
|
let activeTransactions = 0;
|
||||||
@@ -22,7 +22,7 @@ export const waitForTransactions = async (timeoutMs: number = 10000) => {
|
|||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
while (activeTransactions > 0) {
|
while (activeTransactions > 0) {
|
||||||
if (Date.now() - start > timeoutMs) {
|
if (Date.now() - start > timeoutMs) {
|
||||||
logger.warn(`Shutdown timed out waiting for ${activeTransactions} transactions after ${timeoutMs}ms`);
|
console.warn(`Shutdown timed out waiting for ${activeTransactions} transactions after ${timeoutMs}ms`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
@@ -6,13 +6,13 @@ import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction
|
|||||||
const valuesMock = mock((_args: any) => Promise.resolve());
|
const valuesMock = mock((_args: any) => Promise.resolve());
|
||||||
const insertMock = mock(() => ({ values: valuesMock }));
|
const insertMock = mock(() => ({ values: valuesMock }));
|
||||||
|
|
||||||
mock.module("@/lib/DrizzleClient", () => ({
|
mock.module("@shared/db/DrizzleClient", () => ({
|
||||||
DrizzleClient: {
|
DrizzleClient: {
|
||||||
insert: insertMock
|
insert: insertMock
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mock.module("@/db/schema", () => ({
|
mock.module("@db/schema", () => ({
|
||||||
items: "items_schema"
|
items: "items_schema"
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { type Interaction } from "discord.js";
|
import { type Interaction } from "discord.js";
|
||||||
import { items } from "@/db/schema";
|
import { items } from "@db/schema";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
import type { ItemUsageData, ItemEffect } from "@/lib/types";
|
import type { ItemUsageData, ItemEffect } from "@shared/lib/types";
|
||||||
import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
|
import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
|
||||||
import type { DraftItem } from "./item_wizard.types";
|
import type { DraftItem } from "./item_wizard.types";
|
||||||
import { ItemType, EffectType } from "@/lib/constants";
|
import { ItemType, EffectType } from "@shared/lib/constants";
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
|
|
||||||
@@ -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: "",
|
||||||
@@ -241,3 +241,8 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const clearDraftSessions = () => {
|
||||||
|
draftSession.clear();
|
||||||
|
console.log("[ItemWizard] All draft item creation sessions cleared.");
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ItemUsageData } from "@/lib/types";
|
import type { ItemUsageData } from "@shared/lib/types";
|
||||||
|
|
||||||
export interface DraftItem {
|
export interface DraftItem {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from "discord.js";
|
} from "discord.js";
|
||||||
import { createBaseEmbed } from "@lib/embeds";
|
import { createBaseEmbed } from "@lib/embeds";
|
||||||
import type { DraftItem } from "./item_wizard.types";
|
import type { DraftItem } from "./item_wizard.types";
|
||||||
import { ItemType } from "@/lib/constants";
|
import { ItemType } from "@shared/lib/constants";
|
||||||
|
|
||||||
const getItemTypeOptions = () => [
|
const getItemTypeOptions = () => [
|
||||||
{ label: "Material", value: ItemType.MATERIAL, description: "Used for crafting or trading" },
|
{ label: "Material", value: ItemType.MATERIAL, description: "Used for crafting or trading" },
|
||||||
@@ -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,6 +1,6 @@
|
|||||||
import { ButtonInteraction } from "discord.js";
|
import { ButtonInteraction } from "discord.js";
|
||||||
import { lootdropService } from "./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 "@/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { userService } from "@/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;
|
||||||
208
bot/modules/economy/shop.view.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import {
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
AttachmentBuilder,
|
||||||
|
Colors,
|
||||||
|
ContainerBuilder,
|
||||||
|
SectionBuilder,
|
||||||
|
TextDisplayBuilder,
|
||||||
|
MediaGalleryBuilder,
|
||||||
|
MediaGalleryItemBuilder,
|
||||||
|
ThumbnailBuilder,
|
||||||
|
SeparatorBuilder,
|
||||||
|
SeparatorSpacingSize,
|
||||||
|
MessageFlags
|
||||||
|
} from "discord.js";
|
||||||
|
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
|
||||||
|
import { join } from "path";
|
||||||
|
import { existsSync } from "fs";
|
||||||
|
import { LootType, EffectType } from "@shared/lib/constants";
|
||||||
|
import type { LootTableItem } from "@shared/lib/types";
|
||||||
|
|
||||||
|
// Rarity Color Map
|
||||||
|
const RarityColors: Record<string, number> = {
|
||||||
|
"C": Colors.LightGrey,
|
||||||
|
"R": Colors.Blue,
|
||||||
|
"SR": Colors.Purple,
|
||||||
|
"SSR": Colors.Gold,
|
||||||
|
"CURRENCY": Colors.Green,
|
||||||
|
"XP": Colors.Aqua,
|
||||||
|
"NOTHING": Colors.DarkButNotBlack
|
||||||
|
};
|
||||||
|
|
||||||
|
const TitleMap: Record<string, string> = {
|
||||||
|
"C": "📦 Common Items",
|
||||||
|
"R": "📦 Rare Items",
|
||||||
|
"SR": "✨ Super Rare Items",
|
||||||
|
"SSR": "🌟 SSR Items",
|
||||||
|
"CURRENCY": "💰 Currency",
|
||||||
|
"XP": "🔮 Experience",
|
||||||
|
"NOTHING": "💨 Empty"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getShopListingMessage(
|
||||||
|
item: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
formattedPrice: string;
|
||||||
|
iconUrl: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
price: number | bigint;
|
||||||
|
usageData?: any;
|
||||||
|
rarity?: string;
|
||||||
|
},
|
||||||
|
context?: { referencedItems: Map<number, { name: string; rarity: string }> }
|
||||||
|
) {
|
||||||
|
const files: AttachmentBuilder[] = [];
|
||||||
|
let thumbnailUrl = resolveAssetUrl(item.iconUrl);
|
||||||
|
let displayImageUrl = resolveAssetUrl(item.imageUrl);
|
||||||
|
|
||||||
|
// Handle local icon
|
||||||
|
if (item.iconUrl && isLocalAssetUrl(item.iconUrl)) {
|
||||||
|
const iconPath = join(process.cwd(), "bot/assets/graphics", item.iconUrl.replace(/^\/?assets\//, ""));
|
||||||
|
if (existsSync(iconPath)) {
|
||||||
|
const iconName = defaultName(item.iconUrl);
|
||||||
|
files.push(new AttachmentBuilder(iconPath, { name: iconName }));
|
||||||
|
thumbnailUrl = `attachment://${iconName}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle local image
|
||||||
|
if (item.imageUrl && isLocalAssetUrl(item.imageUrl)) {
|
||||||
|
if (item.imageUrl === item.iconUrl && thumbnailUrl?.startsWith("attachment://")) {
|
||||||
|
displayImageUrl = thumbnailUrl;
|
||||||
|
} else {
|
||||||
|
const imagePath = join(process.cwd(), "bot/assets/graphics", item.imageUrl.replace(/^\/?assets\//, ""));
|
||||||
|
if (existsSync(imagePath)) {
|
||||||
|
const imageName = defaultName(item.imageUrl);
|
||||||
|
if (!files.find(f => f.name === imageName)) {
|
||||||
|
files.push(new AttachmentBuilder(imagePath, { name: imageName }));
|
||||||
|
}
|
||||||
|
displayImageUrl = `attachment://${imageName}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const containers: ContainerBuilder[] = [];
|
||||||
|
|
||||||
|
// 1. Main Container
|
||||||
|
const mainContainer = new ContainerBuilder()
|
||||||
|
.setAccentColor(RarityColors[item.rarity || "C"] || Colors.Green);
|
||||||
|
|
||||||
|
// Header Section
|
||||||
|
const infoSection = new SectionBuilder()
|
||||||
|
.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(`# ${item.name}`),
|
||||||
|
new TextDisplayBuilder().setContent(item.description || "_No description available._"),
|
||||||
|
new TextDisplayBuilder().setContent(`### 🏷️ Price: ${item.formattedPrice}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set Thumbnail Accessory if we have an icon
|
||||||
|
if (thumbnailUrl) {
|
||||||
|
infoSection.setThumbnailAccessory(new ThumbnailBuilder().setURL(thumbnailUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
mainContainer.addSectionComponents(infoSection);
|
||||||
|
|
||||||
|
// Media Gallery for additional images (if multiple)
|
||||||
|
const mediaSources: string[] = [];
|
||||||
|
if (thumbnailUrl) mediaSources.push(thumbnailUrl);
|
||||||
|
if (displayImageUrl && displayImageUrl !== thumbnailUrl) mediaSources.push(displayImageUrl);
|
||||||
|
|
||||||
|
if (mediaSources.length > 1) {
|
||||||
|
mainContainer.addMediaGalleryComponents(
|
||||||
|
new MediaGalleryBuilder().addItems(
|
||||||
|
...mediaSources.map(src => new MediaGalleryItemBuilder().setURL(src))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Loot Table (if applicable)
|
||||||
|
if (item.usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX)) {
|
||||||
|
const lootboxEffect = item.usageData.effects.find((e: any) => e.type === EffectType.LOOTBOX);
|
||||||
|
const pool = lootboxEffect.pool as LootTableItem[];
|
||||||
|
const totalWeight = pool.reduce((sum, i) => sum + i.weight, 0);
|
||||||
|
|
||||||
|
mainContainer.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||||
|
mainContainer.addTextDisplayComponents(new TextDisplayBuilder().setContent("## 🎁 Potential Rewards"));
|
||||||
|
|
||||||
|
const groups: Record<string, string[]> = {};
|
||||||
|
for (const drop of pool) {
|
||||||
|
const chance = ((drop.weight / totalWeight) * 100).toFixed(1);
|
||||||
|
let line = "";
|
||||||
|
let rarity = "C";
|
||||||
|
|
||||||
|
switch (drop.type as any) {
|
||||||
|
case LootType.CURRENCY:
|
||||||
|
const currAmount = (drop.minAmount != null && drop.maxAmount != null)
|
||||||
|
? `${drop.minAmount} - ${drop.maxAmount}`
|
||||||
|
: (Array.isArray(drop.amount) ? `${drop.amount[0]} - ${drop.amount[1]}` : drop.amount || 0);
|
||||||
|
line = `**${currAmount} 🪙** (${chance}%)`;
|
||||||
|
rarity = "CURRENCY";
|
||||||
|
break;
|
||||||
|
case LootType.XP:
|
||||||
|
const xpAmount = (drop.minAmount != null && drop.maxAmount != null)
|
||||||
|
? `${drop.minAmount} - ${drop.maxAmount}`
|
||||||
|
: (Array.isArray(drop.amount) ? `${drop.amount[0]} - ${drop.amount[1]}` : drop.amount || 0);
|
||||||
|
line = `**${xpAmount} XP** (${chance}%)`;
|
||||||
|
rarity = "XP";
|
||||||
|
break;
|
||||||
|
case LootType.ITEM:
|
||||||
|
const referencedItems = context?.referencedItems;
|
||||||
|
if (drop.itemId && referencedItems?.has(drop.itemId)) {
|
||||||
|
const i = referencedItems.get(drop.itemId)!;
|
||||||
|
line = `**${i.name}** x${drop.amount || 1} (${chance}%)`;
|
||||||
|
rarity = i.rarity;
|
||||||
|
} else {
|
||||||
|
line = `**Unknown Item** (${chance}%)`;
|
||||||
|
rarity = "C";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case LootType.NOTHING:
|
||||||
|
line = `**Nothing** (${chance}%)`;
|
||||||
|
rarity = "NOTHING";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line) {
|
||||||
|
if (!groups[rarity]) groups[rarity] = [];
|
||||||
|
groups[rarity]!.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = ["SSR", "SR", "R", "C", "CURRENCY", "XP", "NOTHING"];
|
||||||
|
for (const rarity of order) {
|
||||||
|
if (groups[rarity] && groups[rarity]!.length > 0) {
|
||||||
|
mainContainer.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(`### ${TitleMap[rarity] || rarity}`),
|
||||||
|
new TextDisplayBuilder().setContent(groups[rarity]!.join("\n"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purchase Row
|
||||||
|
const buyButton = new ButtonBuilder()
|
||||||
|
.setCustomId(`shop_buy_${item.id}`)
|
||||||
|
.setLabel(`Purchase for ${item.price} 🪙`)
|
||||||
|
.setStyle(ButtonStyle.Success)
|
||||||
|
.setEmoji("🛒");
|
||||||
|
|
||||||
|
mainContainer.addActionRowComponents(
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton)
|
||||||
|
);
|
||||||
|
|
||||||
|
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 "@/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,11 +1,11 @@
|
|||||||
import { levelingService } from "@/modules/leveling/leveling.service";
|
import { levelingService } from "@shared/modules/leveling/leveling.service";
|
||||||
import { economyService } from "@/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 } from "./effect.types";
|
||||||
import type { LootTableItem } from "@/lib/types";
|
import type { LootTableItem } from "@shared/lib/types";
|
||||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { inventory, items } from "@/db/schema";
|
import { inventory, items } from "@db/schema";
|
||||||
import { TimerType, TransactionType, LootType } from "@/lib/constants";
|
import { TimerType, TransactionType, LootType } from "@shared/lib/constants";
|
||||||
|
|
||||||
|
|
||||||
// Helper to extract duration in seconds
|
// Helper to extract duration in seconds
|
||||||
@@ -86,7 +86,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 +100,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 +116,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!`
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,10 +134,21 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
// Try to fetch item name for the message
|
// Try to fetch item name for the message
|
||||||
try {
|
try {
|
||||||
const item = await txFn.query.items.findFirst({
|
const item = await txFn.query.items.findFirst({
|
||||||
where: (items, { eq }) => 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);
|
||||||
@@ -6,8 +6,8 @@ import {
|
|||||||
handleTempRole,
|
handleTempRole,
|
||||||
handleColorRole,
|
handleColorRole,
|
||||||
handleLootbox
|
handleLootbox
|
||||||
} from "./handlers";
|
} from "./effect.handlers";
|
||||||
import type { EffectHandler } from "./types";
|
import type { EffectHandler } from "./effect.types";
|
||||||
|
|
||||||
export const effectHandlers: Record<string, EffectHandler> = {
|
export const effectHandlers: Record<string, EffectHandler> = {
|
||||||
'ADD_XP': handleAddXp,
|
'ADD_XP': handleAddXp,
|
||||||
3
bot/modules/inventory/effect.types.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type { Transaction } from "@shared/lib/types";
|
||||||
|
|
||||||
|
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<any>;
|
||||||
140
bot/modules/inventory/inventory.view.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { EmbedBuilder, AttachmentBuilder } from "discord.js";
|
||||||
|
import type { ItemUsageData } from "@shared/lib/types";
|
||||||
|
import { EffectType } from "@shared/lib/constants";
|
||||||
|
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
|
||||||
|
import { join } from "path";
|
||||||
|
import { existsSync } from "fs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inventory entry with item details
|
||||||
|
*/
|
||||||
|
interface InventoryEntry {
|
||||||
|
quantity: bigint | null;
|
||||||
|
item: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an embed displaying a user's inventory
|
||||||
|
*/
|
||||||
|
export function getInventoryEmbed(items: InventoryEntry[], username: string): EmbedBuilder {
|
||||||
|
const description = items.map(entry => {
|
||||||
|
return `**${entry.item.name}** x${entry.quantity}`;
|
||||||
|
}).join("\n");
|
||||||
|
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTitle(`📦 ${username}'s Inventory`)
|
||||||
|
.setDescription(description)
|
||||||
|
.setColor(0x3498db); // Blue
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an embed showing the results of using an item
|
||||||
|
*/
|
||||||
|
export function getItemUseResultEmbed(results: any[], item?: { name: string, iconUrl: string | null, usageData: any }): { embed: EmbedBuilder, files: AttachmentBuilder[] } {
|
||||||
|
const embed = new EmbedBuilder();
|
||||||
|
const files: AttachmentBuilder[] = [];
|
||||||
|
const otherMessages: string[] = [];
|
||||||
|
let lootResult: any = null;
|
||||||
|
|
||||||
|
for (const res of results) {
|
||||||
|
if (typeof res === 'object' && res.type === 'LOOTBOX_RESULT') {
|
||||||
|
lootResult = res;
|
||||||
|
} else {
|
||||||
|
otherMessages.push(typeof res === 'string' ? `• ${res}` : `• ${JSON.stringify(res)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,4 @@
|
|||||||
import { CaseType } from "@/lib/constants";
|
import { CaseType } from "@shared/lib/constants";
|
||||||
|
|
||||||
export { CaseType };
|
export { CaseType };
|
||||||
|
|
||||||
218
bot/modules/quest/quest.view.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import {
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
ContainerBuilder,
|
||||||
|
TextDisplayBuilder,
|
||||||
|
SeparatorBuilder,
|
||||||
|
SeparatorSpacingSize,
|
||||||
|
MessageFlags
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quest entry with quest details and progress
|
||||||
|
*/
|
||||||
|
interface QuestEntry {
|
||||||
|
progress: number | null;
|
||||||
|
completedAt: Date | null;
|
||||||
|
quest: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
triggerEvent: string;
|
||||||
|
requirements: any;
|
||||||
|
rewards: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available quest interface
|
||||||
|
*/
|
||||||
|
interface AvailableQuest {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
rewards: any;
|
||||||
|
requirements: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color palette for containers
|
||||||
|
const COLORS = {
|
||||||
|
ACTIVE: 0x3498db, // Blue - in progress
|
||||||
|
AVAILABLE: 0x2ecc71, // Green - available
|
||||||
|
COMPLETED: 0xf1c40f // Gold - completed
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats quest rewards object into a human-readable string
|
||||||
|
*/
|
||||||
|
function formatQuestRewards(rewards: { xp?: number, balance?: number }): string {
|
||||||
|
const rewardStr: string[] = [];
|
||||||
|
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
|
||||||
|
if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`);
|
||||||
|
return rewardStr.join(" • ") || "None";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a simple progress bar
|
||||||
|
*/
|
||||||
|
function renderProgressBar(current: number, total: number, size: number = 10): string {
|
||||||
|
const percentage = Math.min(current / total, 1);
|
||||||
|
const progress = Math.round(size * percentage);
|
||||||
|
const empty = size - progress;
|
||||||
|
|
||||||
|
const progressText = "▰".repeat(progress);
|
||||||
|
const emptyText = "▱".repeat(empty);
|
||||||
|
|
||||||
|
return `${progressText}${emptyText} ${Math.round(percentage * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates Components v2 containers for the quest list (active quests only)
|
||||||
|
*/
|
||||||
|
export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuilder[] {
|
||||||
|
// Filter to only show in-progress quests (not completed)
|
||||||
|
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
|
||||||
|
|
||||||
|
const container = new ContainerBuilder()
|
||||||
|
.setAccentColor(COLORS.ACTIVE)
|
||||||
|
.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent("# 📜 Quest Log"),
|
||||||
|
new TextDisplayBuilder().setContent("-# Your active quests")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (activeQuests.length === 0) {
|
||||||
|
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||||
|
container.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent("*You have no active quests. Check available quests!*")
|
||||||
|
);
|
||||||
|
return [container];
|
||||||
|
}
|
||||||
|
|
||||||
|
activeQuests.forEach((entry) => {
|
||||||
|
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||||
|
|
||||||
|
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
||||||
|
const rewardsText = formatQuestRewards(rewards);
|
||||||
|
|
||||||
|
const requirements = entry.quest.requirements as { target?: number };
|
||||||
|
const target = requirements?.target || 1;
|
||||||
|
const progress = entry.progress || 0;
|
||||||
|
const progressBar = renderProgressBar(progress, target);
|
||||||
|
|
||||||
|
container.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(`**${entry.quest.name}**`),
|
||||||
|
new TextDisplayBuilder().setContent(entry.quest.description || "*No description*"),
|
||||||
|
new TextDisplayBuilder().setContent(`📊 ${progressBar} \`${progress}/${target}\` • 🎁 ${rewardsText}`)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [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 }
|
||||||
|
};
|
||||||
|
}
|
||||||