Compare commits
159 Commits
dev
...
47507dd65a
| Author | SHA1 | Date | |
|---|---|---|---|
| 47507dd65a | |||
|
|
e6f94c3e71 | ||
|
|
66af870aa9 | ||
|
|
8047bce755 | ||
|
|
9804456257 | ||
|
|
259b8d6875 | ||
|
|
a2cb684b71 | ||
|
|
9c2098bc46 | ||
|
|
618d973863 | ||
|
|
63f55b6dfd | ||
|
|
ac4025e179 | ||
|
|
ff23f22337 | ||
|
|
292991c605 | ||
|
|
4640cd11a7 | ||
|
|
43a003f641 | ||
|
|
6f4426e49d | ||
|
|
894cad91a8 | ||
|
|
2a1c4e65ae | ||
|
|
022f748517 | ||
|
|
ca392749e3 | ||
|
|
4a1e72c5f3 | ||
|
|
d29a1ec2b7 | ||
|
|
1dd269bf2f | ||
|
|
69186ff3e9 | ||
|
|
b989e807dc | ||
|
|
2e6bdec38c | ||
|
|
a9d5c806ad | ||
|
|
6f73178375 | ||
|
|
dd62336571 | ||
|
|
8280111b66 | ||
|
|
34347f0c63 | ||
|
|
c807fd4fd0 | ||
|
|
47b980eff1 | ||
|
|
bc89ddf7c0 | ||
|
|
606d83a7ae | ||
|
|
3351295bdc | ||
|
|
92cb048a7a | ||
|
|
6ead0c0393 | ||
|
|
278ef4b6b0 | ||
|
|
9a32ab298d | ||
|
|
a2596d4124 | ||
|
|
fbc8952e0a | ||
|
|
d0b4cb80de | ||
|
|
599684cde8 | ||
|
|
5606fb6e2f | ||
|
|
fb260c5beb | ||
|
|
a227e5db59 | ||
|
|
66d5145885 | ||
|
|
2412098536 | ||
|
|
d0c48188b9 | ||
|
|
1523a392c2 | ||
|
|
7d6912cdee | ||
|
|
947bbc10d6 | ||
|
|
2933eaeafc | ||
|
|
77d3fafdce | ||
|
|
10a760edf4 | ||
|
|
a53d30a0b3 | ||
|
|
5420653b2b | ||
|
|
f13ef781b6 | ||
|
|
82a4281f9b | ||
|
|
0dbc532c7e | ||
|
|
953942f563 | ||
|
|
6334275d02 | ||
|
|
f44b053a10 | ||
|
|
fe58380d58 | ||
|
|
64cf47ee03 | ||
|
|
37ac0ee934 | ||
|
|
5ab19bf826 | ||
|
|
42d2313933 | ||
|
|
cddd8cdf57 | ||
|
|
eaaf569f4f | ||
|
|
8c28fe60fc | ||
|
|
6d725b73db | ||
|
|
da048eaad1 | ||
|
|
56da4818dc | ||
|
|
ca443491cb | ||
|
|
345e05f821 | ||
|
|
419059904c | ||
|
|
7698a3abaa | ||
|
|
83984faeae | ||
|
|
2106f06f8f | ||
|
|
16d507991c | ||
|
|
e2aa5ee760 | ||
|
|
e084b6fa4e | ||
|
|
3f6da16f89 | ||
|
|
71de87d3da | ||
|
|
fc7afd7d22 | ||
|
|
fcc82292f2 | ||
|
|
f75cc217e9 | ||
|
|
5c36b9be25 | ||
|
|
eaf97572a4 | ||
|
|
1189483244 | ||
|
|
f39ccee0d3 | ||
|
|
10282a2570 | ||
|
|
a3099b80c5 | ||
|
|
67d6298793 | ||
|
|
808fbef11b | ||
|
|
b833796fb9 | ||
|
|
58ea8b92f1 | ||
|
|
fbd2bd990f | ||
|
|
f859618367 | ||
|
|
b7b1dd87b8 | ||
|
|
f3b6af019d | ||
|
|
0dea266a6d | ||
|
|
fbcac51370 | ||
|
|
75e586cee8 | ||
|
|
6e1e6abf2d | ||
|
|
4a0a2a5878 | ||
|
|
216189b0a4 | ||
|
|
ca1339728a | ||
|
|
5833224ba9 | ||
|
|
65f5dc3721 | ||
|
|
637f0826db | ||
|
|
578987caea | ||
|
|
064efb0ed2 | ||
|
|
4229e5338f | ||
|
|
1f7679e5a1 | ||
|
|
4e228bb7a3 | ||
|
|
95d5202d7f | ||
|
|
6c150f753e | ||
|
|
c881b305f0 | ||
|
|
ae5ef4c802 | ||
|
|
2b365cb96d | ||
|
|
bcbbcaa6a4 | ||
|
|
bdb8456f34 | ||
|
|
acaca46298 | ||
|
|
7b831fa17c | ||
|
|
c128c96aa8 | ||
|
|
d0f53dc37b | ||
|
|
28936a7f7a | ||
|
|
4642cf7f6a | ||
|
|
528a66a7ef | ||
|
|
a97a24f72a | ||
|
|
7bd4d811cd | ||
|
|
2ce768013d | ||
|
|
3c20b23cc1 | ||
|
|
71fefb3a14 | ||
|
|
1d650bb2c7 | ||
|
|
7cf8d68d39 | ||
|
|
83cd33e439 | ||
|
|
34cbea2753 | ||
|
|
ce7d4525b2 | ||
|
|
4ac8b4759e | ||
|
|
56ad5b49cd | ||
|
|
e8f6a56057 | ||
|
|
a7f66a98b9 | ||
|
|
6d54695325 | ||
|
|
8c1f80981b | ||
|
|
3a96b67e89 | ||
|
|
d3ade218ec | ||
|
|
1d4263e178 | ||
|
|
727b63b4dc | ||
|
|
d2edde77e6 | ||
|
|
3acb5304f5 | ||
|
|
9333d6ac6c | ||
|
|
7e986fae5a | ||
|
|
3c81fd8396 | ||
|
|
3984d6112b | ||
|
|
ac6283e60c |
57
.agent/workflows/create-ticket.md
Normal file
57
.agent/workflows/create-ticket.md
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
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: ...
|
||||
53
.agent/workflows/review.md
Normal file
53
.agent/workflows/review.md
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
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: ...
|
||||
50
.agent/workflows/work-on-ticket.md
Normal file
50
.agent/workflows/work-on-ticket.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
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.
|
||||
11
.env.example
11
.env.example
@@ -1,9 +1,12 @@
|
||||
DB_USER=kyoko
|
||||
DB_PASSWORD=kyoko
|
||||
DB_NAME=kyoko
|
||||
DB_USER=aurora
|
||||
DB_PASSWORD=aurora
|
||||
DB_NAME=aurora
|
||||
DB_PORT=5432
|
||||
DB_HOST=db
|
||||
DISCORD_BOT_TOKEN=your-discord-bot-token
|
||||
DISCORD_CLIENT_ID=your-discord-client-id
|
||||
DISCORD_GUILD_ID=your-discord-guild-id
|
||||
DATABASE_URL=postgres://kyoko:kyoko@db:5432/kyoko
|
||||
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
|
||||
|
||||
VPS_USER=your-vps-user
|
||||
VPS_HOST=your-vps-ip
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -4,8 +4,11 @@ db-logs
|
||||
db-data
|
||||
.cursor
|
||||
# dependencies (bun install)
|
||||
|
||||
node_modules
|
||||
|
||||
config/
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
@@ -39,4 +42,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
.DS_Store
|
||||
|
||||
src/db/data
|
||||
src/db/log
|
||||
src/db/log
|
||||
scratchpad/
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
FROM oven/bun:latest AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y git
|
||||
|
||||
# Install dependencies
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
133
README.md
133
README.md
@@ -1,42 +1,119 @@
|
||||
# Kyoko - Discord Rpg
|
||||
# Aurora
|
||||
|
||||
A Discord bot built with [Bun](https://bun.sh), [Discord.js](https://discord.js.org/), and [Drizzle ORM](https://orm.drizzle.team/).
|
||||
> A comprehensive, feature-rich Discord RPG bot built with modern technologies.
|
||||
|
||||
## Architecture
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
This project uses a modular architecture:
|
||||
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.
|
||||
|
||||
- **`src/index.ts`**: Entry point. initializes the client.
|
||||
- **`src/lib/KyokoClient.ts`**: Custom Discord Client wrapper handling command loading and events.
|
||||
- **`src/lib/env.ts`**: **Centralized Environment Configuration**. Validates environment variables using `zod` at startup.
|
||||
- **`src/lib/DrizzleClient.ts`**: Database client instance.
|
||||
- **`src/commands/`**: Command files.
|
||||
- **`src/db/`**: Database schema and migrations.
|
||||
## ✨ Features
|
||||
|
||||
## Setup
|
||||
* **Class System**: Users can join different classes.
|
||||
* **Economy**: Complete economy system with balance, transactions, and daily rewards.
|
||||
* **Inventory & Items**: sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management.
|
||||
* **Leveling**: XP-based leveling system to track user activity and progress.
|
||||
* **Quests**: Quest system with requirements and rewards.
|
||||
* **Trading**: Secure trading system between users.
|
||||
* **Lootdrops**: Random loot drops in channels to engage users.
|
||||
* **Admin Tools**: Administrative commands for server management.
|
||||
|
||||
1. **Install Dependencies**:
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
* **Runtime**: [Bun](https://bun.sh/)
|
||||
* **Framework**: [Discord.js](https://discord.js.org/)
|
||||
* **Database**: [PostgreSQL](https://www.postgresql.org/)
|
||||
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
||||
* **Validation**: [Zod](https://zod.dev/)
|
||||
* **Containerization**: [Docker](https://www.docker.com/)
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
* [Bun](https://bun.sh/) (latest version)
|
||||
* [Docker](https://www.docker.com/) & Docker Compose
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd aurora
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
2. **Environment Variables**:
|
||||
Copy `.env.example` to `.env` (create one if it doesn't exist) and fill in the required values:
|
||||
```env
|
||||
DISCORD_BOT_TOKEN=your_token_here
|
||||
DISCORD_CLIENT_ID=your_client_id
|
||||
DISCORD_GUILD_ID=your_guild_id_optional
|
||||
DATABASE_URL=postgres://user:pass@localhost:5432/db_name
|
||||
```
|
||||
*Note: The app will fail to start if `DISCORD_BOT_TOKEN` or `DATABASE_URL` are missing or invalid.*
|
||||
|
||||
3. **Run Development**:
|
||||
3. **Environment Setup**
|
||||
Copy the example environment file and configure it:
|
||||
```bash
|
||||
bun run dev
|
||||
cp .env.example .env
|
||||
```
|
||||
Edit `.env` with your Discord bot token, Client ID, and database credentials.
|
||||
|
||||
> **Note**: The `DATABASE_URL` in `.env.example` is pre-configured for Docker.
|
||||
|
||||
4. **Start the Database**
|
||||
Run the database service using Docker Compose:
|
||||
```bash
|
||||
docker compose up -d db
|
||||
```
|
||||
|
||||
4. **Database Migrations**:
|
||||
5. **Run Migrations**
|
||||
```bash
|
||||
bun run db:push # Apply schema changes
|
||||
bun run generate # Generate migrations
|
||||
```
|
||||
bun run migrate
|
||||
```
|
||||
OR
|
||||
```bash
|
||||
bun run db:push
|
||||
```
|
||||
|
||||
### Running the Bot
|
||||
|
||||
**Development Mode** (with hot reload):
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
**Production Mode**:
|
||||
Build and run with Docker (recommended):
|
||||
```bash
|
||||
docker compose up -d app
|
||||
```
|
||||
|
||||
## 📜 Scripts
|
||||
|
||||
* `bun run dev`: Start the bot in watch mode.
|
||||
* `bun run generate`: Generate Drizzle migrations.
|
||||
* `bun run migrate`: Apply migrations (via Docker).
|
||||
* `bun run db:push`: Push, schema to DB (via Docker).
|
||||
* `bun run db:studio`: Open Drizzle Studio to inspect the database.
|
||||
* `bun test`: Run tests.
|
||||
|
||||
## 📂 Project Structure
|
||||
|
||||
```
|
||||
├── src
|
||||
│ ├── commands # Slash commands
|
||||
│ ├── events # Discord event handlers
|
||||
│ ├── modules # Feature modules (Economy, Inventory, etc.)
|
||||
│ ├── db # Database schema and connection
|
||||
│ └── lib # Shared utilities
|
||||
├── drizzle # Drizzle migration files
|
||||
├── config # Configuration files
|
||||
└── scripts # Utility scripts
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
@@ -1,29 +1,31 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
container_name: kyoko_db
|
||||
container_name: aurora_db
|
||||
environment:
|
||||
- POSTGRES_USER=${DB_USER}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- POSTGRES_DB=${DB_NAME}
|
||||
ports:
|
||||
- "${DB_PORT}:5432"
|
||||
- "127.0.0.1:${DB_PORT}:5432"
|
||||
volumes:
|
||||
- ./src/db/data:/var/lib/postgresql/data
|
||||
- ./src/db/log:/var/log/postgresql
|
||||
app:
|
||||
container_name: kyoko_app
|
||||
image: kyoko-app
|
||||
container_name: aurora_app
|
||||
restart: unless-stopped
|
||||
image: aurora-app
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
working_dir: /app
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "127.0.0.1:3000:3000"
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- HOST=0.0.0.0
|
||||
- DB_USER=${DB_USER}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- DB_NAME=${DB_NAME}
|
||||
@@ -38,14 +40,14 @@ services:
|
||||
command: bun run dev
|
||||
|
||||
studio:
|
||||
container_name: kyoko_studio
|
||||
image: kyoko-app
|
||||
container_name: aurora_studio
|
||||
image: aurora-app
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
working_dir: /app
|
||||
ports:
|
||||
- "4983:4983"
|
||||
- "127.0.0.1:4983:4983"
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
|
||||
63
docs/COMMANDS.md
Normal file
63
docs/COMMANDS.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Command Reference
|
||||
|
||||
This document lists all available slash commands in Aurora, categorized by their function.
|
||||
|
||||
## Economy
|
||||
|
||||
| Command | Description | Options | Permissions |
|
||||
|---|---|---|---|
|
||||
| `/balance` | View your or another user's balance. | `user` (Optional): The user to check. | Everyone |
|
||||
| `/daily` | Claim your daily currency reward and streak bonus. | None | Everyone |
|
||||
| `/pay` | Transfer currency to another user. | `user` (Required): Recipient.<br>`amount` (Required): Amount to send. | Everyone |
|
||||
| `/trade` | Start a trade session with another user. | `user` (Required): The user to trade with. | Everyone |
|
||||
| `/exam` | Take your weekly exam to earn rewards based on XP gain. | None | Everyone |
|
||||
|
||||
## Inventory & Items
|
||||
|
||||
| Command | Description | Options | Permissions |
|
||||
|---|---|---|---|
|
||||
| `/inventory` | View your or another user's inventory. | `user` (Optional): The user to check. | Everyone |
|
||||
| `/use` | Use an item from your inventory. | `item` (Required): The item to use (Autocomplete). | Everyone |
|
||||
|
||||
## User & Social
|
||||
|
||||
| Command | Description | Options | Permissions |
|
||||
|---|---|---|---|
|
||||
| `/profile` | View your or another user's Student ID card. | `user` (Optional): The user to view. | Everyone |
|
||||
| `/leaderboard` | View top players. | `type` (Required): 'Level / XP' or 'Balance'. | Everyone |
|
||||
| `/feedback` | Submit feedback, bug reports, or suggestions. | None | Everyone |
|
||||
| `/quests` | View your active quests. | None | Everyone |
|
||||
|
||||
## Admin
|
||||
|
||||
> [!IMPORTANT]
|
||||
> These commands require Administrator permissions or specific roles as configured.
|
||||
|
||||
### General Management
|
||||
| Command | Description | Options |
|
||||
|---|---|---|
|
||||
| `/config` | Manage bot configuration. | `group` (Req): Section.<br>`key` (Req): Setting.<br>`value` (Req): New value. |
|
||||
| `/refresh` | Refresh commands or configuration cache. | `type`: 'Commands' or 'Config'. |
|
||||
| `/update` | Update the bot from the repository. | None |
|
||||
| `/features` | Enable/Disable system features. | `feature` (Req): Feature name.<br>`enabled` (Req): True/False. |
|
||||
| `/webhook` | Send a message via webhook. | `payload` (Req): JSON payload. |
|
||||
|
||||
### Moderation
|
||||
| Command | Description | Options |
|
||||
|---|---|---|
|
||||
| `/warn` | Warn a user. | `user` (Req): Target.<br>`reason` (Req): Reason. |
|
||||
| `/warnings` | View active warnings for a user. | `user` (Req): Target. |
|
||||
| `/clearwarning`| Clear a specific warning. | `case_id` (Req): Case ID. |
|
||||
| `/case` | View details of a specific moderation case. | `case_id` (Req): Case ID. |
|
||||
| `/cases` | View moderation history for a user. | `user` (Req): Target. |
|
||||
| `/note` | Add a note to a user. | `user` (Req): Target.<br>`note` (Req): Content. |
|
||||
| `/notes` | View notes for a user. | `user` (Req): Target. |
|
||||
| `/prune` | Bulk delete messages. | `amount` (Req): Number (1-100). |
|
||||
|
||||
### Game Admin
|
||||
| Command | Description | Options |
|
||||
|---|---|---|
|
||||
| `/create_item` | Create a new item in the database. | (Modal based interaction) |
|
||||
| `/create_color`| Create a new color role. | `name` (Req): Role name.<br>`hex` (Req): Hex color code. |
|
||||
| `/listing` | Manage shop listings (Admin view). | None (Context sensitive?) |
|
||||
| `/terminal` | Control the terminal display channel. | `action`: 'setup', 'update', 'clear'. |
|
||||
160
docs/CONFIGURATION.md
Normal file
160
docs/CONFIGURATION.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Configuration Guide
|
||||
|
||||
This document outlines the structure and available options for the `config/config.json` file. The configuration is validated using Zod schemas at runtime (see `src/lib/config.ts`).
|
||||
|
||||
## Core Structure
|
||||
|
||||
### Leveling
|
||||
Configuration for the XP and leveling system.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `base` | `number` | The base XP required for the first level. |
|
||||
| `exponent` | `number` | The exponent used to calculate XP curves. |
|
||||
| `chat.cooldownMs` | `number` | Time in milliseconds between XP gains from chat. |
|
||||
| `chat.minXp` | `number` | Minimum XP awarded per message. |
|
||||
| `chat.maxXp` | `number` | Maximum XP awarded per message. |
|
||||
|
||||
### Economy
|
||||
Settings for currency, rewards, and transfers.
|
||||
|
||||
#### Daily
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `amount` | `integer` | Base amount granted by `/daily`. |
|
||||
| `streakBonus` | `integer` | Bonus amount per streak day. |
|
||||
| `weeklyBonus` | `integer` | Bonus amount for a 7-day streak. |
|
||||
| `cooldownMs` | `number` | Cooldown period for the command (usually 24h). |
|
||||
|
||||
#### Transfers
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `allowSelfTransfer` | `boolean` | Whether users can transfer money to themselves. |
|
||||
| `minAmount` | `integer` | Minimum amount required for a transfer. |
|
||||
|
||||
#### Exam
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `multMin` | `number` | Minimum multiplier for exam rewards. |
|
||||
| `multMax` | `number` | Maximum multiplier for exam rewards. |
|
||||
|
||||
### Inventory
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `maxStackSize` | `integer` | Maximum count of a single item in one slot. |
|
||||
| `maxSlots` | `number` | Total number of inventory slots available. |
|
||||
|
||||
### Lootdrop
|
||||
Settings for the random chat loot drop events.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `activityWindowMs` | `number` | Time window to track activity for spawning drops. |
|
||||
| `minMessages` | `number` | Minimum messages required in window to trigger drop. |
|
||||
| `spawnChance` | `number` | Probability (0-1) of a drop spawning when conditions met. |
|
||||
| `cooldownMs` | `number` | Minimum time between loot drops. |
|
||||
| `reward.min` | `number` | Minimum currency reward. |
|
||||
| `reward.max` | `number` | Maximum currency reward. |
|
||||
| `reward.currency` | `string` | The currency ID/Symbol used for rewards. |
|
||||
|
||||
### Roles
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `studentRole` | `string` | Discord Role ID for students. |
|
||||
| `visitorRole` | `string` | Discord Role ID for visitors. |
|
||||
| `colorRoles` | `string[]` | List of Discord Role IDs available as color roles. |
|
||||
|
||||
### Moderation
|
||||
Automated moderation settings.
|
||||
|
||||
#### Prune
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `maxAmount` | `number` | Maximum messages to delete in one go. |
|
||||
| `confirmThreshold` | `number` | Amount above which confirmation is required. |
|
||||
| `batchSize` | `number` | Size of delete batches. |
|
||||
| `batchDelayMs` | `number` | Delay between batches. |
|
||||
|
||||
#### Cases
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `dmOnWarn` | `boolean` | Whether to DM users when they are warned. |
|
||||
| `logChannelId` | `string` | (Optional) Channel ID for moderation logs. |
|
||||
| `autoTimeoutThreshold` | `number` | (Optional) Warn count to trigger auto-timeout. |
|
||||
|
||||
### System & Misc
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `commands` | `Object` | Map of command names (keys) to boolean (values) to enable/disable them. |
|
||||
| `welcomeChannelId` | `string` | (Optional) Channel ID for welcome messages. |
|
||||
| `welcomeMessage` | `string` | (Optional) Custom welcome message text. |
|
||||
| `feedbackChannelId` | `string` | (Optional) Channel ID where feedback is posted. |
|
||||
| `terminal.channelId` | `string` | (Optional) Channel ID for terminal display. |
|
||||
| `terminal.messageId` | `string` | (Optional) Message ID for terminal display. |
|
||||
|
||||
## Example Config
|
||||
|
||||
```json
|
||||
{
|
||||
"leveling": {
|
||||
"base": 100,
|
||||
"exponent": 1.5,
|
||||
"chat": {
|
||||
"cooldownMs": 60000,
|
||||
"minXp": 15,
|
||||
"maxXp": 25
|
||||
}
|
||||
},
|
||||
"economy": {
|
||||
"daily": {
|
||||
"amount": "100",
|
||||
"streakBonus": "10",
|
||||
"weeklyBonus": "500",
|
||||
"cooldownMs": 86400000
|
||||
},
|
||||
"transfers": {
|
||||
"allowSelfTransfer": false,
|
||||
"minAmount": "10"
|
||||
},
|
||||
"exam": {
|
||||
"multMin": 1.0,
|
||||
"multMax": 2.0
|
||||
}
|
||||
},
|
||||
"inventory": {
|
||||
"maxStackSize": "99",
|
||||
"maxSlots": 20
|
||||
},
|
||||
"lootdrop": {
|
||||
"activityWindowMs": 300000,
|
||||
"minMessages": 10,
|
||||
"spawnChance": 0.05,
|
||||
"cooldownMs": 3600000,
|
||||
"reward": {
|
||||
"min": 50,
|
||||
"max": 150,
|
||||
"currency": "CREDITS"
|
||||
}
|
||||
},
|
||||
"commands": {
|
||||
"example": true
|
||||
},
|
||||
"studentRole": "123456789012345678",
|
||||
"visitorRole": "123456789012345678",
|
||||
"colorRoles": [],
|
||||
"moderation": {
|
||||
"prune": {
|
||||
"maxAmount": 100,
|
||||
"confirmThreshold": 50,
|
||||
"batchSize": 100,
|
||||
"batchDelayMs": 1000
|
||||
},
|
||||
"cases": {
|
||||
"dmOnWarn": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Fields marked as `integer` or `bigint` in the types can often be provided as strings in the JSON to ensure precision, but the system handles parsing them.
|
||||
149
docs/DATABASE.md
Normal file
149
docs/DATABASE.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Database Schema
|
||||
|
||||
This document outlines the database schema for the Aurora project. The database is PostgreSQL, managed via Drizzle ORM.
|
||||
|
||||
## Tables
|
||||
|
||||
### Users (`users`)
|
||||
Stores user data, economy, and progression.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `bigint` | Primary Key. Discord User ID. |
|
||||
| `class_id` | `bigint` | Foreign Key -> `classes.id`. |
|
||||
| `username` | `varchar(255)` | User's Discord username. |
|
||||
| `is_active` | `boolean` | Whether the user is active (default: true). |
|
||||
| `balance` | `bigint` | User's currency balance. |
|
||||
| `xp` | `bigint` | User's experience points. |
|
||||
| `level` | `integer` | User's level. |
|
||||
| `daily_streak` | `integer` | Current streak of daily command usage. |
|
||||
| `settings` | `jsonb` | User-specific settings. |
|
||||
| `created_at` | `timestamp` | Record creation time. |
|
||||
| `updated_at` | `timestamp` | Last update time. |
|
||||
|
||||
### Classes (`classes`)
|
||||
Available character classes.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `bigint` | Primary Key. Custom ID. |
|
||||
| `name` | `varchar(255)` | Class name (Unique). |
|
||||
| `balance` | `bigint` | Class bank balance (shared/flavor). |
|
||||
| `role_id` | `varchar(255)` | Discord Role ID associated with the class. |
|
||||
|
||||
### Items (`items`)
|
||||
Definitions of items available in the game.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `serial` | Primary Key. Auto-incrementing ID. |
|
||||
| `name` | `varchar(255)` | Item name (Unique). |
|
||||
| `description` | `text` | Item description. |
|
||||
| `rarity` | `varchar(20)` | Common, Rare, etc. Default: 'Common'. |
|
||||
| `type` | `varchar(50)` | MATERIAL, CONSUMABLE, EQUIPMENT, etc. |
|
||||
| `usage_data` | `jsonb` | Effect data for consumables/usables. |
|
||||
| `price` | `bigint` | Base value of the item. |
|
||||
| `icon_url` | `text` | URL for the item's icon. |
|
||||
| `image_url` | `text` | URL for the item's large image. |
|
||||
|
||||
### Inventory (`inventory`)
|
||||
Items held by users.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `user_id` | `bigint` | PK/FK -> `users.id`. |
|
||||
| `item_id` | `integer` | PK/FK -> `items.id`. |
|
||||
| `quantity` | `bigint` | Amount held. Must be > 0. |
|
||||
|
||||
### Transactions (`transactions`)
|
||||
Currency transaction history.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `bigserial` | Primary Key. |
|
||||
| `user_id` | `bigint` | FK -> `users.id`. The user affecting the balance. |
|
||||
| `related_user_id` | `bigint` | FK -> `users.id`. The other party (if any). |
|
||||
| `amount` | `bigint` | Amount transferred. |
|
||||
| `type` | `varchar(50)` | Transaction type identifier. |
|
||||
| `description` | `text` | Human-readable description. |
|
||||
| `created_at` | `timestamp` | Time of transaction. |
|
||||
|
||||
### Item Transactions (`item_transactions`)
|
||||
Item flow history.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `bigserial` | Primary Key. |
|
||||
| `user_id` | `bigint` | FK -> `users.id`. |
|
||||
| `related_user_id` | `bigint` | FK -> `users.id`. |
|
||||
| `item_id` | `integer` | FK -> `items.id`. |
|
||||
| `quantity` | `bigint` | Amount gained (+) or lost (-). |
|
||||
| `type` | `varchar(50)` | TRADE, SHOP_BUY, DROP, etc. |
|
||||
| `description` | `text` | Description. |
|
||||
| `created_at` | `timestamp` | Time of transaction. |
|
||||
|
||||
### Quests (`quests`)
|
||||
Quest definitions.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `serial` | Primary Key. |
|
||||
| `name` | `varchar(255)` | Quest title. |
|
||||
| `description` | `text` | Quest text. |
|
||||
| `trigger_event` | `varchar(50)` | Event that triggers progress checks. |
|
||||
| `requirements` | `jsonb` | Completion criteria. |
|
||||
| `rewards` | `jsonb` | Rewards for completion. |
|
||||
|
||||
### User Quests (`user_quests`)
|
||||
User progress on quests.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `user_id` | `bigint` | PK/FK -> `users.id`. |
|
||||
| `quest_id` | `integer` | PK/FK -> `quests.id`. |
|
||||
| `progress` | `integer` | Current progress value. |
|
||||
| `completed_at` | `timestamp` | Completion time (null if active). |
|
||||
|
||||
### User Timers (`user_timers`)
|
||||
Generic timers for cooldowns, temporary effects, etc.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `user_id` | `bigint` | PK/FK -> `users.id`. |
|
||||
| `type` | `varchar(50)` | PK. Timer type (COOLDOWN, EFFECT, ACCESS). |
|
||||
| `key` | `varchar(100)` | PK. specific ID (e.g. 'daily'). |
|
||||
| `expires_at` | `timestamp` | When the timer expires. |
|
||||
| `metadata` | `jsonb` | Extra data. |
|
||||
|
||||
### Lootdrops (`lootdrops`)
|
||||
Active chat loot drop events.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `message_id` | `varchar(255)` | Primary Key. Discord Message ID. |
|
||||
| `channel_id` | `varchar(255)` | Discord Channel ID. |
|
||||
| `reward_amount` | `integer` | Currency amount. |
|
||||
| `currency` | `varchar(50)` | Currency type constant. |
|
||||
| `claimed_by` | `bigint` | FK -> `users.id`. Null if unclaimed. |
|
||||
| `created_at` | `timestamp` | Spawn time. |
|
||||
| `expires_at` | `timestamp` | Despawn time. |
|
||||
|
||||
### Moderation Cases (`moderation_cases`)
|
||||
History of moderation actions.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `bigserial` | Primary Key. |
|
||||
| `case_id` | `varchar(50)` | Unique friendly ID. |
|
||||
| `type` | `varchar(20)` | warn, timeout, kick, ban, etc. |
|
||||
| `user_id` | `bigint` | Target user ID. |
|
||||
| `username` | `varchar(255)` | Target username snapshot. |
|
||||
| `moderator_id` | `bigint` | Acting moderator ID. |
|
||||
| `moderator_name` | `varchar(255)` | Moderator username snapshot. |
|
||||
| `reason` | `text` | Reason for action. |
|
||||
| `metadata` | `jsonb` | Extra data. |
|
||||
| `active` | `boolean` | Is this case active? |
|
||||
| `created_at` | `timestamp` | Creation time. |
|
||||
| `resolved_at` | `timestamp` | Resolution/Expiration time. |
|
||||
| `resolved_by` | `bigint` | User ID who resolved it. |
|
||||
| `resolved_reason` | `text` | Reason for resolution. |
|
||||
127
docs/LOOTBOX_GUIDE.md
Normal file
127
docs/LOOTBOX_GUIDE.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Lootbox Creation Guide
|
||||
|
||||
Currently, the Item Wizard does not support creating **Lootbox** items directly. Instead, they must be inserted manually into the database. This guide details the required JSON structure for the `LOOTBOX` effect.
|
||||
|
||||
## Item Structure
|
||||
|
||||
To create a lootbox, you need to insert a row into the `items` table. The critical part is the `usageData` JSON column.
|
||||
|
||||
```json
|
||||
{
|
||||
"consume": true,
|
||||
"effects": [
|
||||
{
|
||||
"type": "LOOTBOX",
|
||||
"pool": [ ... ]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Loot Table Structure
|
||||
|
||||
The `pool` property is an array of `LootTableItem` objects. A random item is selected based on the total `weight` of all items in the pool.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `type` | `string` | One of: `CURRENCY`, `ITEM`, `XP`, `NOTHING`. |
|
||||
| `weight` | `number` | The relative probability weight of this outcome. |
|
||||
| `message` | `string` | (Optional) Custom message to display when this outcome is selected. |
|
||||
|
||||
### Outcome Types
|
||||
|
||||
#### 1. Currency
|
||||
Gives the user coins.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "CURRENCY",
|
||||
"weight": 50,
|
||||
"amount": 100, // Fixed amount OR
|
||||
"minAmount": 50, // Minimum random amount
|
||||
"maxAmount": 150 // Maximum random amount
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. XP
|
||||
Gives the user Experience Points.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "XP",
|
||||
"weight": 30,
|
||||
"amount": 500 // Fixed amount OR range (minAmount/maxAmount)
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Item
|
||||
Gives the user another item (by ID).
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ITEM",
|
||||
"weight": 10,
|
||||
"itemId": 42, // The ID of the item to give
|
||||
"amount": 1 // (Optional) Quantity to give, default 1
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Nothing
|
||||
An empty roll.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "NOTHING",
|
||||
"weight": 10,
|
||||
"message": "The box was empty! Better luck next time."
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
Here is a full SQL insert example (using a hypothetical SQL client or Drizzle studio) for a "Basic Lootbox":
|
||||
|
||||
**Name**: Basic Lootbox
|
||||
**Type**: CONSUMABLE
|
||||
**Effect**:
|
||||
- 50% chance for 100-200 Coins
|
||||
- 30% chance for 500 XP
|
||||
- 10% chance for Item ID 5 (e.g. Rare Gem)
|
||||
- 10% chance for Nothing
|
||||
|
||||
**JSON for `usageData`**:
|
||||
```json
|
||||
{
|
||||
"consume": true,
|
||||
"effects": [
|
||||
{
|
||||
"type": "LOOTBOX",
|
||||
"pool": [
|
||||
{
|
||||
"type": "CURRENCY",
|
||||
"weight": 50,
|
||||
"minAmount": 100,
|
||||
"maxAmount": 200
|
||||
},
|
||||
{
|
||||
"type": "XP",
|
||||
"weight": 30,
|
||||
"amount": 500
|
||||
},
|
||||
{
|
||||
"type": "ITEM",
|
||||
"weight": 10,
|
||||
"itemId": 5,
|
||||
"amount": 1,
|
||||
"message": "Startstruck! You found a Rare Gem!"
|
||||
},
|
||||
{
|
||||
"type": "NOTHING",
|
||||
"weight": 10,
|
||||
"message": "It's empty..."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
72
docs/MODULE_STRUCTURE.md
Normal file
72
docs/MODULE_STRUCTURE.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Aurora Module Structure Guide
|
||||
|
||||
This guide documents the standard module organization patterns used in the Aurora codebase. Following these patterns ensures consistency, maintainability, and clear separation of concerns.
|
||||
|
||||
## Module Anatomy
|
||||
|
||||
A typical module in `@modules/` is organized into several files, each with a specific responsibility.
|
||||
|
||||
Example: `trade` module
|
||||
- `trade.service.ts`: Business logic and data access.
|
||||
- `trade.view.ts`: Discord UI components (embeds, modals, select menus).
|
||||
- `trade.interaction.ts`: Handler for interaction events (buttons, modals, etc.).
|
||||
- `trade.types.ts`: TypeScript interfaces and types.
|
||||
- `trade.service.test.ts`: Unit tests for the service logic.
|
||||
|
||||
## File Responsibilities
|
||||
|
||||
### 1. Service (`*.service.ts`)
|
||||
The core of the module. It contains the business logic, database interactions (using Drizzle), and state management.
|
||||
- **Rules**:
|
||||
- Export a singleton instance: `export const tradeService = new TradeService();`
|
||||
- Should not contain Discord-specific rendering logic (return data, not embeds).
|
||||
- Throw `UserError` for validation issues that should be shown to the user.
|
||||
|
||||
### 2. View (`*.view.ts`)
|
||||
Handles the creation of Discord-specific UI elements like `EmbedBuilder`, `ActionRowBuilder`, and `ModalBuilder`.
|
||||
- **Rules**:
|
||||
- Focus on formatting and presentation.
|
||||
- Takes raw data (from services) and returns Discord components.
|
||||
|
||||
### 3. Interaction Handler (`*.interaction.ts`)
|
||||
The entry point for Discord component interactions (buttons, select menus, modals).
|
||||
- **Rules**:
|
||||
- Export a single handler function: `export async function handleTradeInteraction(interaction: Interaction) { ... }`
|
||||
- Routes internal `customId` patterns to specific logic.
|
||||
- Relies on `ComponentInteractionHandler` for centralized error handling.
|
||||
- **No local try-catch** for standard validation errors; let them bubble up as `UserError`.
|
||||
|
||||
### 4. Types (`*.types.ts`)
|
||||
Central location for module-specific TypeScript types and constants.
|
||||
- **Rules**:
|
||||
- Define interfaces for complex data structures.
|
||||
- Use enums or literal types for states and custom IDs.
|
||||
|
||||
## Interaction Routing
|
||||
|
||||
All interaction handlers must be registered in `src/lib/interaction.routes.ts`.
|
||||
|
||||
```typescript
|
||||
{
|
||||
predicate: (i) => i.customId.startsWith("module_"),
|
||||
handler: () => import("@/modules/module/module.interaction"),
|
||||
method: 'handleModuleInteraction'
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling Standards
|
||||
|
||||
Aurora uses a centralized error handling pattern in `ComponentInteractionHandler`.
|
||||
|
||||
1. **UserError**: Use this for validation errors or issues the user can fix (e.g., "Insufficient funds").
|
||||
- `throw new UserError("You need more coins!");`
|
||||
2. **SystemError / Generic Error**: Use this for unexpected system failures.
|
||||
- These are logged to the console/logger and show a generic "Unexpected error" message to the user.
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- **Directory Name**: Lowercase, singular (e.g., `trade`, `inventory`).
|
||||
- **File Names**: `moduleName.type.ts` (e.g., `trade.service.ts`).
|
||||
- **Class Names**: PascalCase (e.g., `TradeService`).
|
||||
- **Service Instances**: camelCase (e.g., `tradeService`).
|
||||
- **Interaction Method**: `handle[ModuleName]Interaction`.
|
||||
113
drizzle/0000_fixed_tomas.sql
Normal file
113
drizzle/0000_fixed_tomas.sql
Normal file
@@ -0,0 +1,113 @@
|
||||
CREATE TABLE "classes" (
|
||||
"id" bigint PRIMARY KEY NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"balance" bigint DEFAULT 0,
|
||||
"role_id" varchar(255),
|
||||
CONSTRAINT "classes_name_unique" UNIQUE("name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "inventory" (
|
||||
"user_id" bigint NOT NULL,
|
||||
"item_id" integer NOT NULL,
|
||||
"quantity" bigint DEFAULT 1,
|
||||
CONSTRAINT "inventory_user_id_item_id_pk" PRIMARY KEY("user_id","item_id"),
|
||||
CONSTRAINT "quantity_check" CHECK ("inventory"."quantity" > 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "item_transactions" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"user_id" bigint NOT NULL,
|
||||
"related_user_id" bigint,
|
||||
"item_id" integer NOT NULL,
|
||||
"quantity" bigint NOT NULL,
|
||||
"type" varchar(50) NOT NULL,
|
||||
"description" text,
|
||||
"created_at" timestamp with time zone DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "items" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"description" text,
|
||||
"rarity" varchar(20) DEFAULT 'Common',
|
||||
"type" varchar(50) DEFAULT 'MATERIAL' NOT NULL,
|
||||
"usage_data" jsonb DEFAULT '{}'::jsonb,
|
||||
"price" bigint,
|
||||
"icon_url" text NOT NULL,
|
||||
"image_url" text NOT NULL,
|
||||
CONSTRAINT "items_name_unique" UNIQUE("name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "lootdrops" (
|
||||
"message_id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"channel_id" varchar(255) NOT NULL,
|
||||
"reward_amount" integer NOT NULL,
|
||||
"currency" varchar(50) NOT NULL,
|
||||
"claimed_by" bigint,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"expires_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "quests" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"description" text,
|
||||
"trigger_event" varchar(50) NOT NULL,
|
||||
"requirements" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"rewards" jsonb DEFAULT '{}'::jsonb NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "transactions" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"user_id" bigint,
|
||||
"related_user_id" bigint,
|
||||
"amount" bigint NOT NULL,
|
||||
"type" varchar(50) NOT NULL,
|
||||
"description" text,
|
||||
"created_at" timestamp with time zone DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user_quests" (
|
||||
"user_id" bigint NOT NULL,
|
||||
"quest_id" integer NOT NULL,
|
||||
"progress" integer DEFAULT 0,
|
||||
"completed_at" timestamp with time zone,
|
||||
CONSTRAINT "user_quests_user_id_quest_id_pk" PRIMARY KEY("user_id","quest_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user_timers" (
|
||||
"user_id" bigint NOT NULL,
|
||||
"type" varchar(50) NOT NULL,
|
||||
"key" varchar(100) NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb,
|
||||
CONSTRAINT "user_timers_user_id_type_key_pk" PRIMARY KEY("user_id","type","key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" bigint PRIMARY KEY NOT NULL,
|
||||
"class_id" bigint,
|
||||
"username" varchar(255) NOT NULL,
|
||||
"is_active" boolean DEFAULT true,
|
||||
"balance" bigint DEFAULT 0,
|
||||
"xp" bigint DEFAULT 0,
|
||||
"level" integer DEFAULT 1,
|
||||
"daily_streak" integer DEFAULT 0,
|
||||
"settings" jsonb DEFAULT '{}'::jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now(),
|
||||
"updated_at" timestamp with time zone DEFAULT now(),
|
||||
CONSTRAINT "users_username_unique" UNIQUE("username")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "inventory" ADD CONSTRAINT "inventory_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "inventory" ADD CONSTRAINT "inventory_item_id_items_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "item_transactions" ADD CONSTRAINT "item_transactions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "item_transactions" ADD CONSTRAINT "item_transactions_related_user_id_users_id_fk" FOREIGN KEY ("related_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "item_transactions" ADD CONSTRAINT "item_transactions_item_id_items_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "lootdrops" ADD CONSTRAINT "lootdrops_claimed_by_users_id_fk" FOREIGN KEY ("claimed_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_related_user_id_users_id_fk" FOREIGN KEY ("related_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_quests" ADD CONSTRAINT "user_quests_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_quests" ADD CONSTRAINT "user_quests_quest_id_quests_id_fk" FOREIGN KEY ("quest_id") REFERENCES "public"."quests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_timers" ADD CONSTRAINT "user_timers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_class_id_classes_id_fk" FOREIGN KEY ("class_id") REFERENCES "public"."classes"("id") ON DELETE no action ON UPDATE no action;
|
||||
17
drizzle/0001_heavy_thundra.sql
Normal file
17
drizzle/0001_heavy_thundra.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE "moderation_cases" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"case_id" varchar(50) NOT NULL,
|
||||
"type" varchar(20) NOT NULL,
|
||||
"user_id" bigint NOT NULL,
|
||||
"username" varchar(255) NOT NULL,
|
||||
"moderator_id" bigint NOT NULL,
|
||||
"moderator_name" varchar(255) NOT NULL,
|
||||
"reason" text NOT NULL,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb,
|
||||
"active" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"resolved_at" timestamp with time zone,
|
||||
"resolved_by" bigint,
|
||||
"resolved_reason" text,
|
||||
CONSTRAINT "moderation_cases_case_id_unique" UNIQUE("case_id")
|
||||
);
|
||||
8
drizzle/0002_fancy_forge.sql
Normal file
8
drizzle/0002_fancy_forge.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
CREATE INDEX "moderation_cases_user_id_idx" ON "moderation_cases" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "moderation_cases_case_id_idx" ON "moderation_cases" USING btree ("case_id");--> statement-breakpoint
|
||||
CREATE INDEX "transactions_created_at_idx" ON "transactions" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX "user_timers_expires_at_idx" ON "user_timers" USING btree ("expires_at");--> statement-breakpoint
|
||||
CREATE INDEX "user_timers_lookup_idx" ON "user_timers" USING btree ("user_id","type","key");--> statement-breakpoint
|
||||
CREATE INDEX "users_username_idx" ON "users" USING btree ("username");--> statement-breakpoint
|
||||
CREATE INDEX "users_balance_idx" ON "users" USING btree ("balance");--> statement-breakpoint
|
||||
CREATE INDEX "users_level_xp_idx" ON "users" USING btree ("level","xp");
|
||||
770
drizzle/meta/0000_snapshot.json
Normal file
770
drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,770 @@
|
||||
{
|
||||
"id": "d43c3f7b-afe5-4974-ab67-fcd69256f3d8",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.classes": {
|
||||
"name": "classes",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigint",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"balance": {
|
||||
"name": "balance",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "0"
|
||||
},
|
||||
"role_id": {
|
||||
"name": "role_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"classes_name_unique": {
|
||||
"name": "classes_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.inventory": {
|
||||
"name": "inventory",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"item_id": {
|
||||
"name": "item_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"quantity": {
|
||||
"name": "quantity",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "1"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"inventory_user_id_users_id_fk": {
|
||||
"name": "inventory_user_id_users_id_fk",
|
||||
"tableFrom": "inventory",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"inventory_item_id_items_id_fk": {
|
||||
"name": "inventory_item_id_items_id_fk",
|
||||
"tableFrom": "inventory",
|
||||
"tableTo": "items",
|
||||
"columnsFrom": [
|
||||
"item_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"inventory_user_id_item_id_pk": {
|
||||
"name": "inventory_user_id_item_id_pk",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"item_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"quantity_check": {
|
||||
"name": "quantity_check",
|
||||
"value": "\"inventory\".\"quantity\" > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.item_transactions": {
|
||||
"name": "item_transactions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigserial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"related_user_id": {
|
||||
"name": "related_user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"item_id": {
|
||||
"name": "item_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"quantity": {
|
||||
"name": "quantity",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"item_transactions_user_id_users_id_fk": {
|
||||
"name": "item_transactions_user_id_users_id_fk",
|
||||
"tableFrom": "item_transactions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"item_transactions_related_user_id_users_id_fk": {
|
||||
"name": "item_transactions_related_user_id_users_id_fk",
|
||||
"tableFrom": "item_transactions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"related_user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"item_transactions_item_id_items_id_fk": {
|
||||
"name": "item_transactions_item_id_items_id_fk",
|
||||
"tableFrom": "item_transactions",
|
||||
"tableTo": "items",
|
||||
"columnsFrom": [
|
||||
"item_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.items": {
|
||||
"name": "items",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"rarity": {
|
||||
"name": "rarity",
|
||||
"type": "varchar(20)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'Common'"
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'MATERIAL'"
|
||||
},
|
||||
"usage_data": {
|
||||
"name": "usage_data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"price": {
|
||||
"name": "price",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"icon_url": {
|
||||
"name": "icon_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image_url": {
|
||||
"name": "image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"items_name_unique": {
|
||||
"name": "items_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.lootdrops": {
|
||||
"name": "lootdrops",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"message_id": {
|
||||
"name": "message_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"channel_id": {
|
||||
"name": "channel_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"reward_amount": {
|
||||
"name": "reward_amount",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"currency": {
|
||||
"name": "currency",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"claimed_by": {
|
||||
"name": "claimed_by",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"lootdrops_claimed_by_users_id_fk": {
|
||||
"name": "lootdrops_claimed_by_users_id_fk",
|
||||
"tableFrom": "lootdrops",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"claimed_by"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.quests": {
|
||||
"name": "quests",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"trigger_event": {
|
||||
"name": "trigger_event",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"requirements": {
|
||||
"name": "requirements",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"rewards": {
|
||||
"name": "rewards",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.transactions": {
|
||||
"name": "transactions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigserial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"related_user_id": {
|
||||
"name": "related_user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"amount": {
|
||||
"name": "amount",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"transactions_user_id_users_id_fk": {
|
||||
"name": "transactions_user_id_users_id_fk",
|
||||
"tableFrom": "transactions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"transactions_related_user_id_users_id_fk": {
|
||||
"name": "transactions_related_user_id_users_id_fk",
|
||||
"tableFrom": "transactions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"related_user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user_quests": {
|
||||
"name": "user_quests",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"quest_id": {
|
||||
"name": "quest_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"progress": {
|
||||
"name": "progress",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_quests_user_id_users_id_fk": {
|
||||
"name": "user_quests_user_id_users_id_fk",
|
||||
"tableFrom": "user_quests",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"user_quests_quest_id_quests_id_fk": {
|
||||
"name": "user_quests_quest_id_quests_id_fk",
|
||||
"tableFrom": "user_quests",
|
||||
"tableTo": "quests",
|
||||
"columnsFrom": [
|
||||
"quest_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"user_quests_user_id_quest_id_pk": {
|
||||
"name": "user_quests_user_id_quest_id_pk",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"quest_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user_timers": {
|
||||
"name": "user_timers",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'{}'::jsonb"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_timers_user_id_users_id_fk": {
|
||||
"name": "user_timers_user_id_users_id_fk",
|
||||
"tableFrom": "user_timers",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"user_timers_user_id_type_key_pk": {
|
||||
"name": "user_timers_user_id_type_key_pk",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"type",
|
||||
"key"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigint",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"class_id": {
|
||||
"name": "class_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"balance": {
|
||||
"name": "balance",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "0"
|
||||
},
|
||||
"xp": {
|
||||
"name": "xp",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "0"
|
||||
},
|
||||
"level": {
|
||||
"name": "level",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 1
|
||||
},
|
||||
"daily_streak": {
|
||||
"name": "daily_streak",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"users_class_id_classes_id_fk": {
|
||||
"name": "users_class_id_classes_id_fk",
|
||||
"tableFrom": "users",
|
||||
"tableTo": "classes",
|
||||
"columnsFrom": [
|
||||
"class_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
878
drizzle/meta/0001_snapshot.json
Normal file
878
drizzle/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,878 @@
|
||||
{
|
||||
"id": "72cb5e22-fb44-4db8-9527-020dbec017d0",
|
||||
"prevId": "d43c3f7b-afe5-4974-ab67-fcd69256f3d8",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.classes": {
|
||||
"name": "classes",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigint",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"balance": {
|
||||
"name": "balance",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "0"
|
||||
},
|
||||
"role_id": {
|
||||
"name": "role_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"classes_name_unique": {
|
||||
"name": "classes_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.inventory": {
|
||||
"name": "inventory",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"item_id": {
|
||||
"name": "item_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"quantity": {
|
||||
"name": "quantity",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "1"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"inventory_user_id_users_id_fk": {
|
||||
"name": "inventory_user_id_users_id_fk",
|
||||
"tableFrom": "inventory",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"inventory_item_id_items_id_fk": {
|
||||
"name": "inventory_item_id_items_id_fk",
|
||||
"tableFrom": "inventory",
|
||||
"tableTo": "items",
|
||||
"columnsFrom": [
|
||||
"item_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"inventory_user_id_item_id_pk": {
|
||||
"name": "inventory_user_id_item_id_pk",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"item_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"quantity_check": {
|
||||
"name": "quantity_check",
|
||||
"value": "\"inventory\".\"quantity\" > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.item_transactions": {
|
||||
"name": "item_transactions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigserial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"related_user_id": {
|
||||
"name": "related_user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"item_id": {
|
||||
"name": "item_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"quantity": {
|
||||
"name": "quantity",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"item_transactions_user_id_users_id_fk": {
|
||||
"name": "item_transactions_user_id_users_id_fk",
|
||||
"tableFrom": "item_transactions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"item_transactions_related_user_id_users_id_fk": {
|
||||
"name": "item_transactions_related_user_id_users_id_fk",
|
||||
"tableFrom": "item_transactions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"related_user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"item_transactions_item_id_items_id_fk": {
|
||||
"name": "item_transactions_item_id_items_id_fk",
|
||||
"tableFrom": "item_transactions",
|
||||
"tableTo": "items",
|
||||
"columnsFrom": [
|
||||
"item_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.items": {
|
||||
"name": "items",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"rarity": {
|
||||
"name": "rarity",
|
||||
"type": "varchar(20)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'Common'"
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'MATERIAL'"
|
||||
},
|
||||
"usage_data": {
|
||||
"name": "usage_data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"price": {
|
||||
"name": "price",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"icon_url": {
|
||||
"name": "icon_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image_url": {
|
||||
"name": "image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"items_name_unique": {
|
||||
"name": "items_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.lootdrops": {
|
||||
"name": "lootdrops",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"message_id": {
|
||||
"name": "message_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"channel_id": {
|
||||
"name": "channel_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"reward_amount": {
|
||||
"name": "reward_amount",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"currency": {
|
||||
"name": "currency",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"claimed_by": {
|
||||
"name": "claimed_by",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"lootdrops_claimed_by_users_id_fk": {
|
||||
"name": "lootdrops_claimed_by_users_id_fk",
|
||||
"tableFrom": "lootdrops",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"claimed_by"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.moderation_cases": {
|
||||
"name": "moderation_cases",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigserial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"case_id": {
|
||||
"name": "case_id",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(20)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"moderator_id": {
|
||||
"name": "moderator_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"moderator_name": {
|
||||
"name": "moderator_name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"reason": {
|
||||
"name": "reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"active": {
|
||||
"name": "active",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"resolved_by": {
|
||||
"name": "resolved_by",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"resolved_reason": {
|
||||
"name": "resolved_reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"moderation_cases_case_id_unique": {
|
||||
"name": "moderation_cases_case_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"case_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.quests": {
|
||||
"name": "quests",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"trigger_event": {
|
||||
"name": "trigger_event",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"requirements": {
|
||||
"name": "requirements",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"rewards": {
|
||||
"name": "rewards",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.transactions": {
|
||||
"name": "transactions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigserial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"related_user_id": {
|
||||
"name": "related_user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"amount": {
|
||||
"name": "amount",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"transactions_user_id_users_id_fk": {
|
||||
"name": "transactions_user_id_users_id_fk",
|
||||
"tableFrom": "transactions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"transactions_related_user_id_users_id_fk": {
|
||||
"name": "transactions_related_user_id_users_id_fk",
|
||||
"tableFrom": "transactions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"related_user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user_quests": {
|
||||
"name": "user_quests",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"quest_id": {
|
||||
"name": "quest_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"progress": {
|
||||
"name": "progress",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_quests_user_id_users_id_fk": {
|
||||
"name": "user_quests_user_id_users_id_fk",
|
||||
"tableFrom": "user_quests",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"user_quests_quest_id_quests_id_fk": {
|
||||
"name": "user_quests_quest_id_quests_id_fk",
|
||||
"tableFrom": "user_quests",
|
||||
"tableTo": "quests",
|
||||
"columnsFrom": [
|
||||
"quest_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"user_quests_user_id_quest_id_pk": {
|
||||
"name": "user_quests_user_id_quest_id_pk",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"quest_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user_timers": {
|
||||
"name": "user_timers",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'{}'::jsonb"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_timers_user_id_users_id_fk": {
|
||||
"name": "user_timers_user_id_users_id_fk",
|
||||
"tableFrom": "user_timers",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"user_timers_user_id_type_key_pk": {
|
||||
"name": "user_timers_user_id_type_key_pk",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"type",
|
||||
"key"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigint",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"class_id": {
|
||||
"name": "class_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"balance": {
|
||||
"name": "balance",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "0"
|
||||
},
|
||||
"xp": {
|
||||
"name": "xp",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "0"
|
||||
},
|
||||
"level": {
|
||||
"name": "level",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 1
|
||||
},
|
||||
"daily_streak": {
|
||||
"name": "daily_streak",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"users_class_id_classes_id_fk": {
|
||||
"name": "users_class_id_classes_id_fk",
|
||||
"tableFrom": "users",
|
||||
"tableTo": "classes",
|
||||
"columnsFrom": [
|
||||
"class_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
1020
drizzle/meta/0002_snapshot.json
Normal file
1020
drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
27
drizzle/meta/_journal.json
Normal file
27
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1766137924760,
|
||||
"tag": "0000_fixed_tomas",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1766606046050,
|
||||
"tag": "0001_heavy_thundra",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1767716705797,
|
||||
"tag": "0002_fancy_forge",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,8 +5,7 @@
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"drizzle-kit": "^0.31.7",
|
||||
"postgres": "^3.4.7"
|
||||
"drizzle-kit": "^0.31.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
@@ -15,14 +14,18 @@
|
||||
"generate": "docker compose run --rm app drizzle-kit generate",
|
||||
"migrate": "docker compose run --rm app drizzle-kit migrate",
|
||||
"db:push": "docker compose run --rm app drizzle-kit push",
|
||||
"db:push:local": "drizzle-kit push",
|
||||
"dev": "bun --watch src/index.ts",
|
||||
"db:studio": "drizzle-kit studio --host 0.0.0.0"
|
||||
"db:studio": "drizzle-kit studio --host 0.0.0.0",
|
||||
"studio:remote": "bash scripts/remote-studio.sh",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "^0.1.84",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"postgres": "^3.4.7",
|
||||
"zod": "^4.1.13"
|
||||
}
|
||||
}
|
||||
25
scripts/remote-studio.sh
Executable file
25
scripts/remote-studio.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Load environment variables
|
||||
if [ -f .env ]; then
|
||||
# export $(grep -v '^#' .env | xargs) # Use a safer way if possible, but for simple .env this often works.
|
||||
# Better way to source .env without exporting everything to shell if we just want to use them in script:
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [ -z "$VPS_HOST" ] || [ -z "$VPS_USER" ]; then
|
||||
echo "Error: VPS_HOST and VPS_USER must be set in .env"
|
||||
echo "Please add them to your .env file:"
|
||||
echo "VPS_USER=your-username"
|
||||
echo "VPS_HOST=your-ip-address"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔮 Establishing secure tunnel to Drizzle Studio..."
|
||||
echo "📚 Studio will be accessible at: https://local.drizzle.studio"
|
||||
echo "Press Ctrl+C to stop the connection."
|
||||
|
||||
# -N means "Do not execute a remote command". -L is for local port forwarding.
|
||||
ssh -N -L 4983:127.0.0.1:4983 $VPS_USER@$VPS_HOST
|
||||
@@ -1,135 +0,0 @@
|
||||
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { questService } from "@/modules/quest/quest.service";
|
||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||
import { economyService } from "@/modules/economy/economy.service";
|
||||
import { classService } from "@/modules/class/class.service";
|
||||
import { levelingService } from "@/modules/leveling/leveling.service";
|
||||
import { quests, items, classes, users, inventory } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const TEST_ID = "999999999";
|
||||
const TEST_USERNAME = "verification_bot";
|
||||
const RANDOM_SUFFIX = Math.floor(Math.random() * 10000);
|
||||
const TEST_CLASS_NAME = `Test Class ${RANDOM_SUFFIX}`;
|
||||
const TEST_QUEST_NAME = `Test Quest ${RANDOM_SUFFIX}`;
|
||||
const TEST_ITEM_NAME = `Test Potion ${RANDOM_SUFFIX}`;
|
||||
const TEST_CLASS_ID = BigInt(10000 + RANDOM_SUFFIX);
|
||||
const TEST_QUEST_ID = 10000 + RANDOM_SUFFIX;
|
||||
const TEST_ITEM_ID = 10000 + RANDOM_SUFFIX;
|
||||
|
||||
|
||||
async function verify() {
|
||||
console.log("Starting verification...");
|
||||
|
||||
try {
|
||||
// Cleanup previous run if checking same ID
|
||||
try { await userService.deleteUser(TEST_ID); } catch { }
|
||||
|
||||
// 1. Setup Data (Class, Quest, Item)
|
||||
// Ensure we have a class
|
||||
let [cls] = await DrizzleClient.insert(classes).values({
|
||||
id: TEST_CLASS_ID,
|
||||
name: TEST_CLASS_NAME,
|
||||
balance: 1000n
|
||||
}).returning();
|
||||
|
||||
// Ensure we have a quest
|
||||
let [quest] = await DrizzleClient.insert(quests).values({
|
||||
id: TEST_QUEST_ID,
|
||||
name: TEST_QUEST_NAME,
|
||||
triggerEvent: "manual",
|
||||
rewards: { xp: 500, balance: 100 }
|
||||
}).returning();
|
||||
|
||||
// Ensure we have an item
|
||||
let [item] = await DrizzleClient.insert(items).values({
|
||||
id: TEST_ITEM_ID,
|
||||
name: TEST_ITEM_NAME,
|
||||
price: 50n,
|
||||
iconUrl: "x",
|
||||
imageUrl: "x"
|
||||
}).returning();
|
||||
|
||||
// 2. Create User
|
||||
console.log("Creating user...");
|
||||
await userService.createUser(TEST_ID, TEST_USERNAME);
|
||||
let user = await userService.getUserById(TEST_ID);
|
||||
if (!user) throw new Error("User create failed");
|
||||
console.log("User created:", user.username);
|
||||
|
||||
// 3. Assign Class & Modify Class Balance
|
||||
console.log("Assigning class...");
|
||||
await classService.assignClass(TEST_ID, cls!.id);
|
||||
|
||||
console.log("Modifying class balance...");
|
||||
const clsBalBefore = (await DrizzleClient.query.classes.findFirst({ where: eq(classes.id, cls!.id) }))!.balance ?? 0n;
|
||||
await classService.modifyClassBalance(cls!.id, 50n);
|
||||
const clsBalAfter = (await DrizzleClient.query.classes.findFirst({ where: eq(classes.id, cls!.id) }))!.balance ?? 0n;
|
||||
|
||||
if (clsBalAfter !== clsBalBefore + 50n) throw new Error(`Class balance mismatch: ${clsBalAfter} vs ${clsBalBefore + 50n}`);
|
||||
console.log("Class balance verified.");
|
||||
|
||||
// 4. Assign & Complete Quest (Check Logic)
|
||||
console.log("Assigning quest...");
|
||||
await questService.assignQuest(TEST_ID, quest!.id);
|
||||
|
||||
console.log("Completing quest...");
|
||||
// Initial state
|
||||
const initialXp = user.xp ?? 0n;
|
||||
const initialBal = user.balance ?? 0n;
|
||||
|
||||
const result = await questService.completeQuest(TEST_ID, quest!.id);
|
||||
if (!result.success) throw new Error("Quest completion failed");
|
||||
|
||||
// Refresh User
|
||||
user = await userService.getUserById(TEST_ID);
|
||||
if (!user) throw new Error("User lost");
|
||||
|
||||
console.log("Quest Rewards:", result.rewards);
|
||||
console.log("User State:", { xp: user.xp, balance: user.balance, level: user.level });
|
||||
|
||||
if (user.balance !== initialBal + BigInt(result.rewards.balance)) throw new Error("Balance reward logic failed");
|
||||
if (user.xp !== initialXp + BigInt(result.rewards.xp)) throw new Error("XP reward logic failed");
|
||||
|
||||
// 5. Buy Item (Check Atomic Logic)
|
||||
console.log("Buying item...");
|
||||
const buyResult = await inventoryService.buyItem(TEST_ID, item!.id, 2n);
|
||||
if (!buyResult.success) throw new Error("Buy item failed");
|
||||
|
||||
// Refresh User
|
||||
user = await userService.getUserById(TEST_ID);
|
||||
|
||||
const expectedBal = initialBal + BigInt(result.rewards.balance) - (item!.price! * 2n);
|
||||
if (user!.balance !== expectedBal) throw new Error(`Buy logic balance mismatch: ${user!.balance} vs ${expectedBal}`);
|
||||
|
||||
const inv = await inventoryService.getInventory(TEST_ID);
|
||||
const invItem = inv.find(i => i.itemId === item!.id);
|
||||
if (!invItem || invItem.quantity !== 2n) throw new Error("Inventory item mismatch");
|
||||
|
||||
console.log("Buy Verification Successful.");
|
||||
|
||||
// Cleanup
|
||||
await userService.deleteUser(TEST_ID);
|
||||
// Also clean up metadata
|
||||
await DrizzleClient.delete(inventory).where(eq(inventory.itemId, TEST_ITEM_ID)); // Cascade should handle user link, but manually cleaning item/quest/class
|
||||
await DrizzleClient.delete(items).where(eq(items.id, TEST_ITEM_ID));
|
||||
await DrizzleClient.delete(quests).where(eq(quests.id, TEST_QUEST_ID));
|
||||
await DrizzleClient.delete(classes).where(eq(classes.id, TEST_CLASS_ID));
|
||||
|
||||
console.log("Cleanup done. Verification PASSED.");
|
||||
|
||||
} catch (e) {
|
||||
console.error("Verification FAILED:", e);
|
||||
// Attempt cleanup
|
||||
try { await userService.deleteUser(TEST_ID); } catch { }
|
||||
try { if (TEST_ITEM_ID) await DrizzleClient.delete(inventory).where(eq(inventory.itemId, TEST_ITEM_ID)); } catch { }
|
||||
try { if (TEST_ITEM_ID) await DrizzleClient.delete(items).where(eq(items.id, TEST_ITEM_ID)); } catch { }
|
||||
try { if (TEST_QUEST_ID) await DrizzleClient.delete(quests).where(eq(quests.id, TEST_QUEST_ID)); } catch { }
|
||||
try { if (TEST_CLASS_ID) await DrizzleClient.delete(classes).where(eq(classes.id, TEST_CLASS_ID)); } catch { }
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
verify();
|
||||
BIN
src/assets/graphics/lootdrop/template.png
Normal file
BIN
src/assets/graphics/lootdrop/template.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
54
src/commands/admin/case.ts
Normal file
54
src/commands/admin/case.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@/modules/moderation/moderation.service";
|
||||
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
export const moderationCase = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("case")
|
||||
.setDescription("View details of a specific moderation case")
|
||||
.addStringOption(option =>
|
||||
option
|
||||
.setName("case_id")
|
||||
.setDescription("The case ID (e.g., CASE-0001)")
|
||||
.setRequired(true)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||
|
||||
// Validate case ID format
|
||||
if (!caseId.match(/^CASE-\d+$/)) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the case
|
||||
const moderationCase = await ModerationService.getCaseById(caseId);
|
||||
|
||||
if (!moderationCase) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Display the case
|
||||
await interaction.editReply({
|
||||
embeds: [getCaseEmbed(moderationCase)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Case command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching the case.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
54
src/commands/admin/cases.ts
Normal file
54
src/commands/admin/cases.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@/modules/moderation/moderation.service";
|
||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
export const cases = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("cases")
|
||||
.setDescription("View all moderation cases for a user")
|
||||
.addUserOption(option =>
|
||||
option
|
||||
.setName("user")
|
||||
.setDescription("The user to check cases for")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addBooleanOption(option =>
|
||||
option
|
||||
.setName("active_only")
|
||||
.setDescription("Show only active cases (warnings)")
|
||||
.setRequired(false)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
const activeOnly = interaction.options.getBoolean("active_only") || false;
|
||||
|
||||
// Get cases for the user
|
||||
const userCases = await ModerationService.getUserCases(targetUser.id, activeOnly);
|
||||
|
||||
const title = activeOnly
|
||||
? `⚠️ Active Cases for ${targetUser.username}`
|
||||
: `📋 All Cases for ${targetUser.username}`;
|
||||
|
||||
const description = userCases.length === 0
|
||||
? undefined
|
||||
: `Total cases: **${userCases.length}**`;
|
||||
|
||||
// Display the cases
|
||||
await interaction.editReply({
|
||||
embeds: [getCasesListEmbed(userCases, title, description)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Cases command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching cases.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
84
src/commands/admin/clearwarning.ts
Normal file
84
src/commands/admin/clearwarning.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@/modules/moderation/moderation.service";
|
||||
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
export const clearwarning = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("clearwarning")
|
||||
.setDescription("Clear/resolve a warning")
|
||||
.addStringOption(option =>
|
||||
option
|
||||
.setName("case_id")
|
||||
.setDescription("The case ID to clear (e.g., CASE-0001)")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addStringOption(option =>
|
||||
option
|
||||
.setName("reason")
|
||||
.setDescription("Reason for clearing the warning")
|
||||
.setRequired(false)
|
||||
.setMaxLength(500)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||
const reason = interaction.options.getString("reason") || "Cleared by moderator";
|
||||
|
||||
// Validate case ID format
|
||||
if (!caseId.match(/^CASE-\d+$/)) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if case exists and is active
|
||||
const existingCase = await ModerationService.getCaseById(caseId);
|
||||
|
||||
if (!existingCase) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existingCase.active) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** is already resolved.`)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingCase.type !== 'warn') {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** is not a warning. Only warnings can be cleared.`)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the warning
|
||||
await ModerationService.clearCase({
|
||||
caseId,
|
||||
clearedBy: interaction.user.id,
|
||||
clearedByName: interaction.user.username,
|
||||
reason
|
||||
});
|
||||
|
||||
// Send success message
|
||||
await interaction.editReply({
|
||||
embeds: [getClearSuccessEmbed(caseId)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Clear warning command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while clearing the warning.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
68
src/commands/admin/config.ts
Normal file
68
src/commands/admin/config.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { createCommand } from "@lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ModalSubmitInteraction } from "discord.js";
|
||||
import { config, saveConfig } from "@lib/config";
|
||||
import type { GameConfigType } from "@lib/config";
|
||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
||||
|
||||
export const configCommand = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("config")
|
||||
.setDescription("Edit the bot configuration")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
console.log(`Config command executed by ${interaction.user.tag}`);
|
||||
const replacer = (key: string, value: any) => {
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const currentConfigJson = JSON.stringify(config, replacer, 4);
|
||||
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId("config-modal")
|
||||
.setTitle("Edit Configuration");
|
||||
|
||||
const jsonInput = new TextInputBuilder()
|
||||
.setCustomId("json-input")
|
||||
.setLabel("Configuration JSON")
|
||||
.setStyle(TextInputStyle.Paragraph)
|
||||
.setValue(currentConfigJson)
|
||||
.setRequired(true);
|
||||
|
||||
const actionRow = new ActionRowBuilder<TextInputBuilder>().addComponents(jsonInput);
|
||||
modal.addComponents(actionRow);
|
||||
|
||||
await interaction.showModal(modal);
|
||||
|
||||
try {
|
||||
const submitted = await interaction.awaitModalSubmit({
|
||||
time: 300000, // 5 minutes
|
||||
filter: (i) => i.customId === "config-modal" && i.user.id === interaction.user.id
|
||||
});
|
||||
|
||||
const jsonString = submitted.fields.getTextInputValue("json-input");
|
||||
|
||||
try {
|
||||
const newConfig = JSON.parse(jsonString);
|
||||
saveConfig(newConfig as GameConfigType);
|
||||
|
||||
await submitted.reply({
|
||||
embeds: [createSuccessEmbed("Configuration updated successfully.", "Config Saved")]
|
||||
});
|
||||
} catch (parseError) {
|
||||
await submitted.reply({
|
||||
embeds: [createErrorEmbed(`Invalid JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`, "Config Update Failed")],
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Timeout or other error handling if needed, usually just ignore timeouts for modals
|
||||
if (error instanceof Error && error.message.includes('time')) {
|
||||
// specific timeout handling if desired
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
94
src/commands/admin/create_color.ts
Normal file
94
src/commands/admin/create_color.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js";
|
||||
import { config, saveConfig } from "@/lib/config";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import { items } from "@/db/schema";
|
||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
||||
|
||||
export const createColor = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("createcolor")
|
||||
.setDescription("Create a new Color Role and corresponding Item")
|
||||
.addStringOption(option =>
|
||||
option.setName("name")
|
||||
.setDescription("The name of the role and item")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addStringOption(option =>
|
||||
option.setName("color")
|
||||
.setDescription("The hex color code (e.g. #FF0000)")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addNumberOption(option =>
|
||||
option.setName("price")
|
||||
.setDescription("Price of the item (Default: 500)")
|
||||
.setRequired(false)
|
||||
)
|
||||
.addStringOption(option =>
|
||||
option.setName("image")
|
||||
.setDescription("Image URL for the item")
|
||||
.setRequired(false)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
|
||||
const name = interaction.options.getString("name", true);
|
||||
const colorInput = interaction.options.getString("color", true);
|
||||
const price = interaction.options.getNumber("price") || 500;
|
||||
const imageUrl = interaction.options.getString("image") || "https://cdn.discordapp.com/attachments/1450061247365124199/1453122950822760559/Main_Chip_1.png";
|
||||
|
||||
// 1. Validate Color
|
||||
const colorRegex = /^#([0-9A-F]{3}){1,2}$/i;
|
||||
if (!colorRegex.test(colorInput)) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Hex Color code. Format: #RRGGBB")] });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. Create Role
|
||||
const role = await interaction.guild?.roles.create({
|
||||
name: name,
|
||||
color: colorInput as any, // Discord.js types are a bit strict on ColorResolvable, but string generally works or needs parsing
|
||||
reason: `Created via /createcolor by ${interaction.user.tag}`
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
throw new Error("Failed to create role.");
|
||||
}
|
||||
|
||||
// 3. Update Config
|
||||
if (!config.colorRoles.includes(role.id)) {
|
||||
config.colorRoles.push(role.id);
|
||||
saveConfig(config);
|
||||
}
|
||||
|
||||
// 4. Create Item
|
||||
await DrizzleClient.insert(items).values({
|
||||
name: `Color Role - ${name}`,
|
||||
description: `Use this item to apply the ${name} color to your name.`,
|
||||
type: "CONSUMABLE",
|
||||
rarity: "Common",
|
||||
price: BigInt(price),
|
||||
iconUrl: "",
|
||||
imageUrl: imageUrl,
|
||||
usageData: {
|
||||
consume: false,
|
||||
effects: [{ type: "COLOR_ROLE", roleId: role.id }]
|
||||
} as any
|
||||
});
|
||||
|
||||
// 5. Success
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`**Role:** <@&${role.id}> (${colorInput})\n**Item:** Color Role - ${name}\n**Price:** ${price} 🪙`,
|
||||
"✅ Color Role & Item Created"
|
||||
)]
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error in createcolor:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(`Failed to create color role: ${error.message}`)] });
|
||||
}
|
||||
}
|
||||
});
|
||||
14
src/commands/admin/create_item.ts
Normal file
14
src/commands/admin/create_item.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { renderWizard } from "@/modules/admin/item_wizard";
|
||||
|
||||
export const createItem = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("createitem")
|
||||
.setDescription("Create a new item using the interactive wizard")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
const payload = renderWizard(interaction.user.id);
|
||||
await interaction.reply({ ...payload, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
});
|
||||
95
src/commands/admin/features.ts
Normal file
95
src/commands/admin/features.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createBaseEmbed } from "@lib/embeds";
|
||||
import { configManager } from "@/lib/configManager";
|
||||
import { config, reloadConfig } from "@/lib/config";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
|
||||
export const features = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("features")
|
||||
.setDescription("Manage bot features and commands")
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("list")
|
||||
.setDescription("List all commands and their status")
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("toggle")
|
||||
.setDescription("Enable or disable a command")
|
||||
.addStringOption(option =>
|
||||
option.setName("command")
|
||||
.setDescription("The name of the command")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addBooleanOption(option =>
|
||||
option.setName("enabled")
|
||||
.setDescription("Whether the command should be enabled")
|
||||
.setRequired(true)
|
||||
)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
if (subcommand === "list") {
|
||||
const activeCommands = AuroraClient.commands;
|
||||
const categories = new Map<string, string[]>();
|
||||
|
||||
// Group active commands
|
||||
activeCommands.forEach(cmd => {
|
||||
const cat = cmd.category || 'Uncategorized';
|
||||
if (!categories.has(cat)) categories.set(cat, []);
|
||||
categories.get(cat)!.push(cmd.data.name);
|
||||
});
|
||||
|
||||
// Config overrides
|
||||
const overrides = Object.entries(config.commands)
|
||||
.map(([name, enabled]) => `• **${name}**: ${enabled ? "✅ Enabled (Override)" : "❌ Disabled"}`);
|
||||
|
||||
const embed = createBaseEmbed("Command Features", undefined, "Blue");
|
||||
|
||||
// Add fields for each category
|
||||
const sortedCategories = [...categories.keys()].sort();
|
||||
for (const cat of sortedCategories) {
|
||||
const cmds = categories.get(cat)!.sort();
|
||||
const cmdList = cmds.map(name => {
|
||||
const isOverride = config.commands[name] !== undefined;
|
||||
return isOverride ? `**${name}** (See Overrides)` : `**${name}**`;
|
||||
}).join(", ");
|
||||
|
||||
embed.addFields({ name: `📂 ${cat.toUpperCase()}`, value: cmdList || "None" });
|
||||
}
|
||||
|
||||
if (overrides.length > 0) {
|
||||
embed.addFields({ name: "⚙️ Configuration Overrides", value: overrides.join("\n") });
|
||||
} else {
|
||||
embed.addFields({ name: "⚙️ Configuration Overrides", value: "No overrides set." });
|
||||
}
|
||||
|
||||
// Check permissions manually as a fallback (though defaultMemberPermissions handles it at the API level)
|
||||
if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) {
|
||||
await interaction.reply({ content: "❌ You need Administrator permissions to use this command.", flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
|
||||
} else if (subcommand === "toggle") {
|
||||
const commandName = interaction.options.getString("command", true);
|
||||
const enabled = interaction.options.getBoolean("enabled", true);
|
||||
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
configManager.toggleCommand(commandName, enabled);
|
||||
|
||||
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Reloading configuration...` });
|
||||
|
||||
// Reload config from disk (which was updated by configManager)
|
||||
reloadConfig();
|
||||
|
||||
await AuroraClient.loadCommands(true);
|
||||
await AuroraClient.deployCommands();
|
||||
|
||||
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Commands reloaded!` });
|
||||
}
|
||||
}
|
||||
});
|
||||
84
src/commands/admin/health.test.ts
Normal file
84
src/commands/admin/health.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { health } from "./health";
|
||||
import { ChatInputCommandInteraction, Colors } from "discord.js";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
|
||||
// Mock DrizzleClient
|
||||
const executeMock = mock(() => Promise.resolve());
|
||||
mock.module("@/lib/DrizzleClient", () => ({
|
||||
DrizzleClient: {
|
||||
execute: executeMock
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock BotClient (already has lastCommandTimestamp if imported, but we might want to control it)
|
||||
AuroraClient.lastCommandTimestamp = 1641481200000; // Fixed timestamp for testing
|
||||
|
||||
describe("Health Command", () => {
|
||||
beforeEach(() => {
|
||||
executeMock.mockClear();
|
||||
});
|
||||
|
||||
test("should execute successfully and return health embed", async () => {
|
||||
const interaction = {
|
||||
deferReply: mock(() => Promise.resolve()),
|
||||
editReply: mock(() => Promise.resolve()),
|
||||
client: {
|
||||
ws: {
|
||||
ping: 42
|
||||
}
|
||||
},
|
||||
user: { id: "123", username: "testuser" },
|
||||
commandName: "health"
|
||||
} as unknown as ChatInputCommandInteraction;
|
||||
|
||||
await health.execute(interaction);
|
||||
|
||||
expect(interaction.deferReply).toHaveBeenCalled();
|
||||
expect(executeMock).toHaveBeenCalled();
|
||||
expect(interaction.editReply).toHaveBeenCalled();
|
||||
|
||||
const editReplyCall = (interaction.editReply as any).mock.calls[0][0];
|
||||
const embed = editReplyCall.embeds[0];
|
||||
|
||||
expect(embed.data.title).toBe("System Health Status");
|
||||
expect(embed.data.color).toBe(Colors.Aqua);
|
||||
|
||||
// Check fields
|
||||
const fields = embed.data.fields;
|
||||
expect(fields).toBeDefined();
|
||||
|
||||
// Connectivity field
|
||||
const connectivityField = fields.find((f: any) => f.name === "📡 Connectivity");
|
||||
expect(connectivityField.value).toContain("42ms");
|
||||
expect(connectivityField.value).toContain("Connected");
|
||||
|
||||
// Activity field
|
||||
const activityField = fields.find((f: any) => f.name === "⌨️ Activity");
|
||||
expect(activityField.value).toContain("R>"); // Relative Discord timestamp
|
||||
});
|
||||
|
||||
test("should handle database disconnection", async () => {
|
||||
executeMock.mockImplementationOnce(() => Promise.reject(new Error("DB Down")));
|
||||
|
||||
const interaction = {
|
||||
deferReply: mock(() => Promise.resolve()),
|
||||
editReply: mock(() => Promise.resolve()),
|
||||
client: {
|
||||
ws: {
|
||||
ping: 42
|
||||
}
|
||||
},
|
||||
user: { id: "123", username: "testuser" },
|
||||
commandName: "health"
|
||||
} as unknown as ChatInputCommandInteraction;
|
||||
|
||||
await health.execute(interaction);
|
||||
|
||||
const editReplyCall = (interaction.editReply as any).mock.calls[0][0];
|
||||
const embed = editReplyCall.embeds[0];
|
||||
const connectivityField = embed.data.fields.find((f: any) => f.name === "📡 Connectivity");
|
||||
|
||||
expect(connectivityField.value).toContain("Disconnected");
|
||||
});
|
||||
});
|
||||
60
src/commands/admin/health.ts
Normal file
60
src/commands/admin/health.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { createCommand } from "@lib/utils";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, Colors } from "discord.js";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { createBaseEmbed } from "@lib/embeds";
|
||||
|
||||
export const health = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("health")
|
||||
.setDescription("Check the bot's health status")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
|
||||
// 1. Check Discord API latency
|
||||
const wsPing = interaction.client.ws.ping;
|
||||
|
||||
// 2. Verify database connection
|
||||
let dbStatus = "Connected";
|
||||
let dbPing = -1;
|
||||
try {
|
||||
const start = Date.now();
|
||||
await DrizzleClient.execute(sql`SELECT 1`);
|
||||
dbPing = Date.now() - start;
|
||||
} catch (error) {
|
||||
dbStatus = "Disconnected";
|
||||
console.error("Health check DB error:", error);
|
||||
}
|
||||
|
||||
// 3. Uptime
|
||||
const uptime = process.uptime();
|
||||
const days = Math.floor(uptime / 86400);
|
||||
const hours = Math.floor((uptime % 86400) / 3600);
|
||||
const minutes = Math.floor((uptime % 3600) / 60);
|
||||
const seconds = Math.floor(uptime % 60);
|
||||
const uptimeString = `${days}d ${hours}h ${minutes}m ${seconds}s`;
|
||||
|
||||
// 4. Memory usage
|
||||
const memory = process.memoryUsage();
|
||||
const heapUsed = (memory.heapUsed / 1024 / 1024).toFixed(2);
|
||||
const heapTotal = (memory.heapTotal / 1024 / 1024).toFixed(2);
|
||||
const rss = (memory.rss / 1024 / 1024).toFixed(2);
|
||||
|
||||
// 5. Last successful command
|
||||
const lastCommand = AuroraClient.lastCommandTimestamp
|
||||
? `<t:${Math.floor(AuroraClient.lastCommandTimestamp / 1000)}:R>`
|
||||
: "None since startup";
|
||||
|
||||
const embed = createBaseEmbed("System Health Status", undefined, Colors.Aqua)
|
||||
.addFields(
|
||||
{ name: "📡 Connectivity", value: `**Discord WS:** ${wsPing}ms\n**Database:** ${dbStatus} ${dbPing >= 0 ? `(${dbPing}ms)` : ""}`, inline: true },
|
||||
{ name: "⏱️ Uptime", value: uptimeString, inline: true },
|
||||
{ name: "🧠 Memory Usage", value: `**RSS:** ${rss} MB\n**Heap:** ${heapUsed} / ${heapTotal} MB`, inline: false },
|
||||
{ name: "⌨️ Activity", value: `**Last Command:** ${lastCommand}`, inline: true }
|
||||
);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
});
|
||||
99
src/commands/admin/listing.ts
Normal file
99
src/commands/admin/listing.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import {
|
||||
SlashCommandBuilder,
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
type BaseGuildTextChannel,
|
||||
PermissionFlagsBits,
|
||||
MessageFlags
|
||||
} from "discord.js";
|
||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||
import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { items } from "@/db/schema";
|
||||
import { ilike, isNotNull, and } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import { getShopListingMessage } from "@/modules/economy/shop.view";
|
||||
|
||||
export const listing = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("listing")
|
||||
.setDescription("Post an item listing in the channel for users to buy")
|
||||
.addNumberOption(option =>
|
||||
option.setName("item")
|
||||
.setDescription("The item to list")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
)
|
||||
.addChannelOption(option =>
|
||||
option.setName("channel")
|
||||
.setDescription("The channel to post the listing in (defaults to current)")
|
||||
.setRequired(false)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const itemId = interaction.options.getNumber("item", true);
|
||||
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
|
||||
|
||||
if (!targetChannel || !targetChannel.isSendable()) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const item = await inventoryService.getItem(itemId);
|
||||
if (!item) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!item.price) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item "${item.name}" is not for sale (no price set).`)] });
|
||||
return;
|
||||
}
|
||||
|
||||
const listingMessage = getShopListingMessage({
|
||||
...item,
|
||||
formattedPrice: `${item.price} 🪙`,
|
||||
price: item.price
|
||||
});
|
||||
|
||||
try {
|
||||
await targetChannel.send(listingMessage);
|
||||
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
||||
} else {
|
||||
console.error("Error creating listing:", error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
autocomplete: async (interaction) => {
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
|
||||
const results = await DrizzleClient.select({
|
||||
id: items.id,
|
||||
name: items.name,
|
||||
price: items.price
|
||||
})
|
||||
.from(items)
|
||||
.where(
|
||||
and(
|
||||
ilike(items.name, `%${focusedValue}%`),
|
||||
isNotNull(items.price)
|
||||
)
|
||||
)
|
||||
.limit(20);
|
||||
|
||||
await interaction.respond(
|
||||
results.map(item => ({
|
||||
name: `${item.name} (Price: ${item.price})`,
|
||||
value: item.id
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
62
src/commands/admin/note.ts
Normal file
62
src/commands/admin/note.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@/modules/moderation/moderation.service";
|
||||
import { CaseType } from "@/lib/constants";
|
||||
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
export const note = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("note")
|
||||
.setDescription("Add a staff-only note about a user")
|
||||
.addUserOption(option =>
|
||||
option
|
||||
.setName("user")
|
||||
.setDescription("The user to add a note for")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addStringOption(option =>
|
||||
option
|
||||
.setName("note")
|
||||
.setDescription("The note to add")
|
||||
.setRequired(true)
|
||||
.setMaxLength(1000)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
const noteText = interaction.options.getString("note", true);
|
||||
|
||||
// Create the note case
|
||||
const moderationCase = await ModerationService.createCase({
|
||||
type: CaseType.NOTE,
|
||||
userId: targetUser.id,
|
||||
username: targetUser.username,
|
||||
moderatorId: interaction.user.id,
|
||||
moderatorName: interaction.user.username,
|
||||
reason: noteText,
|
||||
});
|
||||
|
||||
if (!moderationCase) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("Failed to create note.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Send success message
|
||||
await interaction.editReply({
|
||||
embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Note command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while adding the note.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
43
src/commands/admin/notes.ts
Normal file
43
src/commands/admin/notes.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@/modules/moderation/moderation.service";
|
||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
export const notes = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("notes")
|
||||
.setDescription("View all staff notes for a user")
|
||||
.addUserOption(option =>
|
||||
option
|
||||
.setName("user")
|
||||
.setDescription("The user to check notes for")
|
||||
.setRequired(true)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
|
||||
// Get all notes for the user
|
||||
const userNotes = await ModerationService.getUserNotes(targetUser.id);
|
||||
|
||||
// Display the notes
|
||||
await interaction.editReply({
|
||||
embeds: [getCasesListEmbed(
|
||||
userNotes,
|
||||
`📝 Staff Notes for ${targetUser.username}`,
|
||||
userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**`
|
||||
)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Notes command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching notes.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
179
src/commands/admin/prune.ts
Normal file
179
src/commands/admin/prune.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||
import { config } from "@/lib/config";
|
||||
import { PruneService } from "@/modules/moderation/prune.service";
|
||||
import {
|
||||
getConfirmationMessage,
|
||||
getProgressEmbed,
|
||||
getSuccessEmbed,
|
||||
getPruneErrorEmbed,
|
||||
getPruneWarningEmbed,
|
||||
getCancelledEmbed
|
||||
} from "@/modules/moderation/prune.view";
|
||||
|
||||
export const prune = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("prune")
|
||||
.setDescription("Delete messages in bulk (admin only)")
|
||||
.addIntegerOption(option =>
|
||||
option
|
||||
.setName("amount")
|
||||
.setDescription(`Number of messages to delete (1-${config.moderation?.prune?.maxAmount || 100})`)
|
||||
.setRequired(false)
|
||||
.setMinValue(1)
|
||||
.setMaxValue(config.moderation?.prune?.maxAmount || 100)
|
||||
)
|
||||
.addUserOption(option =>
|
||||
option
|
||||
.setName("user")
|
||||
.setDescription("Only delete messages from this user")
|
||||
.setRequired(false)
|
||||
)
|
||||
.addBooleanOption(option =>
|
||||
option
|
||||
.setName("all")
|
||||
.setDescription("Delete all messages in the channel")
|
||||
.setRequired(false)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const amount = interaction.options.getInteger("amount");
|
||||
const user = interaction.options.getUser("user");
|
||||
const all = interaction.options.getBoolean("all") || false;
|
||||
|
||||
// Validate inputs
|
||||
if (!amount && !all) {
|
||||
// Default to 10 messages
|
||||
} else if (amount && all) {
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneErrorEmbed("Cannot specify both `amount` and `all`. Please use one or the other.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const finalAmount = all ? 'all' : (amount || 10);
|
||||
const confirmThreshold = config.moderation.prune.confirmThreshold;
|
||||
|
||||
// Check if confirmation is needed
|
||||
const needsConfirmation = all || (typeof finalAmount === 'number' && finalAmount > confirmThreshold);
|
||||
|
||||
if (needsConfirmation) {
|
||||
// Estimate message count for confirmation
|
||||
let estimatedCount: number | undefined;
|
||||
if (all) {
|
||||
try {
|
||||
estimatedCount = await PruneService.estimateMessageCount(interaction.channel!);
|
||||
} catch {
|
||||
estimatedCount = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const { embeds, components } = getConfirmationMessage(finalAmount, estimatedCount);
|
||||
const response = await interaction.editReply({ embeds, components });
|
||||
|
||||
try {
|
||||
const confirmation = await response.awaitMessageComponent({
|
||||
filter: (i) => i.user.id === interaction.user.id,
|
||||
componentType: ComponentType.Button,
|
||||
time: 30000
|
||||
});
|
||||
|
||||
if (confirmation.customId === "cancel_prune") {
|
||||
await confirmation.update({
|
||||
embeds: [getCancelledEmbed()],
|
||||
components: []
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// User confirmed, proceed with deletion
|
||||
await confirmation.update({
|
||||
embeds: [getProgressEmbed({ current: 0, total: estimatedCount || finalAmount as number })],
|
||||
components: []
|
||||
});
|
||||
|
||||
// Execute deletion with progress callback for 'all' mode
|
||||
const result = await PruneService.deleteMessages(
|
||||
interaction.channel!,
|
||||
{
|
||||
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
|
||||
userId: user?.id,
|
||||
all
|
||||
},
|
||||
all ? async (progress) => {
|
||||
await interaction.editReply({
|
||||
embeds: [getProgressEmbed(progress)]
|
||||
});
|
||||
} : undefined
|
||||
);
|
||||
|
||||
// Show success
|
||||
await interaction.editReply({
|
||||
embeds: [getSuccessEmbed(result)],
|
||||
components: []
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("time")) {
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneWarningEmbed("Confirmation timed out. Please try again.")],
|
||||
components: []
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No confirmation needed, proceed directly
|
||||
const result = await PruneService.deleteMessages(
|
||||
interaction.channel!,
|
||||
{
|
||||
amount: finalAmount as number,
|
||||
userId: user?.id,
|
||||
all: false
|
||||
}
|
||||
);
|
||||
|
||||
// Check if no messages were found
|
||||
if (result.deletedCount === 0) {
|
||||
if (user) {
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneWarningEmbed(`No messages found from **${user.username}** in the last ${finalAmount} messages.`)]
|
||||
});
|
||||
} else {
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneWarningEmbed("No messages found to delete.")]
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [getSuccessEmbed(result)]
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Prune command error:", error);
|
||||
|
||||
let errorMessage = "An unexpected error occurred while trying to delete messages.";
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("permission")) {
|
||||
errorMessage = "I don't have permission to delete messages in this channel.";
|
||||
} else if (error.message.includes("channel type")) {
|
||||
errorMessage = "This command cannot be used in this type of channel.";
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneErrorEmbed(errorMessage)]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
33
src/commands/admin/refresh.ts
Normal file
33
src/commands/admin/refresh.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createCommand } from "@lib/utils";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
|
||||
export const refresh = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("refresh")
|
||||
.setDescription("Reloads all commands and config without restarting")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
await AuroraClient.loadCommands(true);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
// Deploy commands
|
||||
await AuroraClient.deployCommands();
|
||||
|
||||
const embed = createSuccessEmbed(
|
||||
`Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`,
|
||||
"System Refreshed"
|
||||
);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An error occurred while refreshing commands. Check console for details.", "Refresh Failed")] });
|
||||
}
|
||||
}
|
||||
});
|
||||
37
src/commands/admin/terminal.ts
Normal file
37
src/commands/admin/terminal.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
|
||||
import { terminalService } from "@/modules/terminal/terminal.service";
|
||||
import { createBaseEmbed, createErrorEmbed } from "@/lib/embeds";
|
||||
|
||||
export const terminal = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("terminal")
|
||||
.setDescription("Manage the Aurora Terminal")
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("init")
|
||||
.setDescription("Initialize the terminal in the current channel")
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
if (subcommand === "init") {
|
||||
const channel = interaction.channel;
|
||||
if (!channel || channel.type !== ChannelType.GuildText) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed("Terminal can only be initialized in text channels.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({ ephemeral: true, content: "Initializing terminal..." });
|
||||
|
||||
try {
|
||||
await terminalService.init(channel as TextChannel);
|
||||
await interaction.editReply({ content: "✅ Terminal initialized!" });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await interaction.editReply({ content: "❌ Failed to initialize terminal." });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
99
src/commands/admin/update.ts
Normal file
99
src/commands/admin/update.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { createCommand } from "@lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||
import { UpdateService } from "@/modules/admin/update.service";
|
||||
import {
|
||||
getCheckingEmbed,
|
||||
getNoUpdatesEmbed,
|
||||
getUpdatesAvailableMessage,
|
||||
getPreparingEmbed,
|
||||
getUpdatingEmbed,
|
||||
getCancelledEmbed,
|
||||
getTimeoutEmbed,
|
||||
getErrorEmbed
|
||||
} from "@/modules/admin/update.view";
|
||||
|
||||
export const update = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("update")
|
||||
.setDescription("Check for updates and restart the bot")
|
||||
.addBooleanOption(option =>
|
||||
option.setName("force")
|
||||
.setDescription("Force update even if checks fail (not recommended)")
|
||||
.setRequired(false)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
const force = interaction.options.getBoolean("force") || false;
|
||||
|
||||
try {
|
||||
await interaction.editReply({ embeds: [getCheckingEmbed()] });
|
||||
|
||||
const { hasUpdates, log, branch } = await UpdateService.checkForUpdates();
|
||||
|
||||
if (!hasUpdates && !force) {
|
||||
await interaction.editReply({ embeds: [getNoUpdatesEmbed()] });
|
||||
return;
|
||||
}
|
||||
|
||||
const { embeds, components } = getUpdatesAvailableMessage(branch, log, force);
|
||||
const response = await interaction.editReply({ embeds, components });
|
||||
|
||||
try {
|
||||
const confirmation = await response.awaitMessageComponent({
|
||||
filter: (i) => i.user.id === interaction.user.id,
|
||||
componentType: ComponentType.Button,
|
||||
time: 30000
|
||||
});
|
||||
|
||||
if (confirmation.customId === "confirm_update") {
|
||||
await confirmation.update({
|
||||
embeds: [getPreparingEmbed()],
|
||||
components: []
|
||||
});
|
||||
|
||||
// 1. Check what the update requires
|
||||
const { needsInstall, needsMigrations } = await UpdateService.checkUpdateRequirements(branch);
|
||||
|
||||
// 2. Prepare context BEFORE update
|
||||
await UpdateService.prepareRestartContext({
|
||||
channelId: interaction.channelId,
|
||||
userId: interaction.user.id,
|
||||
timestamp: Date.now(),
|
||||
runMigrations: needsMigrations,
|
||||
installDependencies: needsInstall
|
||||
});
|
||||
|
||||
// 3. Update UI to "Restarting" state
|
||||
await interaction.editReply({ embeds: [getUpdatingEmbed(needsInstall)] });
|
||||
|
||||
// 4. Perform Update (Danger Zone)
|
||||
await UpdateService.performUpdate(branch);
|
||||
|
||||
// 5. Trigger Restart (if we are still alive)
|
||||
await UpdateService.triggerRestart();
|
||||
|
||||
} else {
|
||||
await confirmation.update({
|
||||
embeds: [getCancelledEmbed()],
|
||||
components: []
|
||||
});
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes("time")) {
|
||||
await interaction.editReply({
|
||||
embeds: [getTimeoutEmbed()],
|
||||
components: []
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Update failed:", error);
|
||||
await interaction.editReply({ embeds: [getErrorEmbed(error)] });
|
||||
}
|
||||
}
|
||||
});
|
||||
87
src/commands/admin/warn.ts
Normal file
87
src/commands/admin/warn.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@/modules/moderation/moderation.service";
|
||||
import {
|
||||
getWarnSuccessEmbed,
|
||||
getModerationErrorEmbed,
|
||||
getUserWarningEmbed
|
||||
} from "@/modules/moderation/moderation.view";
|
||||
import { config } from "@/lib/config";
|
||||
|
||||
export const warn = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("warn")
|
||||
.setDescription("Issue a warning to a user")
|
||||
.addUserOption(option =>
|
||||
option
|
||||
.setName("user")
|
||||
.setDescription("The user to warn")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addStringOption(option =>
|
||||
option
|
||||
.setName("reason")
|
||||
.setDescription("Reason for the warning")
|
||||
.setRequired(true)
|
||||
.setMaxLength(1000)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
const reason = interaction.options.getString("reason", true);
|
||||
|
||||
// Don't allow warning bots
|
||||
if (targetUser.bot) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("You cannot warn bots.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't allow self-warnings
|
||||
if (targetUser.id === interaction.user.id) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("You cannot warn yourself.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Issue the warning via service
|
||||
const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({
|
||||
userId: targetUser.id,
|
||||
username: targetUser.username,
|
||||
moderatorId: interaction.user.id,
|
||||
moderatorName: interaction.user.username,
|
||||
reason,
|
||||
guildName: interaction.guild?.name || undefined,
|
||||
dmTarget: targetUser,
|
||||
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id)
|
||||
});
|
||||
|
||||
// Send success message to moderator
|
||||
await interaction.editReply({
|
||||
embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
|
||||
});
|
||||
|
||||
// Follow up if auto-timeout was issued
|
||||
if (autoTimeoutIssued) {
|
||||
await interaction.followUp({
|
||||
embeds: [getModerationErrorEmbed(
|
||||
`⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.`
|
||||
)],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Warn command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while issuing the warning.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
39
src/commands/admin/warnings.ts
Normal file
39
src/commands/admin/warnings.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@/modules/moderation/moderation.service";
|
||||
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
export const warnings = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("warnings")
|
||||
.setDescription("View active warnings for a user")
|
||||
.addUserOption(option =>
|
||||
option
|
||||
.setName("user")
|
||||
.setDescription("The user to check warnings for")
|
||||
.setRequired(true)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
|
||||
// Get active warnings for the user
|
||||
const activeWarnings = await ModerationService.getUserWarnings(targetUser.id);
|
||||
|
||||
// Display the warnings
|
||||
await interaction.editReply({
|
||||
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Warnings command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching warnings.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, TextChannel, NewsChannel, VoiceChannel } from "discord.js";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
||||
|
||||
export const webhook = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -13,7 +14,7 @@ export const webhook = createCommand({
|
||||
.setRequired(true)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const payloadString = interaction.options.getString("payload", true);
|
||||
let payload;
|
||||
@@ -36,37 +37,17 @@ export const webhook = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
let webhook;
|
||||
try {
|
||||
webhook = await channel.createWebhook({
|
||||
name: `${interaction.client.user.username} - Proxy`,
|
||||
avatar: interaction.client.user.displayAvatarURL(),
|
||||
reason: `Proxy message requested by ${interaction.user.tag}`
|
||||
});
|
||||
|
||||
|
||||
// Support snake_case keys for raw API compatibility
|
||||
if (payload.avatar_url && !payload.avatarURL) {
|
||||
payload.avatarURL = payload.avatar_url;
|
||||
delete payload.avatar_url;
|
||||
}
|
||||
|
||||
await webhook.send(payload);
|
||||
|
||||
await webhook.delete("Proxy message sent");
|
||||
await sendWebhookMessage(
|
||||
channel,
|
||||
payload,
|
||||
interaction.client.user,
|
||||
`Proxy message requested by ${interaction.user.tag}`
|
||||
);
|
||||
|
||||
await interaction.editReply({ content: "Message sent successfully!" });
|
||||
} catch (error) {
|
||||
console.error("Webhook error:", error);
|
||||
// Attempt cleanup if webhook was created but sending failed
|
||||
if (webhook) {
|
||||
try {
|
||||
await webhook.delete("Cleanup after failure");
|
||||
} catch (cleanupError) {
|
||||
console.error("Failed to delete webhook during cleanup:", cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("Failed to send message via webhook. Ensure the bot has 'Manage Webhooks' permission and the payload is valid.", "Delivery Failed")]
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { createBaseEmbed } from "@lib/embeds";
|
||||
|
||||
export const balance = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -15,12 +16,17 @@ export const balance = createCommand({
|
||||
await interaction.deferReply();
|
||||
|
||||
const targetUser = interaction.options.getUser("user") || interaction.user;
|
||||
|
||||
if (targetUser.bot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setAuthor({ name: targetUser.username, iconURL: targetUser.displayAvatarURL() })
|
||||
.setDescription(`**Balance**: ${user.balance || 0n} AU`)
|
||||
.setColor("Yellow");
|
||||
if (!user) throw new Error("Failed to retrieve user data.");
|
||||
|
||||
const embed = createBaseEmbed(undefined, `**Balance**: ${user.balance || 0n} AU`, "Yellow")
|
||||
.setAuthor({ name: targetUser.username, iconURL: targetUser.displayAvatarURL() });
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { economyService } from "@/modules/economy/economy.service";
|
||||
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
|
||||
export const daily = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -11,26 +13,23 @@ export const daily = createCommand({
|
||||
try {
|
||||
const result = await economyService.claimDaily(interaction.user.id);
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("💰 Daily Reward Claimed!")
|
||||
.setDescription(`You claimed **${result.amount}** Astral Units!`)
|
||||
const embed = createSuccessEmbed(`You claimed ** ${result.amount}** Astral Units!${result.isWeekly ? `\n🎉 **Weekly Bonus!** +${result.weeklyBonus} extra!` : ''}`, "💰 Daily Reward Claimed!")
|
||||
.addFields(
|
||||
{ name: "Streak", value: `🔥 ${result.streak} days`, inline: true },
|
||||
{ name: "Next Reward", value: `<t:${Math.floor(result.nextReadyAt.getTime() / 1000)}:R>`, inline: true }
|
||||
{ name: "Weekly Progress", value: `${"🟩".repeat(result.streak % 7 || 7)}${"⬜".repeat(7 - (result.streak % 7 || 7))} (${result.streak % 7 || 7}/7)`, inline: true },
|
||||
{ name: "Next Reward", value: `<t:${Math.floor(result.nextReadyAt.getTime() / 1000)}:R> `, inline: true }
|
||||
)
|
||||
.setColor("Gold")
|
||||
.setTimestamp();
|
||||
.setColor("Gold");
|
||||
|
||||
await interaction.reply({ embeds: [embed] });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error.message.includes("Daily already claimed")) {
|
||||
await interaction.reply({ embeds: [createWarningEmbed(error.message, "Cooldown")], ephemeral: true });
|
||||
return;
|
||||
if (error instanceof UserError) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
||||
} else {
|
||||
console.error("Error claiming daily:", error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An error occurred while claiming your daily reward.")], ephemeral: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
205
src/commands/economy/exam.ts
Normal file
205
src/commands/economy/exam.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { userTimers, users } from "@/db/schema";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import { config } from "@lib/config";
|
||||
import { TimerType } from "@/lib/constants";
|
||||
|
||||
const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
|
||||
const EXAM_TIMER_KEY = 'default';
|
||||
|
||||
interface ExamMetadata {
|
||||
examDay: number;
|
||||
lastXp: string;
|
||||
}
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
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();
|
||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
if (!user) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to retrieve user data.")] });
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
const currentDay = now.getDay();
|
||||
|
||||
try {
|
||||
// 1. Fetch existing timer/exam data
|
||||
const timer = await DrizzleClient.query.userTimers.findFirst({
|
||||
where: and(
|
||||
eq(userTimers.userId, user.id),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
)
|
||||
});
|
||||
|
||||
// 2. First Run Logic
|
||||
if (!timer) {
|
||||
// Set exam day to today
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + 7);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||
|
||||
const metadata: ExamMetadata = {
|
||||
examDay: currentDay,
|
||||
lastXp: (user.xp ?? 0n).toString()
|
||||
};
|
||||
|
||||
await DrizzleClient.insert(userTimers).values({
|
||||
userId: user.id,
|
||||
type: EXAM_TIMER_TYPE,
|
||||
key: EXAM_TIMER_KEY,
|
||||
expiresAt: nextExamDate,
|
||||
metadata: metadata
|
||||
});
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}** (Server Time).\n` +
|
||||
`Come back on <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>) to take your first exam!`,
|
||||
"Exam Registration Successful"
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = timer.metadata as unknown as ExamMetadata;
|
||||
const examDay = metadata.examDay;
|
||||
|
||||
// 3. Cooldown Check
|
||||
const expiresAt = new Date(timer.expiresAt);
|
||||
expiresAt.setHours(0, 0, 0, 0);
|
||||
|
||||
if (now < expiresAt) {
|
||||
// Calculate time remaining
|
||||
const timestamp = Math.floor(expiresAt.getTime() / 1000);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
||||
`Next exam available: <t:${timestamp}:D> (<t:${timestamp}:R>)`
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Day Check
|
||||
if (currentDay !== examDay) {
|
||||
// Calculate next correct exam day to correct the schedule
|
||||
let daysUntil = (examDay - currentDay + 7) % 7;
|
||||
if (daysUntil === 0) daysUntil = 7;
|
||||
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + daysUntil);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||
|
||||
const newMetadata: ExamMetadata = {
|
||||
examDay: examDay,
|
||||
lastXp: (user.xp ?? 0n).toString()
|
||||
};
|
||||
|
||||
await DrizzleClient.update(userTimers)
|
||||
.set({
|
||||
expiresAt: nextExamDate,
|
||||
metadata: newMetadata
|
||||
})
|
||||
.where(and(
|
||||
eq(userTimers.userId, user.id),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
));
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You missed your exam day! Your exam day is **${DAYS[examDay]}** (Server Time).\n` +
|
||||
`You verify your attendance but score a **0**.\n` +
|
||||
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
|
||||
"Exam Failed"
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Reward Calculation
|
||||
const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case
|
||||
const currentXp = user.xp ?? 0n;
|
||||
const diff = currentXp - lastXp;
|
||||
|
||||
// Calculate Reward
|
||||
const multMin = config.economy.exam.multMin;
|
||||
const multMax = config.economy.exam.multMax;
|
||||
const multiplier = Math.random() * (multMax - multMin) + multMin;
|
||||
|
||||
// Allow negative reward? existing description implies "difference", usually gain.
|
||||
// If diff is negative (lost XP?), reward might be 0.
|
||||
let reward = 0n;
|
||||
if (diff > 0n) {
|
||||
reward = BigInt(Math.floor(Number(diff) * multiplier));
|
||||
}
|
||||
|
||||
// 6. Update State
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + 7);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||
|
||||
const newMetadata: ExamMetadata = {
|
||||
examDay: examDay,
|
||||
lastXp: currentXp.toString()
|
||||
};
|
||||
|
||||
await DrizzleClient.transaction(async (tx) => {
|
||||
// Update Timer
|
||||
await tx.update(userTimers)
|
||||
.set({
|
||||
expiresAt: nextExamDate,
|
||||
metadata: newMetadata
|
||||
})
|
||||
.where(and(
|
||||
eq(userTimers.userId, user.id),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
));
|
||||
|
||||
// Add Currency
|
||||
if (reward > 0n) {
|
||||
await tx.update(users)
|
||||
.set({
|
||||
balance: sql`${users.balance} + ${reward}`
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
}
|
||||
});
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`**XP Gained:** ${diff.toString()}\n` +
|
||||
`**Multiplier:** x${multiplier.toFixed(2)}\n` +
|
||||
`**Reward:** ${reward.toString()} Currency\n\n` +
|
||||
`See you next week: <t:${nextExamTimestamp}:D>`,
|
||||
"Exam Passed!"
|
||||
)]
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
||||
} else {
|
||||
console.error("Error in exam command:", error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
|
||||
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
||||
import { economyService } from "@/modules/economy/economy.service";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { GameConfig } from "@/config/game";
|
||||
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
|
||||
import { config } from "@/lib/config";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
|
||||
export const pay = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -22,38 +24,46 @@ export const pay = createCommand({
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
const targetUser = await userService.getOrCreateUser(interaction.options.getUser("user", true).id, interaction.options.getUser("user", true).username);
|
||||
const amount = BigInt(interaction.options.getInteger("amount", true));
|
||||
const senderId = interaction.user.id;
|
||||
const receiverId = targetUser.id;
|
||||
const discordUser = interaction.options.getUser("user", true);
|
||||
|
||||
if (amount < GameConfig.economy.transfers.minAmount) {
|
||||
await interaction.reply({ embeds: [createWarningEmbed(`Amount must be at least ${GameConfig.economy.transfers.minAmount}.`)], ephemeral: true });
|
||||
if (discordUser.bot) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed("You cannot send money to bots.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
if (senderId === receiverId) {
|
||||
await interaction.reply({ embeds: [createWarningEmbed("You cannot pay yourself.")], ephemeral: true });
|
||||
const amount = BigInt(interaction.options.getInteger("amount", true));
|
||||
const senderId = interaction.user.id;
|
||||
if (!targetUser) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed("User not found.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
const receiverId = targetUser.id;
|
||||
|
||||
if (amount < config.economy.transfers.minAmount) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(`Amount must be at least ${config.economy.transfers.minAmount}.`)], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
if (senderId === receiverId.toString()) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed("You cannot pay yourself.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await economyService.transfer(senderId, receiverId, amount);
|
||||
await interaction.deferReply();
|
||||
await economyService.transfer(senderId, receiverId.toString(), amount);
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("💸 Transfer Successful")
|
||||
.setDescription(`Successfully sent **${amount}** Astral Units to <@${targetUser.id}>.`)
|
||||
.setColor("Green")
|
||||
.setTimestamp();
|
||||
|
||||
await interaction.reply({ embeds: [embed] });
|
||||
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
|
||||
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error.message.includes("Insufficient funds")) {
|
||||
await interaction.reply({ embeds: [createWarningEmbed("Insufficient funds.")], ephemeral: true });
|
||||
return;
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error sending payment:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||
}
|
||||
console.error(error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("Transfer failed.")], ephemeral: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import {
|
||||
SlashCommandBuilder,
|
||||
EmbedBuilder,
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
ComponentType,
|
||||
type BaseGuildTextChannel,
|
||||
type ButtonInteraction,
|
||||
PermissionFlagsBits,
|
||||
MessageFlags
|
||||
} from "discord.js";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||
import type { items } from "@db/schema";
|
||||
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
|
||||
|
||||
export const sell = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("sell")
|
||||
.setDescription("Post an item for sale in the current channel so regular users can buy it")
|
||||
.addNumberOption(option =>
|
||||
option.setName("itemid")
|
||||
.setDescription("The ID of the item to sell")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addChannelOption(option =>
|
||||
option.setName("channel")
|
||||
.setDescription("The channel to post the item in")
|
||||
.setRequired(false)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const itemId = interaction.options.getNumber("itemid", true);
|
||||
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
|
||||
|
||||
if (!targetChannel || !targetChannel.isSendable()) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const item = await inventoryService.getItem(itemId);
|
||||
if (!item) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!item.price) {
|
||||
await interaction.editReply({ content: "", embeds: [createWarningEmbed(`Item "${item.name}" is not for sale (no price set).`)] });
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`Item for sale: ${item.name}`)
|
||||
.setDescription(item.description || "No description available.")
|
||||
.addFields({ name: "Price", value: `${item.price} 🪙`, inline: true })
|
||||
.setColor("Yellow")
|
||||
.setThumbnail(item.iconUrl || null)
|
||||
.setImage(item.imageUrl || null);
|
||||
|
||||
const buyButton = new ButtonBuilder()
|
||||
.setCustomId("buy")
|
||||
.setLabel("Buy")
|
||||
.setStyle(ButtonStyle.Success);
|
||||
|
||||
const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton);
|
||||
|
||||
try {
|
||||
const message = await targetChannel.send({ embeds: [embed], components: [actionRow] });
|
||||
await interaction.editReply({ content: `Item posted in ${targetChannel}.` });
|
||||
|
||||
// Create a collector on the specific message
|
||||
const collector = message.createMessageComponentCollector({
|
||||
componentType: ComponentType.Button,
|
||||
filter: (i) => i.customId === "buy",
|
||||
});
|
||||
|
||||
collector.on("collect", async (i) => {
|
||||
await handleBuyInteraction(i, item);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to send sell message:", error);
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Failed to post the item for sale.")] });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function handleBuyInteraction(interaction: ButtonInteraction, item: typeof items.$inferSelect) {
|
||||
try {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const userId = interaction.user.id;
|
||||
const user = await userService.getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed("User profile not found.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
if ((user.balance ?? 0n) < (item.price ?? 0n)) {
|
||||
await interaction.editReply({ content: "", embeds: [createWarningEmbed(`You don't have enough money! You need ${item.price} 🪙.`)] });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await inventoryService.buyItem(userId, item.id, 1n);
|
||||
|
||||
if (!result.success) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Transaction failed. Please try again.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.editReply({ content: `Successfully bought **${item.name}** for ${item.price} 🪙!` });
|
||||
} catch (error) {
|
||||
console.error("Error processing purchase:", error);
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed("An error occurred while processing your purchase.")] });
|
||||
} else {
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An error occurred while processing your purchase.")], flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, EmbedBuilder, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, ThreadAutoArchiveDuration } from "discord.js";
|
||||
import { TradeService } from "@/modules/trade/trade.service";
|
||||
import { SlashCommandBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags } from "discord.js";
|
||||
import { tradeService } from "@/modules/trade/trade.service";
|
||||
import { getTradeDashboard } from "@/modules/trade/trade.view";
|
||||
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
|
||||
|
||||
export const trade = createCommand({
|
||||
@@ -16,19 +17,19 @@ export const trade = createCommand({
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
|
||||
if (targetUser.id === interaction.user.id) {
|
||||
await interaction.reply({ embeds: [createWarningEmbed("You cannot trade with yourself.")], ephemeral: true });
|
||||
await interaction.reply({ embeds: [createWarningEmbed("You cannot trade with yourself.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetUser.bot) {
|
||||
await interaction.reply({ embeds: [createWarningEmbed("You cannot trade with bots.")], ephemeral: true });
|
||||
await interaction.reply({ embeds: [createWarningEmbed("You cannot trade with bots.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create Thread
|
||||
const channel = interaction.channel;
|
||||
if (!channel || channel.type === ChannelType.DM) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed("Cannot start trade in DMs.")], ephemeral: true });
|
||||
await interaction.reply({ embeds: [createErrorEmbed("Cannot start trade in DMs.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -53,37 +54,20 @@ export const trade = createCommand({
|
||||
} catch (err) {
|
||||
console.error("Failed to delete setup message", err);
|
||||
}
|
||||
await interaction.followUp({ embeds: [createErrorEmbed("Failed to create trade thread. Check permissions.")], ephemeral: true });
|
||||
await interaction.followUp({ embeds: [createErrorEmbed("Failed to create trade thread. Check permissions.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup Session
|
||||
TradeService.createSession(thread.id,
|
||||
const session = tradeService.createSession(thread.id,
|
||||
{ id: interaction.user.id, username: interaction.user.username },
|
||||
{ id: targetUser.id, username: targetUser.username }
|
||||
);
|
||||
|
||||
// Send Dashboard to Thread
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("🤝 Trading Session")
|
||||
.setDescription(`Trade started between ${interaction.user} and ${targetUser}.\nUse the controls below to build your offer.`)
|
||||
.setColor(0xFFD700)
|
||||
.addFields(
|
||||
{ name: interaction.user.username, value: "*Empty Offer*", inline: true },
|
||||
{ name: targetUser.username, value: "*Empty Offer*", inline: true }
|
||||
)
|
||||
.setFooter({ text: "Both parties must click Lock to confirm trade." });
|
||||
const dashboard = getTradeDashboard(session);
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder().setCustomId('trade_add_item').setLabel('Add Item').setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId('trade_add_money').setLabel('Add Money').setStyle(ButtonStyle.Success),
|
||||
new ButtonBuilder().setCustomId('trade_remove_item').setLabel('Remove Item').setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId('trade_lock').setLabel('Lock / Unlock').setStyle(ButtonStyle.Primary),
|
||||
new ButtonBuilder().setCustomId('trade_cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger),
|
||||
);
|
||||
|
||||
await thread.send({ content: `${interaction.user} ${targetUser} Welcome to your trading session!`, embeds: [embed], components: [row] });
|
||||
await thread.send({ content: `${interaction.user} ${targetUser} Welcome to your trading session!`, ...dashboard });
|
||||
|
||||
// Update original reply
|
||||
await interaction.editReply({ content: `✅ Trade opened: <#${thread.id}>` });
|
||||
|
||||
29
src/commands/feedback/feedback.ts
Normal file
29
src/commands/feedback/feedback.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { config } from "@/lib/config";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { getFeedbackTypeMenu } from "@/modules/feedback/feedback.view";
|
||||
|
||||
export const feedback = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("feedback")
|
||||
.setDescription("Submit feedback, feature requests, or bug reports"),
|
||||
execute: async (interaction) => {
|
||||
// Check if feedback channel is configured
|
||||
if (!config.feedbackChannelId) {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("Feedback system is not configured. Please contact an administrator.")],
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Show feedback type selection menu
|
||||
const menu = getFeedbackTypeMenu();
|
||||
await interaction.reply({
|
||||
content: "## 🌟 Share Your Thoughts\n\nThank you for helping improve Aurora! Please select the type of feedback you'd like to submit:",
|
||||
...menu,
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { createWarningEmbed } from "@lib/embeds";
|
||||
import { getInventoryEmbed } from "@/modules/inventory/inventory.view";
|
||||
|
||||
export const inventory = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -17,23 +18,26 @@ export const inventory = createCommand({
|
||||
await interaction.deferReply();
|
||||
|
||||
const targetUser = interaction.options.getUser("user") || interaction.user;
|
||||
|
||||
if (targetUser.bot) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("Bots do not have inventories.", "Inventory Check")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
||||
const items = await inventoryService.getInventory(user.id);
|
||||
if (!user) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("Failed to load user data.", "Error")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const items = await inventoryService.getInventory(user.id.toString());
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("Inventory is empty.", `${user.username}'s Inventory`)] });
|
||||
return;
|
||||
}
|
||||
|
||||
const description = items.map(entry => {
|
||||
return `**${entry.item.name}** x${entry.quantity}`;
|
||||
}).join("\n");
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`${user.username}'s Inventory`)
|
||||
.setDescription(description)
|
||||
.setColor("Blue")
|
||||
.setTimestamp();
|
||||
const embed = getInventoryEmbed(items, user.username);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
|
||||
79
src/commands/inventory/use.ts
Normal file
79
src/commands/inventory/use.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
||||
import type { ItemUsageData } from "@/lib/types";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { config } from "@/lib/config";
|
||||
|
||||
export const use = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("use")
|
||||
.setDescription("Use an item from your inventory")
|
||||
.addNumberOption(option =>
|
||||
option.setName("item")
|
||||
.setDescription("The item to use")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
|
||||
const itemId = interaction.options.getNumber("item", true);
|
||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
if (!user) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await inventoryService.useItem(user.id.toString(), itemId);
|
||||
|
||||
const usageData = result.usageData;
|
||||
if (usageData) {
|
||||
for (const effect of usageData.effects) {
|
||||
if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') {
|
||||
try {
|
||||
const member = await interaction.guild?.members.fetch(user.id.toString());
|
||||
if (member) {
|
||||
if (effect.type === 'TEMP_ROLE') {
|
||||
await member.roles.add(effect.roleId);
|
||||
} else if (effect.type === 'COLOR_ROLE') {
|
||||
// Remove existing color roles
|
||||
const rolesToRemove = config.colorRoles.filter(r => member.roles.cache.has(r));
|
||||
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
|
||||
await member.roles.add(effect.roleId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to assign role in /use command:", e);
|
||||
result.results.push("⚠️ Failed to assign role (Check bot permissions)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const embed = getItemUseResultEmbed(result.results, result.item);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error using item:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred while using the item.")] });
|
||||
}
|
||||
}
|
||||
},
|
||||
autocomplete: async (interaction) => {
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
const userId = interaction.user.id;
|
||||
|
||||
const results = await inventoryService.getAutocompleteItems(userId, focusedValue);
|
||||
|
||||
await interaction.respond(results);
|
||||
}
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import { users } from "@/db/schema";
|
||||
import { desc } from "drizzle-orm";
|
||||
import { users, items, inventory } from "@/db/schema";
|
||||
import { desc, sql, eq } from "drizzle-orm";
|
||||
import { createWarningEmbed } from "@lib/embeds";
|
||||
import { getLeaderboardEmbed } from "@/modules/leveling/leveling.view";
|
||||
|
||||
export const leaderboard = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -11,40 +12,49 @@ export const leaderboard = createCommand({
|
||||
.setDescription("View the top players")
|
||||
.addStringOption(option =>
|
||||
option.setName("type")
|
||||
.setDescription("Sort by XP or Balance")
|
||||
.setDescription("Sort by XP, Balance, or Net Worth")
|
||||
.setRequired(true)
|
||||
.addChoices(
|
||||
{ name: "Level / XP", value: "xp" },
|
||||
{ name: "Balance", value: "balance" }
|
||||
{ name: "Balance", value: "balance" },
|
||||
{ name: "Net Worth", value: "networth" }
|
||||
)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
|
||||
const type = interaction.options.getString("type", true);
|
||||
const isXp = type === "xp";
|
||||
|
||||
const leaders = await DrizzleClient.query.users.findMany({
|
||||
orderBy: isXp ? desc(users.xp) : desc(users.balance),
|
||||
limit: 10
|
||||
});
|
||||
let leaders;
|
||||
|
||||
if (type === 'networth') {
|
||||
leaders = await DrizzleClient.select({
|
||||
username: users.username,
|
||||
level: users.level,
|
||||
xp: users.xp,
|
||||
balance: users.balance,
|
||||
netWorth: sql<bigint>`${users.balance} + COALESCE(SUM(${items.price} * ${inventory.quantity}), 0)`.as('net_worth')
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(inventory, eq(users.id, inventory.userId))
|
||||
.leftJoin(items, eq(inventory.itemId, items.id))
|
||||
.groupBy(users.id)
|
||||
.orderBy(desc(sql`net_worth`))
|
||||
.limit(10);
|
||||
} else {
|
||||
const isXp = type === "xp";
|
||||
leaders = await DrizzleClient.query.users.findMany({
|
||||
orderBy: isXp ? desc(users.xp) : desc(users.balance),
|
||||
limit: 10
|
||||
});
|
||||
}
|
||||
|
||||
if (leaders.length === 0) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("No users found.", "Leaderboard")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const description = leaders.map((user, index) => {
|
||||
const medal = index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : `${index + 1}.`;
|
||||
const value = isXp ? `Lvl ${user.level} (${user.xp} XP)` : `${user.balance} 🪙`;
|
||||
return `${medal} **${user.username}** — ${value}`;
|
||||
}).join("\n");
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(isXp ? "🏆 XP Leaderboard" : "💰 Richest Players")
|
||||
.setDescription(description)
|
||||
.setColor("Gold")
|
||||
.setTimestamp();
|
||||
const embed = getLeaderboardEmbed(leaders, type as 'xp' | 'balance' | 'networth');
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
|
||||
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
||||
import { questService } from "@/modules/quest/quest.service";
|
||||
import { createWarningEmbed } from "@lib/embeds";
|
||||
import { getQuestListEmbed } from "@/modules/quest/quest.view";
|
||||
|
||||
export const quests = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("quests")
|
||||
.setDescription("View your active quests"),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const userQuests = await questService.getUserQuests(interaction.user.id);
|
||||
|
||||
@@ -17,24 +18,7 @@ export const quests = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("📜 Quest Log")
|
||||
.setColor("Blue")
|
||||
.setTimestamp();
|
||||
|
||||
userQuests.forEach(entry => {
|
||||
const status = entry.completedAt ? "✅ Completed" : "In Progress";
|
||||
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
||||
const rewardStr = [];
|
||||
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
|
||||
if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`);
|
||||
|
||||
embed.addFields({
|
||||
name: `${entry.quest.name} (${status})`,
|
||||
value: `${entry.quest.description}\n**Rewards:** ${rewardStr.join(", ")}\n**Progress:** ${entry.progress}%`,
|
||||
inline: false
|
||||
});
|
||||
});
|
||||
const embed = getQuestListEmbed(userQuests);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { createCommand } from "@lib/utils";
|
||||
import { KyokoClient } from "@lib/KyokoClient";
|
||||
import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
|
||||
export const reload = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("reload")
|
||||
.setDescription("Reloads all commands")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
try {
|
||||
await KyokoClient.loadCommands(true);
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("✅ System Reloaded")
|
||||
.setDescription(`Successfully reloaded ${KyokoClient.commands.size} commands.`)
|
||||
.setColor("Green");
|
||||
|
||||
// Deploy commands
|
||||
await KyokoClient.deployCommands();
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An error occurred while reloading commands. Check console for details.", "Reload Failed")] });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, AttachmentBuilder } from "discord.js";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { generateStudentIdCard } from "@/graphics/studentID";
|
||||
import { createWarningEmbed } from "@/lib/embeds";
|
||||
|
||||
export const profile = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -16,6 +17,12 @@ export const profile = createCommand({
|
||||
await interaction.deferReply();
|
||||
|
||||
const targetUser = interaction.options.getUser("user") || interaction.user;
|
||||
|
||||
if (targetUser.bot) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("Bots do not have profiles.", "Profile Check")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
||||
|
||||
const cardBuffer = await generateStudentIdCard({
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
export const GameConfig = {
|
||||
leveling: {
|
||||
// Curve: Base * (Level ^ Exponent)
|
||||
base: 100,
|
||||
exponent: 2.5,
|
||||
|
||||
chat: {
|
||||
cooldownMs: 60000, // 1 minute
|
||||
minXp: 15,
|
||||
maxXp: 25,
|
||||
}
|
||||
},
|
||||
economy: {
|
||||
daily: {
|
||||
amount: 100n,
|
||||
streakBonus: 10n,
|
||||
cooldownMs: 24 * 60 * 60 * 1000, // 24 hours
|
||||
},
|
||||
transfers: {
|
||||
// Future use
|
||||
allowSelfTransfer: false,
|
||||
minAmount: 1n,
|
||||
}
|
||||
},
|
||||
inventory: {
|
||||
maxStackSize: 999n,
|
||||
maxSlots: 50,
|
||||
}
|
||||
} as const;
|
||||
43
src/db/indexes.test.ts
Normal file
43
src/db/indexes.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { expect, test, describe } from "bun:test";
|
||||
import { postgres } from "../lib/DrizzleClient";
|
||||
|
||||
describe("Database Indexes", () => {
|
||||
test("should have indexes on users table", async () => {
|
||||
const result = await postgres`
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'users'
|
||||
`;
|
||||
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
|
||||
expect(indexNames).toContain("users_balance_idx");
|
||||
expect(indexNames).toContain("users_level_xp_idx");
|
||||
});
|
||||
|
||||
test("should have index on transactions table", async () => {
|
||||
const result = await postgres`
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'transactions'
|
||||
`;
|
||||
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
|
||||
expect(indexNames).toContain("transactions_created_at_idx");
|
||||
});
|
||||
|
||||
test("should have indexes on moderation_cases table", async () => {
|
||||
const result = await postgres`
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'moderation_cases'
|
||||
`;
|
||||
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
|
||||
expect(indexNames).toContain("moderation_cases_user_id_idx");
|
||||
expect(indexNames).toContain("moderation_cases_case_id_idx");
|
||||
});
|
||||
|
||||
test("should have indexes on user_timers table", async () => {
|
||||
const result = await postgres`
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'user_timers'
|
||||
`;
|
||||
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
|
||||
expect(indexNames).toContain("user_timers_expires_at_idx");
|
||||
expect(indexNames).toContain("user_timers_lookup_idx");
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
text,
|
||||
integer,
|
||||
primaryKey,
|
||||
index,
|
||||
bigserial,
|
||||
check
|
||||
} from 'drizzle-orm/pg-core';
|
||||
@@ -21,6 +22,7 @@ export const classes = pgTable('classes', {
|
||||
id: bigint('id', { mode: 'bigint' }).primaryKey(),
|
||||
name: varchar('name', { length: 255 }).unique().notNull(),
|
||||
balance: bigint('balance', { mode: 'bigint' }).default(0n),
|
||||
roleId: varchar('role_id', { length: 255 }),
|
||||
});
|
||||
|
||||
// 2. Users
|
||||
@@ -40,7 +42,11 @@ export const users = pgTable('users', {
|
||||
settings: jsonb('settings').default({}),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
}, (table) => [
|
||||
index('users_username_idx').on(table.username),
|
||||
index('users_balance_idx').on(table.balance),
|
||||
index('users_level_xp_idx').on(table.level, table.xp),
|
||||
]);
|
||||
|
||||
// 3. Items
|
||||
export const items = pgTable('items', {
|
||||
@@ -81,7 +87,9 @@ export const transactions = pgTable('transactions', {
|
||||
type: varchar('type', { length: 50 }).notNull(),
|
||||
description: text('description'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
}, (table) => [
|
||||
index('transactions_created_at_idx').on(table.createdAt),
|
||||
]);
|
||||
|
||||
export const itemTransactions = pgTable('item_transactions', {
|
||||
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
|
||||
@@ -128,10 +136,43 @@ export const userTimers = pgTable('user_timers', {
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
metadata: jsonb('metadata').default({}), // Store channelId, specific buff amounts, etc.
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.userId, table.type, table.key] })
|
||||
primaryKey({ columns: [table.userId, table.type, table.key] }),
|
||||
index('user_timers_expires_at_idx').on(table.expiresAt),
|
||||
index('user_timers_lookup_idx').on(table.userId, table.type, table.key),
|
||||
]);
|
||||
// 9. Lootdrops
|
||||
export const lootdrops = pgTable('lootdrops', {
|
||||
messageId: varchar('message_id', { length: 255 }).primaryKey(),
|
||||
channelId: varchar('channel_id', { length: 255 }).notNull(),
|
||||
rewardAmount: integer('reward_amount').notNull(),
|
||||
currency: varchar('currency', { length: 50 }).notNull(),
|
||||
claimedBy: bigint('claimed_by', { mode: 'bigint' }).references(() => users.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
});
|
||||
|
||||
// 10. Moderation Cases
|
||||
export const moderationCases = pgTable('moderation_cases', {
|
||||
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
|
||||
caseId: varchar('case_id', { length: 50 }).unique().notNull(),
|
||||
type: varchar('type', { length: 20 }).notNull(), // 'warn', 'timeout', 'kick', 'ban', 'note', 'prune'
|
||||
userId: bigint('user_id', { mode: 'bigint' }).notNull(),
|
||||
username: varchar('username', { length: 255 }).notNull(),
|
||||
moderatorId: bigint('moderator_id', { mode: 'bigint' }).notNull(),
|
||||
moderatorName: varchar('moderator_name', { length: 255 }).notNull(),
|
||||
reason: text('reason').notNull(),
|
||||
metadata: jsonb('metadata').default({}),
|
||||
active: boolean('active').default(true).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
|
||||
resolvedBy: bigint('resolved_by', { mode: 'bigint' }),
|
||||
resolvedReason: text('resolved_reason'),
|
||||
}, (table) => [
|
||||
index('moderation_cases_user_id_idx').on(table.userId),
|
||||
index('moderation_cases_case_id_idx').on(table.caseId),
|
||||
]);
|
||||
|
||||
// --- RELATIONS ---
|
||||
|
||||
|
||||
export const classesRelations = relations(classes, ({ many }) => ({
|
||||
users: many(users),
|
||||
@@ -205,4 +246,19 @@ export const itemTransactionsRelations = relations(itemTransactions, ({ one }) =
|
||||
fields: [itemTransactions.itemId],
|
||||
references: [items.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const moderationCasesRelations = relations(moderationCases, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [moderationCases.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
moderator: one(users, {
|
||||
fields: [moderationCases.moderatorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
resolver: one(users, {
|
||||
fields: [moderationCases.resolvedBy],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
@@ -1,12 +1,34 @@
|
||||
import { Events } from "discord.js";
|
||||
import type { Event } from "@lib/types";
|
||||
import { config } from "@lib/config";
|
||||
import { userService } from "@modules/user/user.service";
|
||||
|
||||
// Visitor role
|
||||
const event: Event<Events.GuildMemberAdd> = {
|
||||
name: Events.GuildMemberAdd,
|
||||
execute: async (member) => {
|
||||
const role = member.guild.roles.cache.find(role => role.name === "Visitor");
|
||||
if (!role) return;
|
||||
await member.roles.add(role);
|
||||
console.log(`👤 New member joined: ${member.user.tag} (${member.id})`);
|
||||
try {
|
||||
const user = await userService.getUserById(member.id);
|
||||
|
||||
if (user && user.class) {
|
||||
console.log(`🔄 Returning student detected: ${member.user.tag}`);
|
||||
await member.roles.remove(config.visitorRole);
|
||||
await member.roles.add(config.studentRole);
|
||||
|
||||
if (user.class.roleId) {
|
||||
await member.roles.add(user.class.roleId);
|
||||
console.log(`Restored class role ${user.class.name} to ${member.user.tag}`);
|
||||
}
|
||||
console.log(`Restored student role to ${member.user.tag}`);
|
||||
} else {
|
||||
await member.roles.add(config.visitorRole);
|
||||
console.log(`Assigned visitor role to ${member.user.tag}`);
|
||||
}
|
||||
console.log(`User Roles: ${member.roles.cache.map(role => role.name).join(", ")}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to handle role assignment for ${member.user.tag}:`, error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,51 +1,20 @@
|
||||
import { Events, MessageFlags } from "discord.js";
|
||||
import { KyokoClient } from "@lib/KyokoClient";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { Events } from "discord.js";
|
||||
import { ComponentInteractionHandler, AutocompleteHandler, CommandHandler } from "@/lib/handlers";
|
||||
import type { Event } from "@lib/types";
|
||||
|
||||
const event: Event<Events.InteractionCreate> = {
|
||||
name: Events.InteractionCreate,
|
||||
execute: async (interaction) => {
|
||||
// Handle Trade Interactions
|
||||
if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
|
||||
if (interaction.customId.startsWith("trade_") || interaction.customId === "amount") {
|
||||
await import("@/modules/trade/trade.interaction").then(m => m.handleTradeInteraction(interaction));
|
||||
return;
|
||||
}
|
||||
return ComponentInteractionHandler.handle(interaction);
|
||||
}
|
||||
|
||||
if (!interaction.isChatInputCommand()) return;
|
||||
|
||||
const command = KyokoClient.commands.get(interaction.commandName);
|
||||
|
||||
if (!command) {
|
||||
console.error(`No command matching ${interaction.commandName} was found.`);
|
||||
return;
|
||||
if (interaction.isAutocomplete()) {
|
||||
return AutocompleteHandler.handle(interaction);
|
||||
}
|
||||
|
||||
|
||||
// Ensure user exists in database
|
||||
try {
|
||||
const user = await userService.getUserById(interaction.user.id);
|
||||
if (!user) {
|
||||
console.log(`🆕 Creating new user entry for ${interaction.user.tag}`);
|
||||
await userService.createUser(interaction.user.id, interaction.user.username);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check/create user:", error);
|
||||
}
|
||||
|
||||
try {
|
||||
await command.execute(interaction);
|
||||
} catch (error) {
|
||||
console.error(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 });
|
||||
}
|
||||
if (interaction.isChatInputCommand()) {
|
||||
return CommandHandler.handle(interaction);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,6 +13,9 @@ const event: Event<Events.MessageCreate> = {
|
||||
if (!user) return;
|
||||
|
||||
levelingService.processChatXp(message.author.id);
|
||||
|
||||
// Activity Tracking for Lootdrops
|
||||
import("@/modules/economy/lootdrop.service").then(m => m.lootdropService.processMessage(message));
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ const event: Event<Events.ClientReady> = {
|
||||
execute: async (c) => {
|
||||
console.log(`Ready! Logged in as ${c.user.tag}`);
|
||||
schedulerService.start();
|
||||
|
||||
// Handle post-update tasks
|
||||
const { UpdateService } = await import("@/modules/admin/update.service");
|
||||
await UpdateService.handlePostRestart(c);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
135
src/graphics/lootdrop.ts
Normal file
135
src/graphics/lootdrop.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas';
|
||||
import path from 'path';
|
||||
|
||||
// Register Fonts (same as studentID.ts)
|
||||
const fontDir = path.join(process.cwd(), 'src', 'assets', 'fonts');
|
||||
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold');
|
||||
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
|
||||
|
||||
export async function generateLootdropCard(amount: number, currency: string): Promise<Buffer> {
|
||||
const templatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'lootdrop', 'template.png');
|
||||
const template = await loadImage(templatePath);
|
||||
|
||||
const canvas = createCanvas(template.width, template.height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Draw Template
|
||||
ctx.drawImage(template, 0, 0);
|
||||
|
||||
// Draw Lootdrop Text (Title-ish)
|
||||
ctx.save();
|
||||
ctx.font = '48px IBMPlexSansCondensed-SemiBold';
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = 'rgba(255, 255, 255, 0.5)';
|
||||
// Center of lower half (512-1024) is roughly 768
|
||||
ctx.fillText('A STAR IS FALLING', canvas.width / 2, 660);
|
||||
ctx.restore();
|
||||
|
||||
// Draw Reward Amount
|
||||
ctx.save();
|
||||
ctx.font = '72px IBMPlexMono-Bold';
|
||||
ctx.fillStyle = '#DAC7A1';
|
||||
ctx.textAlign = 'center';
|
||||
//ctx.shadowBlur = 15;
|
||||
//ctx.shadowColor = 'rgba(255, 215, 0, 0.8)';
|
||||
ctx.fillText(`${amount} ${currency}`, canvas.width / 2, 760); // Below title
|
||||
ctx.restore();
|
||||
|
||||
// Crop the image by 64px on all sides
|
||||
const croppedWidth = template.width - 128;
|
||||
const croppedHeight = template.height - 128;
|
||||
const croppedCanvas = createCanvas(croppedWidth, croppedHeight);
|
||||
const croppedCtx = croppedCanvas.getContext('2d');
|
||||
|
||||
// Draw the original canvas onto the cropped canvas, shifted by -64
|
||||
croppedCtx.drawImage(canvas, -64, -64);
|
||||
|
||||
return croppedCanvas.toBuffer('image/png');
|
||||
}
|
||||
|
||||
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 template = await loadImage(templatePath);
|
||||
|
||||
const canvas = createCanvas(template.width, template.height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Draw Template
|
||||
ctx.drawImage(template, 0, 0);
|
||||
|
||||
// Add a colored overlay to signify "claimed"
|
||||
ctx.fillStyle = 'rgba(10, 10, 20, 0.85)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw Claimed Text (Title-ish)
|
||||
ctx.save();
|
||||
ctx.font = '48px IBMPlexSansCondensed-SemiBold';
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = 'rgba(255, 255, 255, 0.5)';
|
||||
ctx.fillText('STAR CLAIMED', canvas.width / 2, 660);
|
||||
ctx.restore();
|
||||
|
||||
// Draw "by username" with Avatar
|
||||
ctx.save();
|
||||
ctx.font = '36px IBMPlexSansCondensed-SemiBold';
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
|
||||
// Calculate layout for centering Group (Avatar + Text)
|
||||
const text = `by ${username}`;
|
||||
const textMetrics = ctx.measureText(text);
|
||||
const textWidth = textMetrics.width;
|
||||
const avatarSize = 50;
|
||||
const gap = 15;
|
||||
const totalWidth = avatarSize + gap + textWidth;
|
||||
|
||||
const startX = (canvas.width - totalWidth) / 2;
|
||||
const baselineY = 830;
|
||||
|
||||
// Draw Avatar
|
||||
try {
|
||||
const avatar = await loadImage(avatarUrl);
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
// Center avatar vertically relative to text roughly (baseline - ~half cap height)
|
||||
// 36px text ~ 27px cap height. Center roughly at baselineY - 14
|
||||
const avatarCenterY = baselineY - 14;
|
||||
ctx.arc(startX + avatarSize / 2, avatarCenterY, avatarSize / 2, 0, Math.PI * 2);
|
||||
ctx.closePath();
|
||||
ctx.clip();
|
||||
ctx.drawImage(avatar, startX, avatarCenterY - avatarSize / 2, avatarSize, avatarSize);
|
||||
ctx.restore();
|
||||
} catch (e) {
|
||||
// Fallback if avatar fails to load, just don't draw it (or maybe shift text?)
|
||||
// For now, let's just proceed, the text will be off-center if avatar is missing but that's acceptable edge case
|
||||
console.error("Failed to load avatar", e);
|
||||
}
|
||||
|
||||
// Draw Text
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(text, startX + avatarSize + gap, baselineY);
|
||||
ctx.restore();
|
||||
|
||||
ctx.save();
|
||||
ctx.font = '72px IBMPlexMono-Bold'; // Match Amount size
|
||||
ctx.fillStyle = '#E6D2B5'; // Lighter gold/beige for better contrast
|
||||
ctx.textAlign = 'center';
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = 'rgba(0, 0, 0, 0.8)'; // Dark shadow for contrast
|
||||
ctx.fillText(`${amount} ${currency}`, canvas.width / 2, 760); // Same position as Unclaimed Amount
|
||||
ctx.restore();
|
||||
|
||||
// Crop the image by 64px on all sides
|
||||
const croppedWidth = template.width - 128;
|
||||
const croppedHeight = template.height - 128;
|
||||
const croppedCanvas = createCanvas(croppedWidth, croppedHeight);
|
||||
const croppedCtx = croppedCanvas.getContext('2d');
|
||||
|
||||
// Draw the original canvas onto the cropped canvas, shifted by -64
|
||||
croppedCtx.drawImage(canvas, -64, -64);
|
||||
|
||||
return croppedCanvas.toBuffer('image/png');
|
||||
}
|
||||
@@ -97,9 +97,12 @@ export async function generateStudentIdCard(data: StudentCardData): Promise<Buff
|
||||
ctx.restore();
|
||||
|
||||
// Draw XP Bar
|
||||
const xpForNextLevel = levelingService.getXpForLevel(data.level);
|
||||
const xpForThisLevel = levelingService.getXpForNextLevel(data.level); // The total size of the current level bucket
|
||||
const xpAtStartOfLevel = levelingService.getXpToReachLevel(data.level); // The accumulated XP when this level started
|
||||
const currentLevelProgress = Number(data.xp) - xpAtStartOfLevel; // How much XP into this level
|
||||
|
||||
const xpBarMaxWidth = 382;
|
||||
const xpBarWidth = xpBarMaxWidth * Number(data.xp) / Number(xpForNextLevel);
|
||||
const xpBarWidth = Math.max(0, Math.min(xpBarMaxWidth, xpBarMaxWidth * currentLevelProgress / xpForThisLevel));
|
||||
const xpBarHeight = 3;
|
||||
ctx.save();
|
||||
ctx.fillStyle = '#B3AD93';
|
||||
|
||||
25
src/index.ts
25
src/index.ts
@@ -1,14 +1,27 @@
|
||||
import { KyokoClient } from "@lib/KyokoClient";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { env } from "@lib/env";
|
||||
|
||||
// Load commands & events
|
||||
await KyokoClient.loadCommands();
|
||||
await KyokoClient.loadEvents();
|
||||
await KyokoClient.deployCommands();
|
||||
import { webServer } from "./web/src";
|
||||
|
||||
// Load commands & events
|
||||
await AuroraClient.loadCommands();
|
||||
await AuroraClient.loadEvents();
|
||||
await AuroraClient.deployCommands();
|
||||
|
||||
webServer.start();
|
||||
console.log("Web server is running on http://localhost:3000")
|
||||
|
||||
// login with the token from .env
|
||||
if (!env.DISCORD_BOT_TOKEN) {
|
||||
throw new Error("❌ DISCORD_BOT_TOKEN is not set in environment variables.");
|
||||
}
|
||||
KyokoClient.login(env.DISCORD_BOT_TOKEN);
|
||||
AuroraClient.login(env.DISCORD_BOT_TOKEN);
|
||||
|
||||
// Handle graceful shutdown
|
||||
const shutdownHandler = () => {
|
||||
webServer.stop();
|
||||
AuroraClient.shutdown();
|
||||
};
|
||||
|
||||
process.on("SIGINT", shutdownHandler);
|
||||
process.on("SIGTERM", shutdownHandler);
|
||||
121
src/lib/BotClient.ts
Normal file
121
src/lib/BotClient.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js";
|
||||
import { join } from "node:path";
|
||||
import type { Command } from "@lib/types";
|
||||
import { env } from "@lib/env";
|
||||
import { CommandLoader } from "@lib/loaders/CommandLoader";
|
||||
import { EventLoader } from "@lib/loaders/EventLoader";
|
||||
|
||||
export class Client extends DiscordClient {
|
||||
|
||||
commands: Collection<string, Command>;
|
||||
lastCommandTimestamp: number | null = null;
|
||||
private commandLoader: CommandLoader;
|
||||
private eventLoader: EventLoader;
|
||||
|
||||
constructor({ intents }: { intents: number[] }) {
|
||||
super({ intents });
|
||||
this.commands = new Collection<string, Command>();
|
||||
this.commandLoader = new CommandLoader(this);
|
||||
this.eventLoader = new EventLoader(this);
|
||||
}
|
||||
|
||||
async loadCommands(reload: boolean = false) {
|
||||
if (reload) {
|
||||
this.commands.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("./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] });
|
||||
@@ -4,6 +4,10 @@ import * as schema from "@db/schema";
|
||||
import { env } from "@lib/env";
|
||||
|
||||
const connectionString = env.DATABASE_URL;
|
||||
const postgres = new SQL(connectionString);
|
||||
export const postgres = new SQL(connectionString);
|
||||
|
||||
export const DrizzleClient = drizzle(postgres, { schema });
|
||||
export const DrizzleClient = drizzle(postgres, { schema });
|
||||
|
||||
export const closeDatabase = async () => {
|
||||
await postgres.close();
|
||||
};
|
||||
@@ -1,172 +0,0 @@
|
||||
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { Command, Event } from "@lib/types";
|
||||
import { env } from "@lib/env";
|
||||
|
||||
class Client extends DiscordClient {
|
||||
|
||||
commands: Collection<string, Command>;
|
||||
|
||||
constructor({ intents }: { intents: number[] }) {
|
||||
super({ intents });
|
||||
this.commands = new Collection<string, Command>();
|
||||
}
|
||||
|
||||
async loadCommands(reload: boolean = false) {
|
||||
if (reload) {
|
||||
this.commands.clear();
|
||||
console.log("♻️ Reloading commands...");
|
||||
}
|
||||
|
||||
const commandsPath = join(import.meta.dir, '../commands');
|
||||
await this.readCommandsRecursively(commandsPath, reload);
|
||||
}
|
||||
|
||||
async loadEvents(reload: boolean = false) {
|
||||
if (reload) {
|
||||
this.removeAllListeners();
|
||||
console.log("♻️ Reloading events...");
|
||||
}
|
||||
|
||||
const eventsPath = join(import.meta.dir, '../events');
|
||||
await this.readEventsRecursively(eventsPath, reload);
|
||||
}
|
||||
|
||||
private async readCommandsRecursively(dir: string, reload: boolean = false) {
|
||||
try {
|
||||
const files = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = join(dir, file.name);
|
||||
|
||||
if (file.isDirectory()) {
|
||||
await this.readCommandsRecursively(filePath, reload);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!file.name.endsWith('.ts') && !file.name.endsWith('.js')) continue;
|
||||
|
||||
try {
|
||||
const importPath = reload ? `${filePath}?t=${Date.now()}` : filePath;
|
||||
const commandModule = await import(importPath);
|
||||
const commands = Object.values(commandModule);
|
||||
if (commands.length === 0) {
|
||||
console.warn(`⚠️ No commands found in ${file.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const command of commands) {
|
||||
if (this.isValidCommand(command)) {
|
||||
this.commands.set(command.data.name, command);
|
||||
console.log(`✅ Loaded command: ${command.data.name}`);
|
||||
} else {
|
||||
console.warn(`⚠️ Skipping invalid command in ${file.name}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to load command from ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading directory ${dir}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async readEventsRecursively(dir: string, reload: boolean = false) {
|
||||
try {
|
||||
const files = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = join(dir, file.name);
|
||||
|
||||
if (file.isDirectory()) {
|
||||
await this.readEventsRecursively(filePath, reload);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!file.name.endsWith('.ts') && !file.name.endsWith('.js')) continue;
|
||||
|
||||
try {
|
||||
const importPath = reload ? `${filePath}?t=${Date.now()}` : filePath;
|
||||
const eventModule = await import(importPath);
|
||||
const event = eventModule.default;
|
||||
|
||||
if (this.isValidEvent(event)) {
|
||||
if (event.once) {
|
||||
this.once(event.name, (...args) => event.execute(...args));
|
||||
} else {
|
||||
this.on(event.name, (...args) => event.execute(...args));
|
||||
}
|
||||
console.log(`✅ Loaded event: ${event.name}`);
|
||||
} else {
|
||||
console.warn(`⚠️ Skipping invalid event in ${file.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to load event from ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading directory ${dir}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private isValidCommand(command: any): command is Command {
|
||||
return command && typeof command === 'object' && 'data' in command && 'execute' in command;
|
||||
}
|
||||
|
||||
private isValidEvent(event: any): event is Event<any> {
|
||||
return event && typeof event === 'object' && 'name' in event && 'execute' in event;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const KyokoClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages] });
|
||||
204
src/lib/config.ts
Normal file
204
src/lib/config.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
const configPath = join(import.meta.dir, '..', '..', 'config', 'config.json');
|
||||
|
||||
export interface GameConfigType {
|
||||
leveling: {
|
||||
base: number;
|
||||
exponent: number;
|
||||
chat: {
|
||||
cooldownMs: number;
|
||||
minXp: number;
|
||||
maxXp: number;
|
||||
}
|
||||
},
|
||||
economy: {
|
||||
daily: {
|
||||
amount: bigint;
|
||||
streakBonus: bigint;
|
||||
weeklyBonus: bigint;
|
||||
cooldownMs: number;
|
||||
},
|
||||
transfers: {
|
||||
allowSelfTransfer: boolean;
|
||||
minAmount: bigint;
|
||||
},
|
||||
exam: {
|
||||
multMin: number;
|
||||
multMax: number;
|
||||
}
|
||||
},
|
||||
inventory: {
|
||||
maxStackSize: bigint;
|
||||
maxSlots: number;
|
||||
},
|
||||
commands: Record<string, boolean>;
|
||||
lootdrop: {
|
||||
activityWindowMs: number;
|
||||
minMessages: number;
|
||||
spawnChance: number;
|
||||
cooldownMs: number;
|
||||
reward: {
|
||||
min: number;
|
||||
max: number;
|
||||
currency: string;
|
||||
}
|
||||
};
|
||||
studentRole: string;
|
||||
visitorRole: string;
|
||||
colorRoles: string[];
|
||||
welcomeChannelId?: string;
|
||||
welcomeMessage?: string;
|
||||
feedbackChannelId?: string;
|
||||
terminal?: {
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
};
|
||||
moderation: {
|
||||
prune: {
|
||||
maxAmount: number;
|
||||
confirmThreshold: number;
|
||||
batchSize: number;
|
||||
batchDelayMs: number;
|
||||
};
|
||||
cases: {
|
||||
dmOnWarn: boolean;
|
||||
logChannelId?: string;
|
||||
autoTimeoutThreshold?: number;
|
||||
};
|
||||
};
|
||||
system: Record<string, any>;
|
||||
}
|
||||
|
||||
// Initial default config state
|
||||
export const config: GameConfigType = {} as GameConfigType;
|
||||
|
||||
const bigIntSchema = z.union([z.string(), z.number(), z.bigint()])
|
||||
.refine((val) => {
|
||||
try {
|
||||
BigInt(val);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, { message: "Must be a valid integer" })
|
||||
.transform((val) => BigInt(val));
|
||||
|
||||
const configSchema = z.object({
|
||||
leveling: z.object({
|
||||
base: z.number(),
|
||||
exponent: z.number(),
|
||||
chat: z.object({
|
||||
cooldownMs: z.number(),
|
||||
minXp: z.number(),
|
||||
maxXp: z.number(),
|
||||
})
|
||||
}),
|
||||
economy: z.object({
|
||||
daily: z.object({
|
||||
amount: bigIntSchema,
|
||||
streakBonus: bigIntSchema,
|
||||
weeklyBonus: bigIntSchema.default(50n),
|
||||
cooldownMs: z.number(),
|
||||
}),
|
||||
transfers: z.object({
|
||||
allowSelfTransfer: z.boolean(),
|
||||
minAmount: bigIntSchema,
|
||||
}),
|
||||
exam: z.object({
|
||||
multMin: z.number(),
|
||||
multMax: z.number(),
|
||||
})
|
||||
}),
|
||||
inventory: z.object({
|
||||
maxStackSize: bigIntSchema,
|
||||
maxSlots: z.number(),
|
||||
}),
|
||||
commands: z.record(z.string(), z.boolean()),
|
||||
lootdrop: z.object({
|
||||
activityWindowMs: z.number(),
|
||||
minMessages: z.number(),
|
||||
spawnChance: z.number(),
|
||||
cooldownMs: z.number(),
|
||||
reward: z.object({
|
||||
min: z.number(),
|
||||
max: z.number(),
|
||||
currency: z.string(),
|
||||
})
|
||||
|
||||
}),
|
||||
studentRole: z.string(),
|
||||
visitorRole: z.string(),
|
||||
colorRoles: z.array(z.string()).default([]),
|
||||
welcomeChannelId: z.string().optional(),
|
||||
welcomeMessage: z.string().optional(),
|
||||
feedbackChannelId: z.string().optional(),
|
||||
terminal: z.object({
|
||||
channelId: z.string(),
|
||||
messageId: z.string()
|
||||
}).optional(),
|
||||
moderation: z.object({
|
||||
prune: z.object({
|
||||
maxAmount: z.number().default(100),
|
||||
confirmThreshold: z.number().default(50),
|
||||
batchSize: z.number().default(100),
|
||||
batchDelayMs: z.number().default(1000)
|
||||
}),
|
||||
cases: z.object({
|
||||
dmOnWarn: z.boolean().default(true),
|
||||
logChannelId: z.string().optional(),
|
||||
autoTimeoutThreshold: z.number().optional()
|
||||
})
|
||||
}).default({
|
||||
prune: {
|
||||
maxAmount: 100,
|
||||
confirmThreshold: 50,
|
||||
batchSize: 100,
|
||||
batchDelayMs: 1000
|
||||
},
|
||||
cases: {
|
||||
dmOnWarn: true
|
||||
}
|
||||
}),
|
||||
system: z.record(z.string(), z.any()).default({}),
|
||||
});
|
||||
|
||||
export function reloadConfig() {
|
||||
if (!existsSync(configPath)) {
|
||||
throw new Error(`Config file not found at ${configPath}`);
|
||||
}
|
||||
|
||||
const raw = readFileSync(configPath, 'utf-8');
|
||||
const rawConfig = JSON.parse(raw);
|
||||
|
||||
// Update config object in place
|
||||
// We use Object.assign to keep the reference to the exported 'config' object same
|
||||
const validatedConfig = configSchema.parse(rawConfig);
|
||||
Object.assign(config, validatedConfig);
|
||||
|
||||
console.log("🔄 Config reloaded from disk.");
|
||||
}
|
||||
|
||||
// Initial load
|
||||
reloadConfig();
|
||||
|
||||
// Backwards compatibility alias
|
||||
export const GameConfig = config;
|
||||
|
||||
export function saveConfig(newConfig: unknown) {
|
||||
// Validate and transform input
|
||||
const validatedConfig = configSchema.parse(newConfig);
|
||||
|
||||
const replacer = (key: string, value: any) => {
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const jsonString = JSON.stringify(validatedConfig, replacer, 4);
|
||||
writeFileSync(configPath, jsonString, 'utf-8');
|
||||
reloadConfig();
|
||||
}
|
||||
19
src/lib/configManager.ts
Normal file
19
src/lib/configManager.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const configPath = join(process.cwd(), 'config', 'config.json');
|
||||
|
||||
export const configManager = {
|
||||
toggleCommand: (commandName: string, enabled: boolean) => {
|
||||
const raw = readFileSync(configPath, 'utf-8');
|
||||
const data = JSON.parse(raw);
|
||||
|
||||
if (!data.commands) {
|
||||
data.commands = {};
|
||||
}
|
||||
|
||||
data.commands[commandName] = enabled;
|
||||
|
||||
writeFileSync(configPath, JSON.stringify(data, null, 4));
|
||||
}
|
||||
};
|
||||
65
src/lib/constants.ts
Normal file
65
src/lib/constants.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Global Constants and Enums
|
||||
*/
|
||||
|
||||
export enum TimerType {
|
||||
COOLDOWN = 'COOLDOWN',
|
||||
EFFECT = 'EFFECT',
|
||||
ACCESS = 'ACCESS',
|
||||
EXAM_SYSTEM = 'EXAM_SYSTEM',
|
||||
}
|
||||
|
||||
export enum EffectType {
|
||||
ADD_XP = 'ADD_XP',
|
||||
ADD_BALANCE = 'ADD_BALANCE',
|
||||
REPLY_MESSAGE = 'REPLY_MESSAGE',
|
||||
XP_BOOST = 'XP_BOOST',
|
||||
TEMP_ROLE = 'TEMP_ROLE',
|
||||
COLOR_ROLE = 'COLOR_ROLE',
|
||||
LOOTBOX = 'LOOTBOX',
|
||||
}
|
||||
|
||||
export enum TransactionType {
|
||||
TRANSFER_IN = 'TRANSFER_IN',
|
||||
TRANSFER_OUT = 'TRANSFER_OUT',
|
||||
DAILY_REWARD = 'DAILY_REWARD',
|
||||
ITEM_USE = 'ITEM_USE',
|
||||
LOOTBOX = 'LOOTBOX',
|
||||
EXAM_REWARD = 'EXAM_REWARD',
|
||||
PURCHASE = 'PURCHASE',
|
||||
TRADE_IN = 'TRADE_IN',
|
||||
TRADE_OUT = 'TRADE_OUT',
|
||||
QUEST_REWARD = 'QUEST_REWARD',
|
||||
}
|
||||
|
||||
export enum ItemTransactionType {
|
||||
TRADE_IN = 'TRADE_IN',
|
||||
TRADE_OUT = 'TRADE_OUT',
|
||||
SHOP_BUY = 'SHOP_BUY',
|
||||
DROP = 'DROP',
|
||||
GIVE = 'GIVE',
|
||||
USE = 'USE',
|
||||
}
|
||||
|
||||
export enum ItemType {
|
||||
MATERIAL = 'MATERIAL',
|
||||
CONSUMABLE = 'CONSUMABLE',
|
||||
EQUIPMENT = 'EQUIPMENT',
|
||||
QUEST = 'QUEST',
|
||||
}
|
||||
|
||||
export enum CaseType {
|
||||
WARN = 'warn',
|
||||
TIMEOUT = 'timeout',
|
||||
KICK = 'kick',
|
||||
BAN = 'ban',
|
||||
NOTE = 'note',
|
||||
PRUNE = 'prune',
|
||||
}
|
||||
|
||||
export enum LootType {
|
||||
NOTHING = 'NOTHING',
|
||||
CURRENCY = 'CURRENCY',
|
||||
XP = 'XP',
|
||||
ITEM = 'ITEM',
|
||||
}
|
||||
47
src/lib/db.test.ts
Normal file
47
src/lib/db.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||
|
||||
// Mock DrizzleClient
|
||||
mock.module("./DrizzleClient", () => ({
|
||||
DrizzleClient: {
|
||||
transaction: async (cb: any) => cb("MOCK_TX")
|
||||
}
|
||||
}));
|
||||
|
||||
import { withTransaction } from "./db";
|
||||
import { setShuttingDown, getActiveTransactions, decrementTransactions } from "./shutdown";
|
||||
|
||||
describe("db withTransaction", () => {
|
||||
beforeEach(() => {
|
||||
setShuttingDown(false);
|
||||
// Reset transaction count
|
||||
while (getActiveTransactions() > 0) {
|
||||
decrementTransactions();
|
||||
}
|
||||
});
|
||||
|
||||
it("should allow transactions when not shutting down", async () => {
|
||||
const result = await withTransaction(async (tx) => {
|
||||
return "success";
|
||||
});
|
||||
expect(result).toBe("success");
|
||||
expect(getActiveTransactions()).toBe(0);
|
||||
});
|
||||
|
||||
it("should throw error when shutting down", async () => {
|
||||
setShuttingDown(true);
|
||||
expect(withTransaction(async (tx) => {
|
||||
return "success";
|
||||
})).rejects.toThrow("System is shutting down, no new transactions allowed.");
|
||||
expect(getActiveTransactions()).toBe(0);
|
||||
});
|
||||
|
||||
it("should increment and decrement transaction count", async () => {
|
||||
let countDuring = 0;
|
||||
await withTransaction(async (tx) => {
|
||||
countDuring = getActiveTransactions();
|
||||
return "ok";
|
||||
});
|
||||
expect(countDuring).toBe(1);
|
||||
expect(getActiveTransactions()).toBe(0);
|
||||
});
|
||||
});
|
||||
25
src/lib/db.ts
Normal file
25
src/lib/db.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { DrizzleClient } from "./DrizzleClient";
|
||||
import type { Transaction } from "./types";
|
||||
import { isShuttingDown, incrementTransactions, decrementTransactions } from "./shutdown";
|
||||
|
||||
export const withTransaction = async <T>(
|
||||
callback: (tx: Transaction) => Promise<T>,
|
||||
tx?: Transaction
|
||||
): Promise<T> => {
|
||||
if (tx) {
|
||||
return await callback(tx);
|
||||
} else {
|
||||
if (isShuttingDown()) {
|
||||
throw new Error("System is shutting down, no new transactions allowed.");
|
||||
}
|
||||
|
||||
incrementTransactions();
|
||||
try {
|
||||
return await DrizzleClient.transaction(async (newTx) => {
|
||||
return await callback(newTx);
|
||||
});
|
||||
} finally {
|
||||
decrementTransactions();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EmbedBuilder, Colors } from "discord.js";
|
||||
import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js";
|
||||
|
||||
/**
|
||||
* Creates a standardized error embed.
|
||||
@@ -55,3 +55,21 @@ export function createInfoEmbed(message: string, title: string = "Info"): EmbedB
|
||||
.setColor(Colors.Blue)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a standardized base embed with common configuration.
|
||||
* @param title Optional title for the embed.
|
||||
* @param description Optional description for the embed.
|
||||
* @param color Optional color for the embed.
|
||||
* @returns An EmbedBuilder instance with base configuration.
|
||||
*/
|
||||
export function createBaseEmbed(title?: string, description?: string, color?: ColorResolvable): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTimestamp();
|
||||
|
||||
if (title) embed.setTitle(title);
|
||||
if (description) embed.setDescription(description);
|
||||
if (color) embed.setColor(color);
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ const envSchema = z.object({
|
||||
DISCORD_CLIENT_ID: z.string().optional(),
|
||||
DISCORD_GUILD_ID: z.string().optional(),
|
||||
DATABASE_URL: z.string().min(1, "Database URL is required"),
|
||||
PORT: z.coerce.number().default(3000),
|
||||
HOST: z.string().default("127.0.0.1"),
|
||||
});
|
||||
|
||||
const parsedEnv = envSchema.safeParse(process.env);
|
||||
|
||||
18
src/lib/errors.ts
Normal file
18
src/lib/errors.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export class ApplicationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
}
|
||||
}
|
||||
|
||||
export class UserError extends ApplicationError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class SystemError extends ApplicationError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
22
src/lib/handlers/AutocompleteHandler.ts
Normal file
22
src/lib/handlers/AutocompleteHandler.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { AutocompleteInteraction } from "discord.js";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
|
||||
|
||||
/**
|
||||
* Handles autocomplete interactions for slash commands
|
||||
*/
|
||||
export class AutocompleteHandler {
|
||||
static async handle(interaction: AutocompleteInteraction): Promise<void> {
|
||||
const command = AuroraClient.commands.get(interaction.commandName);
|
||||
|
||||
if (!command || !command.autocomplete) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await command.autocomplete(interaction);
|
||||
} catch (error) {
|
||||
console.error(`Error handling autocomplete for ${interaction.commandName}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/lib/handlers/CommandHandler.test.ts
Normal file
59
src/lib/handlers/CommandHandler.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { CommandHandler } from "./CommandHandler";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
|
||||
// Mock UserService
|
||||
mock.module("@/modules/user/user.service", () => ({
|
||||
userService: {
|
||||
getOrCreateUser: mock(() => Promise.resolve())
|
||||
}
|
||||
}));
|
||||
|
||||
describe("CommandHandler", () => {
|
||||
beforeEach(() => {
|
||||
AuroraClient.commands.clear();
|
||||
AuroraClient.lastCommandTimestamp = null;
|
||||
});
|
||||
|
||||
test("should update lastCommandTimestamp on successful execution", async () => {
|
||||
const executeSuccess = mock(() => Promise.resolve());
|
||||
AuroraClient.commands.set("test", {
|
||||
data: { name: "test" } as any,
|
||||
execute: executeSuccess
|
||||
} as any);
|
||||
|
||||
const interaction = {
|
||||
commandName: "test",
|
||||
user: { id: "123", username: "testuser" }
|
||||
} as unknown as ChatInputCommandInteraction;
|
||||
|
||||
await CommandHandler.handle(interaction);
|
||||
|
||||
expect(executeSuccess).toHaveBeenCalled();
|
||||
expect(AuroraClient.lastCommandTimestamp).not.toBeNull();
|
||||
expect(AuroraClient.lastCommandTimestamp).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("should not update lastCommandTimestamp on failed execution", async () => {
|
||||
const executeError = mock(() => Promise.reject(new Error("Command Failed")));
|
||||
AuroraClient.commands.set("fail", {
|
||||
data: { name: "fail" } as any,
|
||||
execute: executeError
|
||||
} as any);
|
||||
|
||||
const interaction = {
|
||||
commandName: "fail",
|
||||
user: { id: "123", username: "testuser" },
|
||||
replied: false,
|
||||
deferred: false,
|
||||
reply: mock(() => Promise.resolve()),
|
||||
followUp: mock(() => Promise.resolve())
|
||||
} as unknown as ChatInputCommandInteraction;
|
||||
|
||||
await CommandHandler.handle(interaction);
|
||||
|
||||
expect(executeError).toHaveBeenCalled();
|
||||
expect(AuroraClient.lastCommandTimestamp).toBeNull();
|
||||
});
|
||||
});
|
||||
41
src/lib/handlers/CommandHandler.ts
Normal file
41
src/lib/handlers/CommandHandler.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
console.error(`No command matching ${interaction.commandName} was found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure user exists in database
|
||||
try {
|
||||
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
} catch (error) {
|
||||
console.error("Failed to ensure user exists:", error);
|
||||
}
|
||||
|
||||
try {
|
||||
await command.execute(interaction);
|
||||
AuroraClient.lastCommandTimestamp = Date.now();
|
||||
} catch (error) {
|
||||
console.error(String(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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
78
src/lib/handlers/ComponentInteractionHandler.ts
Normal file
78
src/lib/handlers/ComponentInteractionHandler.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js";
|
||||
|
||||
import { UserError } from "@lib/errors";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
|
||||
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
||||
|
||||
/**
|
||||
* Handles component interactions (buttons, select menus, modals)
|
||||
* Routes to appropriate handlers based on customId patterns
|
||||
* Provides centralized error handling with UserError differentiation
|
||||
*/
|
||||
export class ComponentInteractionHandler {
|
||||
static async handle(interaction: ComponentInteraction): Promise<void> {
|
||||
const { interactionRoutes } = await import("@lib/interaction.routes");
|
||||
|
||||
for (const route of interactionRoutes) {
|
||||
if (route.predicate(interaction)) {
|
||||
const module = await route.handler();
|
||||
const handlerMethod = module[route.method];
|
||||
|
||||
if (typeof handlerMethod === 'function') {
|
||||
try {
|
||||
await handlerMethod(interaction);
|
||||
return;
|
||||
} catch (error) {
|
||||
await this.handleError(interaction, error, route.method);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.error(`Handler method ${route.method} not found in module`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles errors from interaction handlers
|
||||
* Differentiates between UserError (user-facing) and system errors
|
||||
*/
|
||||
private static async handleError(
|
||||
interaction: ComponentInteraction,
|
||||
error: unknown,
|
||||
handlerName: string
|
||||
): Promise<void> {
|
||||
const isUserError = error instanceof UserError;
|
||||
|
||||
// Determine error message
|
||||
const errorMessage = isUserError
|
||||
? (error as Error).message
|
||||
: 'An unexpected error occurred. Please try again later.';
|
||||
|
||||
// Log system errors (non-user errors) for debugging
|
||||
if (!isUserError) {
|
||||
console.error(`Error in ${handlerName}:`, error);
|
||||
}
|
||||
|
||||
const errorEmbed = createErrorEmbed(errorMessage);
|
||||
|
||||
try {
|
||||
// Handle different interaction states
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
await interaction.followUp({
|
||||
embeds: [errorEmbed],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
} else {
|
||||
await interaction.reply({
|
||||
embeds: [errorEmbed],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
} catch (replyError) {
|
||||
// If we can't send a reply, log it
|
||||
console.error(`Failed to send error response in ${handlerName}:`, replyError);
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/lib/handlers/index.ts
Normal file
3
src/lib/handlers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { ComponentInteractionHandler } from "./ComponentInteractionHandler";
|
||||
export { AutocompleteHandler } from "./AutocompleteHandler";
|
||||
export { CommandHandler } from "./CommandHandler";
|
||||
61
src/lib/interaction.routes.ts
Normal file
61
src/lib/interaction.routes.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
|
||||
|
||||
// Union type for all component interactions
|
||||
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
||||
|
||||
// Type for the handler function that modules export
|
||||
type InteractionHandler = (interaction: ComponentInteraction) => Promise<void>;
|
||||
|
||||
// Type for the dynamically imported module containing the handler
|
||||
interface InteractionModule {
|
||||
[key: string]: (...args: any[]) => Promise<void> | any;
|
||||
}
|
||||
|
||||
// Route definition
|
||||
interface InteractionRoute {
|
||||
predicate: (interaction: ComponentInteraction) => boolean;
|
||||
handler: () => Promise<InteractionModule>;
|
||||
method: string;
|
||||
}
|
||||
|
||||
export const interactionRoutes: InteractionRoute[] = [
|
||||
// --- TRADE MODULE ---
|
||||
{
|
||||
predicate: (i) => i.customId.startsWith("trade_") || i.customId === "amount",
|
||||
handler: () => import("@/modules/trade/trade.interaction"),
|
||||
method: 'handleTradeInteraction'
|
||||
},
|
||||
|
||||
// --- ECONOMY MODULE ---
|
||||
{
|
||||
predicate: (i) => i.isButton() && i.customId.startsWith("shop_buy_"),
|
||||
handler: () => import("@/modules/economy/shop.interaction"),
|
||||
method: 'handleShopInteraction'
|
||||
},
|
||||
{
|
||||
predicate: (i) => i.isButton() && i.customId.startsWith("lootdrop_"),
|
||||
handler: () => import("@/modules/economy/lootdrop.interaction"),
|
||||
method: 'handleLootdropInteraction'
|
||||
},
|
||||
|
||||
// --- ADMIN MODULE ---
|
||||
{
|
||||
predicate: (i) => i.customId.startsWith("createitem_"),
|
||||
handler: () => import("@/modules/admin/item_wizard"),
|
||||
method: 'handleItemWizardInteraction'
|
||||
},
|
||||
|
||||
// --- USER MODULE ---
|
||||
{
|
||||
predicate: (i) => i.isButton() && i.customId === "enrollment",
|
||||
handler: () => import("@/modules/user/enrollment.interaction"),
|
||||
method: 'handleEnrollmentInteraction'
|
||||
},
|
||||
|
||||
// --- FEEDBACK MODULE ---
|
||||
{
|
||||
predicate: (i) => i.customId.startsWith("feedback_"),
|
||||
handler: () => import("@/modules/feedback/feedback.interaction"),
|
||||
method: 'handleFeedbackInteraction'
|
||||
}
|
||||
];
|
||||
111
src/lib/loaders/CommandLoader.ts
Normal file
111
src/lib/loaders/CommandLoader.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { Command } from "@lib/types";
|
||||
import { config } from "@lib/config";
|
||||
import type { LoadResult, LoadError } from "./types";
|
||||
import type { Client } from "../BotClient";
|
||||
|
||||
|
||||
/**
|
||||
* Handles loading commands from the file system
|
||||
*/
|
||||
export class CommandLoader {
|
||||
private client: Client;
|
||||
|
||||
constructor(client: Client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load commands from a directory recursively
|
||||
*/
|
||||
async loadFromDirectory(dir: string, reload: boolean = false): Promise<LoadResult> {
|
||||
const result: LoadResult = { loaded: 0, skipped: 0, errors: [] };
|
||||
await this.scanDirectory(dir, reload, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scan directory for command files
|
||||
*/
|
||||
private async scanDirectory(dir: string, reload: boolean, result: LoadResult): Promise<void> {
|
||||
try {
|
||||
const files = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = join(dir, file.name);
|
||||
|
||||
if (file.isDirectory()) {
|
||||
await this.scanDirectory(filePath, reload, result);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((!file.name.endsWith('.ts') && !file.name.endsWith('.js')) || file.name.endsWith('.test.ts') || file.name.endsWith('.spec.ts')) continue;
|
||||
|
||||
await this.loadCommandFile(filePath, reload, result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading directory ${dir}:`, error);
|
||||
result.errors.push({ file: dir, error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a single command file
|
||||
*/
|
||||
private async loadCommandFile(filePath: string, reload: boolean, result: LoadResult): Promise<void> {
|
||||
try {
|
||||
const importPath = reload ? `${filePath}?t=${Date.now()}` : filePath;
|
||||
const commandModule = await import(importPath);
|
||||
const commands = Object.values(commandModule);
|
||||
|
||||
if (commands.length === 0) {
|
||||
console.warn(`No commands found in ${filePath}`);
|
||||
result.skipped++;
|
||||
return;
|
||||
}
|
||||
|
||||
const category = this.extractCategory(filePath);
|
||||
|
||||
for (const command of commands) {
|
||||
if (this.isValidCommand(command)) {
|
||||
command.category = category;
|
||||
|
||||
const isEnabled = config.commands[command.data.name] !== false;
|
||||
|
||||
if (!isEnabled) {
|
||||
console.log(`🚫 Skipping disabled command: ${command.data.name}`);
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
this.client.commands.set(command.data.name, command);
|
||||
console.log(`Loaded command: ${command.data.name}`);
|
||||
result.loaded++;
|
||||
} else {
|
||||
console.warn(`Skipping invalid command in ${filePath}`);
|
||||
result.skipped++;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load command from ${filePath}:`, error);
|
||||
result.errors.push({ file: filePath, error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract category from file path
|
||||
* e.g., /path/to/commands/admin/features.ts -> "admin"
|
||||
*/
|
||||
private extractCategory(filePath: string): string {
|
||||
const pathParts = filePath.split('/');
|
||||
return pathParts[pathParts.length - 2] ?? "uncategorized";
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to validate command structure
|
||||
*/
|
||||
private isValidCommand(command: any): command is Command {
|
||||
return command && typeof command === 'object' && 'data' in command && 'execute' in command;
|
||||
}
|
||||
}
|
||||
85
src/lib/loaders/EventLoader.ts
Normal file
85
src/lib/loaders/EventLoader.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { Event } from "@lib/types";
|
||||
import type { LoadResult } from "./types";
|
||||
import type { Client } from "../BotClient";
|
||||
|
||||
|
||||
/**
|
||||
* Handles loading events from the file system
|
||||
*/
|
||||
export class EventLoader {
|
||||
private client: Client;
|
||||
|
||||
constructor(client: Client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load events from a directory recursively
|
||||
*/
|
||||
async loadFromDirectory(dir: string, reload: boolean = false): Promise<LoadResult> {
|
||||
const result: LoadResult = { loaded: 0, skipped: 0, errors: [] };
|
||||
await this.scanDirectory(dir, reload, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scan directory for event files
|
||||
*/
|
||||
private async scanDirectory(dir: string, reload: boolean, result: LoadResult): Promise<void> {
|
||||
try {
|
||||
const files = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = join(dir, file.name);
|
||||
|
||||
if (file.isDirectory()) {
|
||||
await this.scanDirectory(filePath, reload, result);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!file.name.endsWith('.ts') && !file.name.endsWith('.js')) continue;
|
||||
|
||||
await this.loadEventFile(filePath, reload, result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading directory ${dir}:`, error);
|
||||
result.errors.push({ file: dir, error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a single event file
|
||||
*/
|
||||
private async loadEventFile(filePath: string, reload: boolean, result: LoadResult): Promise<void> {
|
||||
try {
|
||||
const importPath = reload ? `${filePath}?t=${Date.now()}` : filePath;
|
||||
const eventModule = await import(importPath);
|
||||
const event = eventModule.default;
|
||||
|
||||
if (this.isValidEvent(event)) {
|
||||
if (event.once) {
|
||||
this.client.once(event.name, (...args) => event.execute(...args));
|
||||
} else {
|
||||
this.client.on(event.name, (...args) => event.execute(...args));
|
||||
}
|
||||
console.log(`Loaded event: ${event.name}`);
|
||||
result.loaded++;
|
||||
} else {
|
||||
console.warn(`Skipping invalid event in ${filePath}`);
|
||||
result.skipped++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load event from ${filePath}:`, error);
|
||||
result.errors.push({ file: filePath, error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to validate event structure
|
||||
*/
|
||||
private isValidEvent(event: any): event is Event<any> {
|
||||
return event && typeof event === 'object' && 'name' in event && 'execute' in event;
|
||||
}
|
||||
}
|
||||
16
src/lib/loaders/types.ts
Normal file
16
src/lib/loaders/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Result of loading commands or events
|
||||
*/
|
||||
export interface LoadResult {
|
||||
loaded: number;
|
||||
skipped: number;
|
||||
errors: LoadError[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Error that occurred during loading
|
||||
*/
|
||||
export interface LoadError {
|
||||
file: string;
|
||||
error: unknown;
|
||||
}
|
||||
56
src/lib/shutdown.test.ts
Normal file
56
src/lib/shutdown.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, beforeEach } from "bun:test";
|
||||
import { isShuttingDown, setShuttingDown, incrementTransactions, decrementTransactions, getActiveTransactions, waitForTransactions } from "./shutdown";
|
||||
|
||||
describe("shutdown logic", () => {
|
||||
beforeEach(() => {
|
||||
setShuttingDown(false);
|
||||
while (getActiveTransactions() > 0) {
|
||||
decrementTransactions();
|
||||
}
|
||||
});
|
||||
|
||||
it("should initialize with shuttingDown as false", () => {
|
||||
expect(isShuttingDown()).toBe(false);
|
||||
});
|
||||
|
||||
it("should update shuttingDown state", () => {
|
||||
setShuttingDown(true);
|
||||
expect(isShuttingDown()).toBe(true);
|
||||
});
|
||||
|
||||
it("should track active transactions", () => {
|
||||
expect(getActiveTransactions()).toBe(0);
|
||||
incrementTransactions();
|
||||
expect(getActiveTransactions()).toBe(1);
|
||||
decrementTransactions();
|
||||
expect(getActiveTransactions()).toBe(0);
|
||||
});
|
||||
|
||||
it("should wait for transactions to complete", async () => {
|
||||
incrementTransactions();
|
||||
|
||||
const start = Date.now();
|
||||
const waitPromise = waitForTransactions(1000);
|
||||
|
||||
// Simulate completion after 200ms
|
||||
setTimeout(() => {
|
||||
decrementTransactions();
|
||||
}, 200);
|
||||
|
||||
await waitPromise;
|
||||
const duration = Date.now() - start;
|
||||
|
||||
expect(duration).toBeGreaterThanOrEqual(200);
|
||||
expect(getActiveTransactions()).toBe(0);
|
||||
});
|
||||
|
||||
it("should timeout if transactions never complete", async () => {
|
||||
incrementTransactions();
|
||||
const start = Date.now();
|
||||
await waitForTransactions(500);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
expect(duration).toBeGreaterThanOrEqual(500);
|
||||
expect(getActiveTransactions()).toBe(1); // Still 1 because we didn't decrement
|
||||
});
|
||||
});
|
||||
30
src/lib/shutdown.ts
Normal file
30
src/lib/shutdown.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
|
||||
let shuttingDown = false;
|
||||
let activeTransactions = 0;
|
||||
|
||||
export const isShuttingDown = () => shuttingDown;
|
||||
export const setShuttingDown = (value: boolean) => {
|
||||
shuttingDown = value;
|
||||
};
|
||||
|
||||
export const incrementTransactions = () => {
|
||||
activeTransactions++;
|
||||
};
|
||||
|
||||
export const decrementTransactions = () => {
|
||||
activeTransactions--;
|
||||
};
|
||||
|
||||
export const getActiveTransactions = () => activeTransactions;
|
||||
|
||||
export const waitForTransactions = async (timeoutMs: number = 10000) => {
|
||||
const start = Date.now();
|
||||
while (activeTransactions > 0) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
console.warn(`Shutdown timed out waiting for ${activeTransactions} transactions after ${timeoutMs}ms`);
|
||||
break;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
};
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { ChatInputCommandInteraction, ClientEvents, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder } from "discord.js";
|
||||
import type { AutocompleteInteraction, ChatInputCommandInteraction, ClientEvents, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder } from "discord.js";
|
||||
import { LootType, EffectType } from "./constants";
|
||||
import { DrizzleClient } from "./DrizzleClient";
|
||||
|
||||
export interface Command {
|
||||
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder;
|
||||
execute: (interaction: ChatInputCommandInteraction) => Promise<void> | void;
|
||||
autocomplete?: (interaction: AutocompleteInteraction) => Promise<void> | void;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface Event<K extends keyof ClientEvents> {
|
||||
@@ -10,3 +14,30 @@ export interface Event<K extends keyof ClientEvents> {
|
||||
once?: boolean;
|
||||
execute: (...args: ClientEvents[K]) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export type ItemEffect =
|
||||
| { type: EffectType.ADD_XP; amount: number }
|
||||
| { type: EffectType.ADD_BALANCE; amount: number }
|
||||
| { type: EffectType.XP_BOOST; multiplier: number; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
|
||||
| { type: EffectType.TEMP_ROLE; roleId: string; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
|
||||
| { type: EffectType.REPLY_MESSAGE; message: string }
|
||||
| { type: EffectType.COLOR_ROLE; roleId: string }
|
||||
| { type: EffectType.LOOTBOX; pool: LootTableItem[] };
|
||||
|
||||
export interface LootTableItem {
|
||||
type: LootType;
|
||||
weight: number;
|
||||
amount?: number; // For CURRENCY, XP
|
||||
itemId?: number; // For ITEM
|
||||
minAmount?: number; // Optional range for CURRENCY/XP
|
||||
maxAmount?: number; // Optional range for CURRENCY/XP
|
||||
message?: string; // Optional custom message for this outcome
|
||||
}
|
||||
|
||||
export interface ItemUsageData {
|
||||
consume: boolean;
|
||||
effects: ItemEffect[];
|
||||
}
|
||||
|
||||
export type DbClient = typeof DrizzleClient;
|
||||
export type Transaction = Parameters<Parameters<DbClient['transaction']>[0]>[0];
|
||||
|
||||
56
src/lib/webhookUtils.ts
Normal file
56
src/lib/webhookUtils.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { type TextBasedChannel, User } from 'discord.js';
|
||||
|
||||
/**
|
||||
* Sends a message to a channel using a temporary webhook (imitating the bot or custom persona).
|
||||
*
|
||||
* @param channel The channel to send the message to (must support webhooks).
|
||||
* @param payload The message payload (string content or JSON object for embeds/options).
|
||||
* @param clientUser The client user (bot) to fallback for avatar/name if not specified in payload.
|
||||
* @param reason The reason for creating the webhook (for audit logs).
|
||||
*/
|
||||
export async function sendWebhookMessage(
|
||||
channel: TextBasedChannel,
|
||||
payload: any,
|
||||
clientUser: User,
|
||||
reason: string
|
||||
): Promise<void> {
|
||||
|
||||
if (!('createWebhook' in channel)) {
|
||||
throw new Error("Channel does not support webhooks.");
|
||||
}
|
||||
|
||||
// Normalize payload if it's just a string, wrap it in content
|
||||
if (typeof payload === 'string') {
|
||||
payload = { content: payload };
|
||||
}
|
||||
|
||||
let webhook;
|
||||
try {
|
||||
webhook = await channel.createWebhook({
|
||||
name: payload.username || `${clientUser.username}`, // Use payload name or bot name
|
||||
avatar: payload.avatar_url || payload.avatarURL || clientUser.displayAvatarURL(),
|
||||
reason: reason
|
||||
});
|
||||
|
||||
// Support snake_case keys for raw API compatibility if passed from config
|
||||
if (payload.avatar_url && !payload.avatarURL) {
|
||||
payload.avatarURL = payload.avatar_url;
|
||||
delete payload.avatar_url;
|
||||
}
|
||||
|
||||
await webhook.send(payload);
|
||||
|
||||
await webhook.delete(reason);
|
||||
|
||||
} catch (error) {
|
||||
// Attempt cleanup if webhook was created but sending failed
|
||||
if (webhook) {
|
||||
try {
|
||||
await webhook.delete("Cleanup after failure");
|
||||
} catch (cleanupError) {
|
||||
console.error("Failed to delete webhook during cleanup:", cleanupError);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
260
src/modules/admin/item_wizard.test.ts
Normal file
260
src/modules/admin/item_wizard.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { describe, test, expect, spyOn, beforeEach, mock } from "bun:test";
|
||||
import { handleItemWizardInteraction, renderWizard } from "./item_wizard";
|
||||
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
|
||||
|
||||
// Mock Setup
|
||||
const valuesMock = mock((_args: any) => Promise.resolve());
|
||||
const insertMock = mock(() => ({ values: valuesMock }));
|
||||
|
||||
mock.module("@/lib/DrizzleClient", () => ({
|
||||
DrizzleClient: {
|
||||
insert: insertMock
|
||||
}
|
||||
}));
|
||||
|
||||
mock.module("@/db/schema", () => ({
|
||||
items: "items_schema"
|
||||
}));
|
||||
|
||||
describe("ItemWizard", () => {
|
||||
const userId = "test-user-123";
|
||||
|
||||
beforeEach(() => {
|
||||
valuesMock.mockClear();
|
||||
insertMock.mockClear();
|
||||
// Since draftSession is internal, we can't easily clear it.
|
||||
// We will use unique user IDs or rely on overwrite behavior.
|
||||
});
|
||||
|
||||
// Helper to create base interaction
|
||||
const createBaseInteraction = (id: string, customId: string) => ({
|
||||
user: { id },
|
||||
customId,
|
||||
deferUpdate: mock(() => Promise.resolve()),
|
||||
editReply: mock(() => Promise.resolve()),
|
||||
update: mock(() => Promise.resolve()),
|
||||
showModal: mock(() => Promise.resolve()),
|
||||
followUp: mock(() => Promise.resolve()),
|
||||
reply: mock(() => Promise.resolve()),
|
||||
});
|
||||
|
||||
test("renderWizard should return initial state for new user", () => {
|
||||
const result = renderWizard(`new-${Date.now()}`);
|
||||
expect(result.embeds).toHaveLength(1);
|
||||
expect(result.embeds[0]?.data.title).toContain("New Item");
|
||||
expect(result.components).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("handleItemWizardInteraction should handle details modal submit", async () => {
|
||||
const uid = `user-details-${Date.now()}`;
|
||||
renderWizard(uid); // Init session
|
||||
|
||||
const interaction = {
|
||||
...createBaseInteraction(uid, "createitem_modal_details"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => true,
|
||||
isMessageComponent: () => false,
|
||||
fields: {
|
||||
getTextInputValue: (key: string) => {
|
||||
if (key === "name") return "Updated Name";
|
||||
if (key === "desc") return "Updated Desc";
|
||||
if (key === "rarity") return "Legendary";
|
||||
return "";
|
||||
}
|
||||
},
|
||||
} as unknown as ModalSubmitInteraction;
|
||||
|
||||
await handleItemWizardInteraction(interaction);
|
||||
|
||||
expect(interaction.deferUpdate).toHaveBeenCalled();
|
||||
const result = renderWizard(uid);
|
||||
expect(result.embeds[0]?.data.title).toContain("Updated Name");
|
||||
});
|
||||
|
||||
test("handleItemWizardInteraction should handle economy modal submit", async () => {
|
||||
const uid = `user-economy-${Date.now()}`;
|
||||
renderWizard(uid);
|
||||
|
||||
const interaction = {
|
||||
...createBaseInteraction(uid, "createitem_modal_economy"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => true,
|
||||
isMessageComponent: () => false,
|
||||
fields: {
|
||||
getTextInputValue: (key: string) => (key === "price" ? "500" : "")
|
||||
},
|
||||
} as unknown as ModalSubmitInteraction;
|
||||
|
||||
await handleItemWizardInteraction(interaction);
|
||||
|
||||
const result = renderWizard(uid);
|
||||
const economyField = result.embeds[0]?.data.fields?.find(f => f.name === "Economy");
|
||||
expect(economyField?.value).toContain("500 🪙");
|
||||
});
|
||||
|
||||
test("handleItemWizardInteraction should handle visuals modal submit", async () => {
|
||||
const uid = `user-visuals-${Date.now()}`;
|
||||
renderWizard(uid);
|
||||
|
||||
const interaction = {
|
||||
...createBaseInteraction(uid, "createitem_modal_visuals"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => true,
|
||||
isMessageComponent: () => false,
|
||||
fields: {
|
||||
getTextInputValue: (key: string) => {
|
||||
if (key === "icon") return "http://icon.com";
|
||||
if (key === "image") return "http://image.com";
|
||||
return "";
|
||||
}
|
||||
},
|
||||
} as unknown as ModalSubmitInteraction;
|
||||
|
||||
await handleItemWizardInteraction(interaction);
|
||||
|
||||
const result = renderWizard(uid);
|
||||
expect(result.embeds[0]?.data.thumbnail?.url).toBe("http://icon.com");
|
||||
expect(result.embeds[0]?.data.image?.url).toBe("http://image.com");
|
||||
});
|
||||
|
||||
test("handleItemWizardInteraction should flow through adding an effect", async () => {
|
||||
const uid = `user-effect-${Date.now()}`;
|
||||
renderWizard(uid);
|
||||
|
||||
// 1. Start Add Effect
|
||||
const startInteraction = {
|
||||
...createBaseInteraction(uid, "createitem_addeffect_start"),
|
||||
isButton: () => true,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => false,
|
||||
isMessageComponent: () => true,
|
||||
} as unknown as ButtonInteraction;
|
||||
await handleItemWizardInteraction(startInteraction);
|
||||
expect(startInteraction.update).toHaveBeenCalled(); // Should show select menu
|
||||
|
||||
// 2. Select Effect Type
|
||||
const selectInteraction = {
|
||||
...createBaseInteraction(uid, "createitem_select_effect_type"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => true,
|
||||
isModalSubmit: () => false,
|
||||
isMessageComponent: () => true,
|
||||
values: ["ADD_XP"]
|
||||
} as unknown as StringSelectMenuInteraction;
|
||||
await handleItemWizardInteraction(selectInteraction);
|
||||
expect(selectInteraction.showModal).toHaveBeenCalled(); // Should show config modal
|
||||
|
||||
// 3. Submit Effect Config Modal
|
||||
const modalInteraction = {
|
||||
...createBaseInteraction(uid, "createitem_modal_effect"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => true,
|
||||
isMessageComponent: () => false,
|
||||
fields: {
|
||||
getTextInputValue: (key: string) => (key === "amount" ? "1000" : "")
|
||||
},
|
||||
} as unknown as ModalSubmitInteraction;
|
||||
await handleItemWizardInteraction(modalInteraction);
|
||||
|
||||
// Verify Effect Added
|
||||
const result = renderWizard(uid);
|
||||
const effectsField = result.embeds[0]?.data.fields?.find(f => f.name === "Usage Effects");
|
||||
expect(effectsField?.value).toContain("ADD_XP");
|
||||
expect(effectsField?.value).toContain("1000");
|
||||
});
|
||||
|
||||
test("handleItemWizardInteraction should save item to database", async () => {
|
||||
const uid = `user-save-${Date.now()}`;
|
||||
renderWizard(uid);
|
||||
|
||||
// Set name first so we can check it
|
||||
const nameInteraction = {
|
||||
...createBaseInteraction(uid, "createitem_modal_details"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => true,
|
||||
isMessageComponent: () => false,
|
||||
fields: {
|
||||
getTextInputValue: (key: string) => {
|
||||
if (key === "name") return "Saved Item";
|
||||
if (key === "desc") return "Desc";
|
||||
if (key === "rarity") return "Common";
|
||||
return "";
|
||||
}
|
||||
},
|
||||
} as unknown as ModalSubmitInteraction;
|
||||
await handleItemWizardInteraction(nameInteraction);
|
||||
|
||||
// Save
|
||||
const saveInteraction = {
|
||||
...createBaseInteraction(uid, "createitem_save"),
|
||||
isButton: () => true,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => false,
|
||||
isMessageComponent: () => true,
|
||||
} as unknown as ButtonInteraction;
|
||||
|
||||
await handleItemWizardInteraction(saveInteraction);
|
||||
|
||||
expect(valuesMock).toHaveBeenCalled();
|
||||
const calls = valuesMock.mock.calls as any[];
|
||||
if (calls.length > 0) {
|
||||
const callArgs = calls[0][0];
|
||||
expect(callArgs).toMatchObject({
|
||||
name: "Saved Item",
|
||||
description: "Desc",
|
||||
rarity: "Common",
|
||||
// Add other fields as needed
|
||||
});
|
||||
}
|
||||
|
||||
expect(saveInteraction.editReply).toHaveBeenCalledWith(expect.objectContaining({
|
||||
content: expect.stringContaining("successfully")
|
||||
}));
|
||||
});
|
||||
|
||||
test("handleItemWizardInteraction should cancel and clear session", async () => {
|
||||
const uid = `user-cancel-${Date.now()}`;
|
||||
renderWizard(uid);
|
||||
|
||||
const interaction = {
|
||||
...createBaseInteraction(uid, "createitem_cancel"),
|
||||
isButton: () => true, // Technically any component
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => false,
|
||||
isMessageComponent: () => true,
|
||||
} as unknown as ButtonInteraction;
|
||||
|
||||
await handleItemWizardInteraction(interaction);
|
||||
|
||||
expect(interaction.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
content: expect.stringContaining("cancelled")
|
||||
}));
|
||||
|
||||
// Verify session is gone by checking if renderWizard returns default New Item
|
||||
// Let's modify it first
|
||||
const modInteraction = {
|
||||
...createBaseInteraction(uid, "createitem_modal_details"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => true,
|
||||
isMessageComponent: () => false,
|
||||
fields: {
|
||||
getTextInputValue: (key: string) => (key === "name" ? "Modified" : "x")
|
||||
},
|
||||
} as unknown as ModalSubmitInteraction;
|
||||
await handleItemWizardInteraction(modInteraction);
|
||||
|
||||
// Now Cancel
|
||||
await handleItemWizardInteraction(interaction);
|
||||
|
||||
// New render should be "New Item" not "Modified"
|
||||
const result = renderWizard(uid);
|
||||
expect(result.embeds[0]?.data.title).toContain("New Item");
|
||||
expect(result.embeds[0]?.data.title).not.toContain("Modified");
|
||||
});
|
||||
});
|
||||
243
src/modules/admin/item_wizard.ts
Normal file
243
src/modules/admin/item_wizard.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { type Interaction } from "discord.js";
|
||||
import { items } from "@/db/schema";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import type { ItemUsageData, ItemEffect } from "@/lib/types";
|
||||
import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
|
||||
import type { DraftItem } from "./item_wizard.types";
|
||||
import { ItemType, EffectType } from "@/lib/constants";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
|
||||
// --- State ---
|
||||
const draftSession = new Map<string, DraftItem>();
|
||||
|
||||
|
||||
|
||||
// --- Render ---
|
||||
export const renderWizard = (userId: string, isDraft = true) => {
|
||||
let draft = draftSession.get(userId);
|
||||
|
||||
// Initialize if new
|
||||
if (!draft) {
|
||||
draft = {
|
||||
name: "New Item",
|
||||
description: "No description",
|
||||
rarity: "Common",
|
||||
type: ItemType.MATERIAL,
|
||||
price: null,
|
||||
iconUrl: "",
|
||||
imageUrl: "",
|
||||
usageData: { consume: true, effects: [] } // Default Consume to true for now
|
||||
};
|
||||
draftSession.set(userId, draft);
|
||||
}
|
||||
|
||||
const { embeds, components } = getItemWizardEmbed(draft);
|
||||
return { embeds, components };
|
||||
};
|
||||
|
||||
// --- Handler ---
|
||||
export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
// Only handle createitem interactions
|
||||
if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return;
|
||||
if (!interaction.customId.startsWith("createitem_")) return;
|
||||
|
||||
const userId = interaction.user.id;
|
||||
let draft = draftSession.get(userId);
|
||||
|
||||
// Special case for Cancel - doesn't need draft checks usually, but we want to clear it
|
||||
if (interaction.customId === "createitem_cancel") {
|
||||
draftSession.delete(userId);
|
||||
if (interaction.isMessageComponent()) {
|
||||
await interaction.update({ content: "❌ Item creation cancelled.", embeds: [], components: [] });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize draft if missing for other actions (edge case: bot restart)
|
||||
if (!draft) {
|
||||
if (interaction.isMessageComponent()) {
|
||||
// Create one implicitly to prevent crashes, or warn user
|
||||
if (interaction.customId === "createitem_start") {
|
||||
// Allow start
|
||||
} else {
|
||||
await interaction.reply({ content: "⚠️ Session expired. Please run `/createitem` again.", ephemeral: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-get draft (guaranteed now if we handled the start/restart)
|
||||
// Actually renderWizard initializes it, so if we call that we are safe.
|
||||
// But for Modals we need it.
|
||||
|
||||
if (!draft) {
|
||||
// Just init it
|
||||
renderWizard(userId);
|
||||
draft = draftSession.get(userId)!;
|
||||
}
|
||||
|
||||
|
||||
// --- Routing ---
|
||||
|
||||
// 1. Details Modal
|
||||
if (interaction.customId === "createitem_details") {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = getDetailsModal(draft);
|
||||
await interaction.showModal(modal);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Economy Modal
|
||||
if (interaction.customId === "createitem_economy") {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = getEconomyModal(draft);
|
||||
await interaction.showModal(modal);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Visuals Modal
|
||||
if (interaction.customId === "createitem_visuals") {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = getVisualsModal(draft);
|
||||
await interaction.showModal(modal);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Type Toggle (Start Select Menu)
|
||||
if (interaction.customId === "createitem_type_toggle") {
|
||||
if (!interaction.isButton()) return;
|
||||
const { components } = getItemTypeSelection();
|
||||
await interaction.update({ components }); // Temporary view
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.customId === "createitem_select_type") {
|
||||
if (!interaction.isStringSelectMenu()) return;
|
||||
const selected = interaction.values[0];
|
||||
if (selected) {
|
||||
draft.type = selected;
|
||||
}
|
||||
// Re-render
|
||||
const payload = renderWizard(userId);
|
||||
await interaction.update(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Add Effect Flow
|
||||
if (interaction.customId === "createitem_addeffect_start") {
|
||||
if (!interaction.isButton()) return;
|
||||
const { components } = getEffectTypeSelection();
|
||||
await interaction.update({ components });
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.customId === "createitem_select_effect_type") {
|
||||
if (!interaction.isStringSelectMenu()) return;
|
||||
const effectType = interaction.values[0];
|
||||
if (!effectType) return;
|
||||
draft.pendingEffectType = effectType;
|
||||
|
||||
// Immediately show modal for data collection
|
||||
// Note: You can't showModal from an update? You CAN showModal from a component interaction (SelectMenu).
|
||||
// But we shouldn't update the message AND show modal. We must pick one.
|
||||
// We will show modal. The message remains in "Select Effect" state until modal submit re-renders it.
|
||||
|
||||
const modal = getEffectConfigModal(effectType);
|
||||
await interaction.showModal(modal);
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle Consume
|
||||
if (interaction.customId === "createitem_toggle_consume") {
|
||||
if (!interaction.isButton()) return;
|
||||
draft.usageData.consume = !draft.usageData.consume;
|
||||
const payload = renderWizard(userId);
|
||||
await interaction.update(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. Handle Modal Submits
|
||||
if (interaction.isModalSubmit()) {
|
||||
if (interaction.customId === "createitem_modal_details") {
|
||||
draft.name = interaction.fields.getTextInputValue("name");
|
||||
draft.description = interaction.fields.getTextInputValue("desc");
|
||||
draft.rarity = interaction.fields.getTextInputValue("rarity");
|
||||
}
|
||||
else if (interaction.customId === "createitem_modal_economy") {
|
||||
const price = parseInt(interaction.fields.getTextInputValue("price"));
|
||||
draft.price = isNaN(price) || price === 0 ? null : price;
|
||||
}
|
||||
else if (interaction.customId === "createitem_modal_visuals") {
|
||||
draft.iconUrl = interaction.fields.getTextInputValue("icon");
|
||||
draft.imageUrl = interaction.fields.getTextInputValue("image");
|
||||
}
|
||||
else if (interaction.customId === "createitem_modal_effect") {
|
||||
const type = draft.pendingEffectType;
|
||||
if (type) {
|
||||
let effect: ItemEffect | null = null;
|
||||
|
||||
if (type === EffectType.ADD_XP || type === EffectType.ADD_BALANCE) {
|
||||
const amount = parseInt(interaction.fields.getTextInputValue("amount"));
|
||||
if (!isNaN(amount)) effect = { type: type as any, amount };
|
||||
}
|
||||
else if (type === EffectType.REPLY_MESSAGE) {
|
||||
effect = { type: EffectType.REPLY_MESSAGE, message: interaction.fields.getTextInputValue("message") };
|
||||
}
|
||||
else if (type === EffectType.XP_BOOST) {
|
||||
const multiplier = parseFloat(interaction.fields.getTextInputValue("multiplier"));
|
||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
||||
if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: EffectType.XP_BOOST, multiplier, durationSeconds: duration };
|
||||
}
|
||||
else if (type === EffectType.TEMP_ROLE) {
|
||||
const roleId = interaction.fields.getTextInputValue("role_id");
|
||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
||||
if (roleId && !isNaN(duration)) effect = { type: EffectType.TEMP_ROLE, roleId: roleId, durationSeconds: duration };
|
||||
}
|
||||
else if (type === EffectType.COLOR_ROLE) {
|
||||
const roleId = interaction.fields.getTextInputValue("role_id");
|
||||
if (roleId) effect = { type: EffectType.COLOR_ROLE, roleId: roleId };
|
||||
}
|
||||
|
||||
if (effect) {
|
||||
draft.usageData.effects.push(effect);
|
||||
}
|
||||
draft.pendingEffectType = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-render
|
||||
const payload = renderWizard(userId);
|
||||
await interaction.deferUpdate();
|
||||
await interaction.editReply(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
// 7. Save
|
||||
if (interaction.customId === "createitem_save") {
|
||||
if (!interaction.isButton()) return;
|
||||
|
||||
await interaction.deferUpdate(); // Prepare to save
|
||||
|
||||
try {
|
||||
await DrizzleClient.insert(items).values({
|
||||
name: draft.name,
|
||||
description: draft.description,
|
||||
type: draft.type,
|
||||
rarity: draft.rarity,
|
||||
price: draft.price ? BigInt(draft.price) : null,
|
||||
iconUrl: draft.iconUrl,
|
||||
imageUrl: draft.imageUrl,
|
||||
usageData: draft.usageData
|
||||
});
|
||||
|
||||
draftSession.delete(userId);
|
||||
await interaction.editReply({ content: `✅ **${draft.name}** has been created successfully!`, embeds: [], components: [] });
|
||||
} catch (error: any) {
|
||||
console.error("Failed to create item:", error);
|
||||
// Restore state
|
||||
await interaction.followUp({ content: `❌ Failed to save item: ${error.message}`, ephemeral: true });
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
14
src/modules/admin/item_wizard.types.ts
Normal file
14
src/modules/admin/item_wizard.types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ItemUsageData } from "@/lib/types";
|
||||
|
||||
export interface DraftItem {
|
||||
name: string;
|
||||
description: string;
|
||||
rarity: string;
|
||||
type: string;
|
||||
price: number | null;
|
||||
iconUrl: string;
|
||||
imageUrl: string;
|
||||
usageData: ItemUsageData;
|
||||
// Temporary state for effect adding flow
|
||||
pendingEffectType?: string;
|
||||
}
|
||||
135
src/modules/admin/item_wizard.view.ts
Normal file
135
src/modules/admin/item_wizard.view.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
ModalBuilder,
|
||||
StringSelectMenuBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle,
|
||||
type MessageActionRowComponentBuilder
|
||||
} from "discord.js";
|
||||
import { createBaseEmbed } from "@lib/embeds";
|
||||
import type { DraftItem } from "./item_wizard.types";
|
||||
import { ItemType } from "@/lib/constants";
|
||||
|
||||
const getItemTypeOptions = () => [
|
||||
{ label: "Material", value: ItemType.MATERIAL, description: "Used for crafting or trading" },
|
||||
{ label: "Consumable", value: ItemType.CONSUMABLE, description: "Can be used to gain effects" },
|
||||
{ label: "Equipment", value: ItemType.EQUIPMENT, description: "Can be equipped (Not yet implemented)" },
|
||||
{ label: "Quest Item", value: ItemType.QUEST, description: "Required for quests" },
|
||||
];
|
||||
|
||||
const getEffectTypeOptions = () => [
|
||||
{ label: "Add XP", value: "ADD_XP", description: "Gives XP to the user" },
|
||||
{ label: "Add Balance", value: "ADD_BALANCE", description: "Gives currency to the user" },
|
||||
{ label: "Reply Message", value: "REPLY_MESSAGE", description: "Bot replies with a message" },
|
||||
{ label: "XP Boost", value: "XP_BOOST", description: "Temporarily boosts XP gain" },
|
||||
{ label: "Temp Role", value: "TEMP_ROLE", description: "Gives a temporary role" },
|
||||
{ label: "Color Role", value: "COLOR_ROLE", description: "Equips a permanent color role (swaps)" },
|
||||
];
|
||||
|
||||
export const getItemWizardEmbed = (draft: DraftItem) => {
|
||||
const embed = createBaseEmbed(`🛠️ Item Creator: ${draft.name}`, undefined, "Blue")
|
||||
.addFields(
|
||||
{ name: "General", value: `**Type:** ${draft.type}\n**Rarity:** ${draft.rarity}\n**Desc:** ${draft.description}`, inline: true },
|
||||
{ name: "Economy", value: `**Price:** ${draft.price ? `${draft.price} 🪙` : "Not for sale"}`, inline: true },
|
||||
{ name: "Visuals", value: `**Icon:** ${draft.iconUrl ? "✅ Set" : "❌"}\n**Image:** ${draft.imageUrl ? "✅ Set" : "❌"}`, inline: true },
|
||||
{ name: "Usage", value: `**Consume:** ${draft.usageData.consume ? "✅ Yes" : "❌ No"}`, inline: true },
|
||||
);
|
||||
|
||||
// Effects Display
|
||||
if (draft.usageData.effects.length > 0) {
|
||||
const effecto = draft.usageData.effects.map((e, i) => `${i + 1}. **${e.type}**: ${JSON.stringify(e)}`).join("\n");
|
||||
embed.addFields({ name: "Usage Effects", value: effecto.substring(0, 1024) });
|
||||
} else {
|
||||
embed.addFields({ name: "Usage Effects", value: "None" });
|
||||
}
|
||||
|
||||
if (draft.imageUrl) embed.setImage(draft.imageUrl);
|
||||
if (draft.iconUrl) embed.setThumbnail(draft.iconUrl);
|
||||
|
||||
// Components
|
||||
const row1 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder().setCustomId("createitem_details").setLabel("Edit Details").setStyle(ButtonStyle.Secondary).setEmoji("📝"),
|
||||
new ButtonBuilder().setCustomId("createitem_economy").setLabel("Edit Economy").setStyle(ButtonStyle.Secondary).setEmoji("💰"),
|
||||
new ButtonBuilder().setCustomId("createitem_visuals").setLabel("Edit Visuals").setStyle(ButtonStyle.Secondary).setEmoji("🖼️"),
|
||||
new ButtonBuilder().setCustomId("createitem_type_toggle").setLabel("Change Type").setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
|
||||
);
|
||||
|
||||
const row2 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder().setCustomId("createitem_addeffect_start").setLabel("Add Effect").setStyle(ButtonStyle.Primary).setEmoji("✨"),
|
||||
new ButtonBuilder().setCustomId("createitem_toggle_consume").setLabel(`Consume: ${draft.usageData.consume ? "ON" : "OFF"}`).setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
|
||||
new ButtonBuilder().setCustomId("createitem_save").setLabel("Save Item").setStyle(ButtonStyle.Success).setEmoji("💾"),
|
||||
new ButtonBuilder().setCustomId("createitem_cancel").setLabel("Cancel").setStyle(ButtonStyle.Danger).setEmoji("✖️")
|
||||
);
|
||||
|
||||
return { embeds: [embed], components: [row1, row2] };
|
||||
};
|
||||
|
||||
export const getItemTypeSelection = () => {
|
||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||
new StringSelectMenuBuilder().setCustomId("createitem_select_type").setPlaceholder("Select Item Type").addOptions(getItemTypeOptions())
|
||||
);
|
||||
return { components: [row] };
|
||||
};
|
||||
|
||||
export const getEffectTypeSelection = () => {
|
||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||
new StringSelectMenuBuilder().setCustomId("createitem_select_effect_type").setPlaceholder("Select Effect Type").addOptions(getEffectTypeOptions())
|
||||
);
|
||||
return { components: [row] };
|
||||
};
|
||||
|
||||
export const getDetailsModal = (current: DraftItem) => {
|
||||
const modal = new ModalBuilder().setCustomId("createitem_modal_details").setTitle("Edit Details");
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("name").setLabel("Name").setValue(current.name).setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("desc").setLabel("Description").setValue(current.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("Common, Rare, Legendary...").setRequired(true))
|
||||
);
|
||||
return modal;
|
||||
};
|
||||
|
||||
export const getEconomyModal = (current: DraftItem) => {
|
||||
const modal = new ModalBuilder().setCustomId("createitem_modal_economy").setTitle("Edit Economy");
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("price").setLabel("Price (0 for not for sale)").setValue(current.price?.toString() || "0").setStyle(TextInputStyle.Short).setRequired(true))
|
||||
);
|
||||
return modal;
|
||||
};
|
||||
|
||||
export const getVisualsModal = (current: DraftItem) => {
|
||||
const modal = new ModalBuilder().setCustomId("createitem_modal_visuals").setTitle("Edit Visuals");
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("icon").setLabel("Icon URL (Emoji or Link)").setValue(current.iconUrl).setStyle(TextInputStyle.Short).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("image").setLabel("Image URL").setValue(current.imageUrl).setStyle(TextInputStyle.Short).setRequired(false))
|
||||
);
|
||||
return modal;
|
||||
};
|
||||
|
||||
export const getEffectConfigModal = (effectType: string) => {
|
||||
let modal = new ModalBuilder().setCustomId("createitem_modal_effect").setTitle(`Config ${effectType}`);
|
||||
|
||||
if (effectType === "ADD_XP" || effectType === "ADD_BALANCE") {
|
||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("amount").setLabel("Amount").setStyle(TextInputStyle.Short).setRequired(true).setPlaceholder("100")));
|
||||
} else if (effectType === "REPLY_MESSAGE") {
|
||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("message").setLabel("Message").setStyle(TextInputStyle.Paragraph).setRequired(true)));
|
||||
} else if (effectType === "XP_BOOST") {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("multiplier").setLabel("Multiplier (e.g. 1.5)").setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||
);
|
||||
} else if (effectType === "TEMP_ROLE") {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||
);
|
||||
} else if (effectType === "COLOR_ROLE") {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true))
|
||||
);
|
||||
}
|
||||
return modal;
|
||||
};
|
||||
248
src/modules/admin/update.service.test.ts
Normal file
248
src/modules/admin/update.service.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { describe, expect, test, mock, beforeEach, afterAll, spyOn } from "bun:test";
|
||||
import * as fs from "fs/promises";
|
||||
|
||||
// Mock child_process BEFORE importing the service
|
||||
const mockExec = mock((cmd: string, callback?: any) => {
|
||||
// Handle calls without callback (like exec().unref())
|
||||
if (!callback) {
|
||||
return { unref: () => { } };
|
||||
}
|
||||
|
||||
if (cmd.includes("git rev-parse")) {
|
||||
callback(null, { stdout: "main\n" });
|
||||
} else if (cmd.includes("git fetch")) {
|
||||
callback(null, { stdout: "" });
|
||||
} else if (cmd.includes("git log")) {
|
||||
callback(null, { stdout: "abcdef Update 1\n123456 Update 2" });
|
||||
} else if (cmd.includes("git diff")) {
|
||||
callback(null, { stdout: "package.json\nsrc/index.ts" });
|
||||
} else if (cmd.includes("git reset")) {
|
||||
callback(null, { stdout: "HEAD is now at abcdef Update 1" });
|
||||
} else if (cmd.includes("bun install")) {
|
||||
callback(null, { stdout: "Installed dependencies" });
|
||||
} else if (cmd.includes("drizzle-kit migrate")) {
|
||||
callback(null, { stdout: "Migrations applied" });
|
||||
} else {
|
||||
callback(null, { stdout: "" });
|
||||
}
|
||||
});
|
||||
|
||||
mock.module("child_process", () => ({
|
||||
exec: mockExec
|
||||
}));
|
||||
|
||||
// Mock fs/promises
|
||||
const mockWriteFile = mock((path: string, content: string) => Promise.resolve());
|
||||
const mockReadFile = mock((path: string, encoding: string) => Promise.resolve("{}"));
|
||||
const mockUnlink = mock((path: string) => Promise.resolve());
|
||||
|
||||
mock.module("fs/promises", () => ({
|
||||
writeFile: mockWriteFile,
|
||||
readFile: mockReadFile,
|
||||
unlink: mockUnlink
|
||||
}));
|
||||
|
||||
// Mock view module to avoid import issues
|
||||
mock.module("./update.view", () => ({
|
||||
getPostRestartEmbed: () => ({ title: "Update Complete" }),
|
||||
getInstallingDependenciesEmbed: () => ({ title: "Installing..." }),
|
||||
}));
|
||||
|
||||
describe("UpdateService", () => {
|
||||
let UpdateService: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockExec.mockClear();
|
||||
mockWriteFile.mockClear();
|
||||
mockReadFile.mockClear();
|
||||
mockUnlink.mockClear();
|
||||
|
||||
// Dynamically import to ensure mock is used
|
||||
const module = await import("./update.service");
|
||||
UpdateService = module.UpdateService;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe("checkForUpdates", () => {
|
||||
test("should return updates if git log has output", async () => {
|
||||
const result = await UpdateService.checkForUpdates();
|
||||
|
||||
expect(result.hasUpdates).toBe(true);
|
||||
expect(result.branch).toBe("main");
|
||||
expect(result.log).toContain("Update 1");
|
||||
});
|
||||
|
||||
test("should call git rev-parse, fetch, and log commands", async () => {
|
||||
await UpdateService.checkForUpdates();
|
||||
|
||||
const calls = mockExec.mock.calls.map((c: any) => c[0]);
|
||||
expect(calls.some((cmd: string) => cmd.includes("git rev-parse"))).toBe(true);
|
||||
expect(calls.some((cmd: string) => cmd.includes("git fetch"))).toBe(true);
|
||||
expect(calls.some((cmd: string) => cmd.includes("git log"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("performUpdate", () => {
|
||||
test("should run git reset --hard with correct branch", async () => {
|
||||
await UpdateService.performUpdate("main");
|
||||
|
||||
const lastCall = mockExec.mock.lastCall;
|
||||
expect(lastCall).toBeDefined();
|
||||
expect(lastCall![0]).toContain("git reset --hard origin/main");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkUpdateRequirements", () => {
|
||||
test("should detect package.json and schema.ts changes", async () => {
|
||||
const result = await UpdateService.checkUpdateRequirements("main");
|
||||
|
||||
expect(result.needsInstall).toBe(true);
|
||||
expect(result.needsMigrations).toBe(false); // mock doesn't include schema.ts
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should call git diff with correct branch", async () => {
|
||||
await UpdateService.checkUpdateRequirements("develop");
|
||||
|
||||
const lastCall = mockExec.mock.lastCall;
|
||||
expect(lastCall).toBeDefined();
|
||||
expect(lastCall![0]).toContain("git diff HEAD..origin/develop");
|
||||
});
|
||||
});
|
||||
|
||||
describe("installDependencies", () => {
|
||||
test("should run bun install and return output", async () => {
|
||||
const output = await UpdateService.installDependencies();
|
||||
|
||||
expect(output).toBe("Installed dependencies");
|
||||
const lastCall = mockExec.mock.lastCall;
|
||||
expect(lastCall![0]).toBe("bun install");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareRestartContext", () => {
|
||||
test("should write context to file", async () => {
|
||||
const context = {
|
||||
channelId: "123",
|
||||
userId: "456",
|
||||
timestamp: Date.now(),
|
||||
runMigrations: true,
|
||||
installDependencies: false
|
||||
};
|
||||
|
||||
await UpdateService.prepareRestartContext(context);
|
||||
|
||||
expect(mockWriteFile).toHaveBeenCalled();
|
||||
const lastCall = mockWriteFile.mock.lastCall as [string, string] | undefined;
|
||||
expect(lastCall).toBeDefined();
|
||||
expect(lastCall![0]).toContain("restart_context");
|
||||
expect(JSON.parse(lastCall![1])).toEqual(context);
|
||||
});
|
||||
});
|
||||
|
||||
describe("triggerRestart", () => {
|
||||
test("should use RESTART_COMMAND env var when set", async () => {
|
||||
const originalEnv = process.env.RESTART_COMMAND;
|
||||
process.env.RESTART_COMMAND = "pm2 restart bot";
|
||||
|
||||
await UpdateService.triggerRestart();
|
||||
|
||||
const lastCall = mockExec.mock.lastCall;
|
||||
expect(lastCall).toBeDefined();
|
||||
expect(lastCall![0]).toBe("pm2 restart bot");
|
||||
|
||||
process.env.RESTART_COMMAND = originalEnv;
|
||||
});
|
||||
|
||||
test("should write to trigger file when no env var", async () => {
|
||||
const originalEnv = process.env.RESTART_COMMAND;
|
||||
delete process.env.RESTART_COMMAND;
|
||||
|
||||
await UpdateService.triggerRestart();
|
||||
|
||||
expect(mockWriteFile).toHaveBeenCalled();
|
||||
const lastCall = mockWriteFile.mock.lastCall as [string, string] | undefined;
|
||||
expect(lastCall).toBeDefined();
|
||||
expect(lastCall![0]).toContain("restart_trigger");
|
||||
|
||||
process.env.RESTART_COMMAND = originalEnv;
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlePostRestart", () => {
|
||||
const createMockClient = (channel: any = null) => ({
|
||||
channels: {
|
||||
fetch: mock(() => Promise.resolve(channel))
|
||||
}
|
||||
});
|
||||
|
||||
const createMockChannel = () => ({
|
||||
isSendable: () => true,
|
||||
send: mock(() => Promise.resolve())
|
||||
});
|
||||
|
||||
test("should ignore stale context (>10 mins old)", async () => {
|
||||
const staleContext = {
|
||||
channelId: "123",
|
||||
userId: "456",
|
||||
timestamp: Date.now() - (15 * 60 * 1000), // 15 mins ago
|
||||
runMigrations: true,
|
||||
installDependencies: true
|
||||
};
|
||||
|
||||
mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(staleContext)));
|
||||
|
||||
const mockChannel = createMockChannel();
|
||||
// Create mock with instanceof support
|
||||
const channel = Object.assign(mockChannel, { constructor: { name: "TextChannel" } });
|
||||
Object.setPrototypeOf(channel, Object.create({ constructor: { name: "TextChannel" } }));
|
||||
|
||||
const mockClient = createMockClient(channel);
|
||||
|
||||
await UpdateService.handlePostRestart(mockClient);
|
||||
|
||||
// Should not send any message for stale context
|
||||
expect(mockChannel.send).not.toHaveBeenCalled();
|
||||
// Should clean up the context file
|
||||
expect(mockUnlink).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should do nothing if no context file exists", async () => {
|
||||
mockReadFile.mockImplementationOnce(() => Promise.reject(new Error("ENOENT")));
|
||||
|
||||
const mockClient = createMockClient();
|
||||
|
||||
await UpdateService.handlePostRestart(mockClient);
|
||||
|
||||
// Should not throw and not try to clean up
|
||||
expect(mockUnlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should clean up context file after processing", async () => {
|
||||
const validContext = {
|
||||
channelId: "123",
|
||||
userId: "456",
|
||||
timestamp: Date.now(),
|
||||
runMigrations: false,
|
||||
installDependencies: false
|
||||
};
|
||||
|
||||
mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(validContext)));
|
||||
|
||||
// Create a proper TextChannel mock
|
||||
const { TextChannel } = await import("discord.js");
|
||||
const mockChannel = Object.create(TextChannel.prototype);
|
||||
mockChannel.isSendable = () => true;
|
||||
mockChannel.send = mock(() => Promise.resolve());
|
||||
|
||||
const mockClient = createMockClient(mockChannel);
|
||||
|
||||
await UpdateService.handlePostRestart(mockClient);
|
||||
|
||||
expect(mockUnlink).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
188
src/modules/admin/update.service.ts
Normal file
188
src/modules/admin/update.service.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { writeFile, readFile, unlink } from "fs/promises";
|
||||
import { Client, TextChannel } from "discord.js";
|
||||
import { getPostRestartEmbed, getInstallingDependenciesEmbed } from "./update.view";
|
||||
import type { PostRestartResult } from "./update.view";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Constants
|
||||
const STALE_CONTEXT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
export interface RestartContext {
|
||||
channelId: string;
|
||||
userId: string;
|
||||
timestamp: number;
|
||||
runMigrations: boolean;
|
||||
installDependencies: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
needsInstall: boolean;
|
||||
needsMigrations: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class UpdateService {
|
||||
private static readonly CONTEXT_FILE = ".restart_context.json";
|
||||
|
||||
static async checkForUpdates(): Promise<{ hasUpdates: boolean; log: string; branch: string }> {
|
||||
const { stdout: branchName } = await execAsync("git rev-parse --abbrev-ref HEAD");
|
||||
const branch = branchName.trim();
|
||||
|
||||
await execAsync("git fetch --all");
|
||||
const { stdout: logOutput } = await execAsync(`git log HEAD..origin/${branch} --oneline`);
|
||||
|
||||
return {
|
||||
hasUpdates: !!logOutput.trim(),
|
||||
log: logOutput.trim(),
|
||||
branch
|
||||
};
|
||||
}
|
||||
|
||||
static async performUpdate(branch: string): Promise<void> {
|
||||
await execAsync(`git reset --hard origin/${branch}`);
|
||||
}
|
||||
|
||||
static async checkUpdateRequirements(branch: string): Promise<UpdateCheckResult> {
|
||||
try {
|
||||
const { stdout } = await execAsync(`git diff HEAD..origin/${branch} --name-only`);
|
||||
return {
|
||||
needsInstall: stdout.includes("package.json"),
|
||||
needsMigrations: stdout.includes("schema.ts") || stdout.includes("drizzle/")
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Failed to check update requirements:", e);
|
||||
return {
|
||||
needsInstall: false,
|
||||
needsMigrations: false,
|
||||
error: e instanceof Error ? e : new Error(String(e))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static async installDependencies(): Promise<string> {
|
||||
const { stdout } = await execAsync("bun install");
|
||||
return stdout;
|
||||
}
|
||||
|
||||
static async prepareRestartContext(context: RestartContext): Promise<void> {
|
||||
await writeFile(this.CONTEXT_FILE, JSON.stringify(context));
|
||||
}
|
||||
|
||||
static async triggerRestart(): Promise<void> {
|
||||
if (process.env.RESTART_COMMAND) {
|
||||
// Run without awaiting - it may kill the process immediately
|
||||
exec(process.env.RESTART_COMMAND).unref();
|
||||
} else {
|
||||
// Fallback: exit the process and let Docker/PM2/systemd restart it
|
||||
// Small delay to allow any pending I/O to complete
|
||||
setTimeout(() => process.exit(0), 100);
|
||||
}
|
||||
}
|
||||
|
||||
static async handlePostRestart(client: Client): Promise<void> {
|
||||
try {
|
||||
const context = await this.loadRestartContext();
|
||||
if (!context) return;
|
||||
|
||||
if (this.isContextStale(context)) {
|
||||
await this.cleanupContext();
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = await this.fetchNotificationChannel(client, context.channelId);
|
||||
if (!channel) {
|
||||
await this.cleanupContext();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.executePostRestartTasks(context, channel);
|
||||
await this.notifyPostRestartResult(channel, result);
|
||||
await this.cleanupContext();
|
||||
} catch (e) {
|
||||
console.error("Failed to handle post-restart context:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Private Helper Methods ---
|
||||
|
||||
private static async loadRestartContext(): Promise<RestartContext | null> {
|
||||
try {
|
||||
const contextData = await readFile(this.CONTEXT_FILE, "utf-8");
|
||||
return JSON.parse(contextData) as RestartContext;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static isContextStale(context: RestartContext): boolean {
|
||||
return Date.now() - context.timestamp > STALE_CONTEXT_MS;
|
||||
}
|
||||
|
||||
private static async fetchNotificationChannel(client: Client, channelId: string): Promise<TextChannel | null> {
|
||||
try {
|
||||
const channel = await client.channels.fetch(channelId);
|
||||
if (channel && channel.isSendable() && channel instanceof TextChannel) {
|
||||
return channel;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async executePostRestartTasks(
|
||||
context: RestartContext,
|
||||
channel: TextChannel
|
||||
): Promise<PostRestartResult> {
|
||||
const result: PostRestartResult = {
|
||||
installSuccess: true,
|
||||
installOutput: "",
|
||||
migrationSuccess: true,
|
||||
migrationOutput: "",
|
||||
ranInstall: context.installDependencies,
|
||||
ranMigrations: context.runMigrations
|
||||
};
|
||||
|
||||
// 1. Install Dependencies if needed
|
||||
if (context.installDependencies) {
|
||||
try {
|
||||
await channel.send({ embeds: [getInstallingDependenciesEmbed()] });
|
||||
const { stdout } = await execAsync("bun install");
|
||||
result.installOutput = stdout;
|
||||
} catch (err: unknown) {
|
||||
result.installSuccess = false;
|
||||
result.installOutput = err instanceof Error ? err.message : String(err);
|
||||
console.error("Dependency Install Failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Run Migrations
|
||||
if (context.runMigrations) {
|
||||
try {
|
||||
const { stdout } = await execAsync("bun x drizzle-kit migrate");
|
||||
result.migrationOutput = stdout;
|
||||
} catch (err: unknown) {
|
||||
result.migrationSuccess = false;
|
||||
result.migrationOutput = err instanceof Error ? err.message : String(err);
|
||||
console.error("Migration Failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async notifyPostRestartResult(channel: TextChannel, result: PostRestartResult): Promise<void> {
|
||||
await channel.send({ embeds: [getPostRestartEmbed(result)] });
|
||||
}
|
||||
|
||||
private static async cleanupContext(): Promise<void> {
|
||||
try {
|
||||
await unlink(this.CONTEXT_FILE);
|
||||
} catch {
|
||||
// File may not exist, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
100
src/modules/admin/update.view.ts
Normal file
100
src/modules/admin/update.view.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
|
||||
import { createInfoEmbed, createSuccessEmbed, createWarningEmbed, createErrorEmbed } from "@lib/embeds";
|
||||
|
||||
// Constants for UI
|
||||
const LOG_TRUNCATE_LENGTH = 1000;
|
||||
const OUTPUT_TRUNCATE_LENGTH = 500;
|
||||
|
||||
function truncate(text: string, maxLength: number): string {
|
||||
return text.length > maxLength ? `${text.substring(0, maxLength)}\n...and more` : text;
|
||||
}
|
||||
|
||||
export function getCheckingEmbed() {
|
||||
return createInfoEmbed("Checking for updates...", "System Update");
|
||||
}
|
||||
|
||||
export function getNoUpdatesEmbed() {
|
||||
return createSuccessEmbed("The bot is already up to date.", "No Updates Found");
|
||||
}
|
||||
|
||||
export function getUpdatesAvailableMessage(branch: string, log: string, force: boolean) {
|
||||
const embed = createInfoEmbed(
|
||||
`**Branch:** \`${branch}\`\n\n**Pending Changes:**\n\`\`\`\n${truncate(log, LOG_TRUNCATE_LENGTH)}\n\`\`\`\n**Do you want to proceed?**`,
|
||||
"Updates Available"
|
||||
);
|
||||
|
||||
const confirmButton = new ButtonBuilder()
|
||||
.setCustomId("confirm_update")
|
||||
.setLabel(force ? "Force Update & Restart" : "Update & Restart")
|
||||
.setStyle(force ? ButtonStyle.Danger : ButtonStyle.Success);
|
||||
|
||||
const cancelButton = new ButtonBuilder()
|
||||
.setCustomId("cancel_update")
|
||||
.setLabel("Cancel")
|
||||
.setStyle(ButtonStyle.Secondary);
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>()
|
||||
.addComponents(confirmButton, cancelButton);
|
||||
|
||||
return { embeds: [embed], components: [row] };
|
||||
}
|
||||
|
||||
export function getPreparingEmbed() {
|
||||
return createInfoEmbed("⏳ Preparing update...", "Update In Progress");
|
||||
}
|
||||
|
||||
export function getUpdatingEmbed(needsDependencyInstall: boolean) {
|
||||
const message = `Downloading and applying updates...${needsDependencyInstall ? `\nExpect a slightly longer startup for dependency installation.` : ""}\nThe system will restart automatically.`;
|
||||
return createWarningEmbed(message, "Updating & Restarting");
|
||||
}
|
||||
|
||||
export function getCancelledEmbed() {
|
||||
return createInfoEmbed("Update cancelled.", "Cancelled");
|
||||
}
|
||||
|
||||
export function getTimeoutEmbed() {
|
||||
return createWarningEmbed("Update confirmation timed out.", "Timed Out");
|
||||
}
|
||||
|
||||
export function getErrorEmbed(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return createErrorEmbed(`Failed to update:\n\`\`\`\n${message}\n\`\`\``, "Update Failed");
|
||||
}
|
||||
|
||||
export interface PostRestartResult {
|
||||
installSuccess: boolean;
|
||||
installOutput: string;
|
||||
migrationSuccess: boolean;
|
||||
migrationOutput: string;
|
||||
ranInstall: boolean;
|
||||
ranMigrations: boolean;
|
||||
}
|
||||
|
||||
export function getPostRestartEmbed(result: PostRestartResult) {
|
||||
const parts: string[] = ["System updated successfully."];
|
||||
|
||||
if (result.ranInstall) {
|
||||
parts.push(`**Dependencies:** ${result.installSuccess ? "✅ Installed" : "❌ Failed"}`);
|
||||
}
|
||||
|
||||
if (result.ranMigrations) {
|
||||
parts.push(`**Migrations:** ${result.migrationSuccess ? "✅ Applied" : "❌ Failed"}`);
|
||||
}
|
||||
|
||||
if (result.installOutput) {
|
||||
parts.push(`\n**Install Output:**\n\`\`\`\n${truncate(result.installOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``);
|
||||
}
|
||||
|
||||
if (result.migrationOutput) {
|
||||
parts.push(`\n**Migration Output:**\n\`\`\`\n${truncate(result.migrationOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``);
|
||||
}
|
||||
|
||||
const isSuccess = result.installSuccess && result.migrationSuccess;
|
||||
const title = isSuccess ? "Update Complete" : "Update Completed with Errors";
|
||||
|
||||
return createSuccessEmbed(parts.join("\n"), title);
|
||||
}
|
||||
|
||||
export function getInstallingDependenciesEmbed() {
|
||||
return createSuccessEmbed("Installing dependencies...", "Post-Update Action");
|
||||
}
|
||||
208
src/modules/class/class.service.test.ts
Normal file
208
src/modules/class/class.service.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||
import { classService } from "./class.service";
|
||||
import { classes, users } from "@/db/schema";
|
||||
|
||||
// Define mock functions
|
||||
const mockFindMany = mock();
|
||||
const mockFindFirst = mock();
|
||||
const mockInsert = mock();
|
||||
const mockUpdate = mock();
|
||||
const mockDelete = mock();
|
||||
const mockValues = mock();
|
||||
const mockReturning = mock();
|
||||
const mockSet = mock();
|
||||
const mockWhere = mock();
|
||||
|
||||
// Chainable mock setup
|
||||
mockInsert.mockReturnValue({ values: mockValues });
|
||||
mockValues.mockReturnValue({ returning: mockReturning });
|
||||
|
||||
mockUpdate.mockReturnValue({ set: mockSet });
|
||||
mockSet.mockReturnValue({ where: mockWhere });
|
||||
mockWhere.mockReturnValue({ returning: mockReturning });
|
||||
|
||||
mockDelete.mockReturnValue({ where: mockWhere }); // Fix for delete chaining if needed, usually delete(table).where(...)
|
||||
|
||||
// Mock DrizzleClient
|
||||
mock.module("@/lib/DrizzleClient", () => {
|
||||
return {
|
||||
DrizzleClient: {
|
||||
query: {
|
||||
classes: {
|
||||
findMany: mockFindMany,
|
||||
findFirst: mockFindFirst,
|
||||
},
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
transaction: async (cb: any) => {
|
||||
return cb({
|
||||
query: {
|
||||
classes: {
|
||||
findMany: mockFindMany,
|
||||
findFirst: mockFindFirst,
|
||||
},
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("classService", () => {
|
||||
beforeEach(() => {
|
||||
mockFindMany.mockReset();
|
||||
mockFindFirst.mockReset();
|
||||
mockInsert.mockClear();
|
||||
mockUpdate.mockClear();
|
||||
mockDelete.mockClear();
|
||||
mockValues.mockClear();
|
||||
mockReturning.mockClear();
|
||||
mockSet.mockClear();
|
||||
mockWhere.mockClear();
|
||||
});
|
||||
|
||||
describe("getAllClasses", () => {
|
||||
it("should return all classes", async () => {
|
||||
const mockClasses = [{ id: 1n, name: "Warrior" }, { id: 2n, name: "Mage" }];
|
||||
mockFindMany.mockResolvedValue(mockClasses);
|
||||
|
||||
const result = await classService.getAllClasses();
|
||||
|
||||
expect(result).toEqual(mockClasses as any);
|
||||
expect(mockFindMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assignClass", () => {
|
||||
it("should assign class to user if class exists", async () => {
|
||||
const mockClass = { id: 1n, name: "Warrior" };
|
||||
const mockUser = { id: 123n, classId: 1n };
|
||||
|
||||
mockFindFirst.mockResolvedValue(mockClass);
|
||||
mockReturning.mockResolvedValue([mockUser]);
|
||||
|
||||
const result = await classService.assignClass("123", 1n);
|
||||
|
||||
expect(result).toEqual(mockUser as any);
|
||||
// Verify class check
|
||||
expect(mockFindFirst).toHaveBeenCalledTimes(1);
|
||||
// Verify update
|
||||
expect(mockUpdate).toHaveBeenCalledWith(users);
|
||||
expect(mockSet).toHaveBeenCalledWith({ classId: 1n });
|
||||
});
|
||||
|
||||
it("should throw error if class not found", async () => {
|
||||
mockFindFirst.mockResolvedValue(undefined);
|
||||
|
||||
expect(classService.assignClass("123", 99n)).rejects.toThrow("Class not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getClassBalance", () => {
|
||||
it("should return class balance", async () => {
|
||||
const mockClass = { id: 1n, balance: 100n };
|
||||
mockFindFirst.mockResolvedValue(mockClass);
|
||||
|
||||
const result = await classService.getClassBalance(1n);
|
||||
|
||||
expect(result).toBe(100n);
|
||||
});
|
||||
|
||||
it("should return 0n if class has no balance or not found", async () => {
|
||||
mockFindFirst.mockResolvedValue(null);
|
||||
const result = await classService.getClassBalance(1n);
|
||||
expect(result).toBe(0n);
|
||||
|
||||
mockFindFirst.mockResolvedValue({ id: 1n, balance: null });
|
||||
const result2 = await classService.getClassBalance(1n);
|
||||
expect(result2).toBe(0n);
|
||||
});
|
||||
});
|
||||
|
||||
describe("modifyClassBalance", () => {
|
||||
it("should modify class balance successfully", async () => {
|
||||
const mockClass = { id: 1n, balance: 100n };
|
||||
const updatedClass = { id: 1n, balance: 150n };
|
||||
|
||||
mockFindFirst.mockResolvedValue(mockClass);
|
||||
mockReturning.mockResolvedValue([updatedClass]);
|
||||
|
||||
const result = await classService.modifyClassBalance(1n, 50n);
|
||||
|
||||
expect(result).toEqual(updatedClass as any);
|
||||
expect(mockUpdate).toHaveBeenCalledWith(classes);
|
||||
// Note: sql template literal matching might be tricky, checking strict call might fail if not exact object ref
|
||||
// We verify at least mockSet was called
|
||||
expect(mockSet).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw if class not found", async () => {
|
||||
mockFindFirst.mockResolvedValue(undefined);
|
||||
expect(classService.modifyClassBalance(1n, 50n)).rejects.toThrow("Class not found");
|
||||
});
|
||||
|
||||
it("should throw if insufficient funds", async () => {
|
||||
const mockClass = { id: 1n, balance: 10n };
|
||||
mockFindFirst.mockResolvedValue(mockClass);
|
||||
|
||||
expect(classService.modifyClassBalance(1n, -20n)).rejects.toThrow("Insufficient class funds");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateClass", () => {
|
||||
it("should update class details", async () => {
|
||||
const updateData = { name: "Super Warrior" };
|
||||
const updatedClass = { id: 1n, name: "Super Warrior" };
|
||||
|
||||
mockReturning.mockResolvedValue([updatedClass]);
|
||||
|
||||
const result = await classService.updateClass(1n, updateData);
|
||||
|
||||
expect(result).toEqual(updatedClass as any);
|
||||
expect(mockUpdate).toHaveBeenCalledWith(classes);
|
||||
expect(mockSet).toHaveBeenCalledWith(updateData);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createClass", () => {
|
||||
it("should create a new class", async () => {
|
||||
const newClassData = { name: "Archer", description: "Bow user" };
|
||||
const createdClass = { id: 3n, ...newClassData };
|
||||
|
||||
mockReturning.mockResolvedValue([createdClass]);
|
||||
|
||||
const result = await classService.createClass(newClassData as any);
|
||||
|
||||
expect(result).toEqual(createdClass as any);
|
||||
expect(mockInsert).toHaveBeenCalledWith(classes);
|
||||
expect(mockValues).toHaveBeenCalledWith(newClassData);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteClass", () => {
|
||||
it("should delete a class", async () => {
|
||||
mockDelete.mockReturnValue({ where: mockWhere });
|
||||
// The chain is delete(table).where(...) for delete
|
||||
|
||||
// Wait, in user.service.test.ts:
|
||||
// mockDelete called without chain setup in the file provided?
|
||||
// "mockDelete = mock()"
|
||||
// And in mock: "delete: mockDelete"
|
||||
// And in usage: "await txFn.delete(users).where(...)"
|
||||
// So mockDelete must return an object with where.
|
||||
|
||||
mockDelete.mockReturnValue({ where: mockWhere });
|
||||
|
||||
await classService.deleteClass(1n);
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith(classes);
|
||||
// We can't easily check 'where' arguments specifically without complex matcher if we don't return specific mock
|
||||
expect(mockWhere).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,22 @@
|
||||
|
||||
import { classes, users } from "@/db/schema";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { withTransaction } from "@/lib/db";
|
||||
import type { Transaction } from "@/lib/types";
|
||||
|
||||
export const classService = {
|
||||
getAllClasses: async () => {
|
||||
return await DrizzleClient.query.classes.findMany();
|
||||
},
|
||||
|
||||
assignClass: async (userId: string, classId: bigint, tx?: any) => {
|
||||
const execute = async (txFn: any) => {
|
||||
assignClass: async (userId: string, classId: bigint, tx?: Transaction) => {
|
||||
return await withTransaction(async (txFn) => {
|
||||
const cls = await txFn.query.classes.findFirst({
|
||||
where: eq(classes.id, classId),
|
||||
});
|
||||
|
||||
if (!cls) throw new Error("Class not found");
|
||||
if (!cls) throw new UserError("Class not found");
|
||||
|
||||
const [user] = await txFn.update(users)
|
||||
.set({ classId: classId })
|
||||
@@ -22,8 +24,7 @@ export const classService = {
|
||||
.returning();
|
||||
|
||||
return user;
|
||||
};
|
||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
||||
}, tx);
|
||||
},
|
||||
getClassBalance: async (classId: bigint) => {
|
||||
const cls = await DrizzleClient.query.classes.findFirst({
|
||||
@@ -31,38 +32,51 @@ export const classService = {
|
||||
});
|
||||
return cls?.balance || 0n;
|
||||
},
|
||||
modifyClassBalance: async (classId: bigint, amount: bigint, tx?: any) => {
|
||||
const execute = async (txFn: any) => {
|
||||
modifyClassBalance: async (classId: bigint, amount: bigint, tx?: Transaction) => {
|
||||
return await withTransaction(async (txFn) => {
|
||||
const cls = await txFn.query.classes.findFirst({
|
||||
where: eq(classes.id, classId),
|
||||
});
|
||||
|
||||
if (!cls) throw new Error("Class not found");
|
||||
if (!cls) throw new UserError("Class not found");
|
||||
|
||||
if (amount < 0n && (cls.balance ?? 0n) < -amount) {
|
||||
throw new Error("Insufficient class funds");
|
||||
if ((cls.balance ?? 0n) + amount < 0n) {
|
||||
throw new UserError("Insufficient class funds");
|
||||
}
|
||||
|
||||
const [updatedClass] = await txFn.update(classes)
|
||||
.set({
|
||||
balance: sql`${classes.balance} + ${amount}`,
|
||||
balance: sql`${classes.balance} + ${amount} `,
|
||||
})
|
||||
.where(eq(classes.id, classId))
|
||||
.returning();
|
||||
|
||||
return updatedClass;
|
||||
};
|
||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
||||
}, tx);
|
||||
},
|
||||
|
||||
updateClass: async (id: bigint, data: Partial<typeof classes.$inferInsert>, tx?: any) => {
|
||||
const execute = async (txFn: any) => {
|
||||
updateClass: async (id: bigint, data: Partial<typeof classes.$inferInsert>, tx?: Transaction) => {
|
||||
return await withTransaction(async (txFn) => {
|
||||
const [updatedClass] = await txFn.update(classes)
|
||||
.set(data)
|
||||
.where(eq(classes.id, id))
|
||||
.returning();
|
||||
return updatedClass;
|
||||
};
|
||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
||||
}, tx);
|
||||
},
|
||||
|
||||
createClass: async (data: typeof classes.$inferInsert, tx?: Transaction) => {
|
||||
return await withTransaction(async (txFn) => {
|
||||
const [newClass] = await txFn.insert(classes)
|
||||
.values(data)
|
||||
.returning();
|
||||
return newClass;
|
||||
}, tx);
|
||||
},
|
||||
|
||||
deleteClass: async (id: bigint, tx?: Transaction) => {
|
||||
return await withTransaction(async (txFn) => {
|
||||
await txFn.delete(classes).where(eq(classes.id, id));
|
||||
}, tx);
|
||||
}
|
||||
};
|
||||
|
||||
261
src/modules/economy/economy.service.test.ts
Normal file
261
src/modules/economy/economy.service.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { describe, it, expect, mock, beforeEach, setSystemTime } from "bun:test";
|
||||
import { economyService } from "./economy.service";
|
||||
import { users, userTimers, transactions } from "@/db/schema";
|
||||
|
||||
// Define mock functions
|
||||
const mockFindMany = mock();
|
||||
const mockFindFirst = mock();
|
||||
const mockInsert = mock();
|
||||
const mockUpdate = mock();
|
||||
const mockDelete = mock();
|
||||
const mockValues = mock();
|
||||
const mockReturning = mock();
|
||||
const mockSet = mock();
|
||||
const mockWhere = mock();
|
||||
const mockOnConflictDoUpdate = mock();
|
||||
|
||||
// Chainable mock setup
|
||||
mockInsert.mockReturnValue({ values: mockValues });
|
||||
mockValues.mockReturnValue({
|
||||
returning: mockReturning,
|
||||
onConflictDoUpdate: mockOnConflictDoUpdate // For claimDaily chain
|
||||
});
|
||||
mockOnConflictDoUpdate.mockResolvedValue({}); // Terminate the chain
|
||||
|
||||
mockUpdate.mockReturnValue({ set: mockSet });
|
||||
mockSet.mockReturnValue({ where: mockWhere });
|
||||
mockWhere.mockReturnValue({ returning: mockReturning });
|
||||
|
||||
// Mock DrizzleClient
|
||||
mock.module("@/lib/DrizzleClient", () => {
|
||||
// Mock Transaction Object Structure
|
||||
const createMockTx = () => ({
|
||||
query: {
|
||||
users: { findFirst: mockFindFirst },
|
||||
userTimers: { findFirst: mockFindFirst },
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
});
|
||||
|
||||
return {
|
||||
DrizzleClient: {
|
||||
query: {
|
||||
users: { findFirst: mockFindFirst },
|
||||
userTimers: { findFirst: mockFindFirst },
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
transaction: async (cb: any) => {
|
||||
return cb(createMockTx());
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock Config
|
||||
mock.module("@/lib/config", () => ({
|
||||
config: {
|
||||
economy: {
|
||||
daily: {
|
||||
amount: 100n,
|
||||
streakBonus: 10n,
|
||||
weeklyBonus: 50n,
|
||||
cooldownMs: 86400000, // 24 hours
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
describe("economyService", () => {
|
||||
beforeEach(() => {
|
||||
mockFindFirst.mockReset();
|
||||
mockInsert.mockClear();
|
||||
mockUpdate.mockClear();
|
||||
mockDelete.mockClear();
|
||||
mockValues.mockClear();
|
||||
mockReturning.mockClear();
|
||||
mockSet.mockClear();
|
||||
mockWhere.mockClear();
|
||||
mockOnConflictDoUpdate.mockClear();
|
||||
});
|
||||
|
||||
describe("transfer", () => {
|
||||
it("should transfer amount successfully", async () => {
|
||||
const sender = { id: 1n, balance: 200n };
|
||||
mockFindFirst.mockResolvedValue(sender);
|
||||
|
||||
const result = await economyService.transfer("1", "2", 50n);
|
||||
|
||||
expect(result).toEqual({ success: true, amount: 50n });
|
||||
|
||||
// Check sender update
|
||||
expect(mockUpdate).toHaveBeenCalledWith(users);
|
||||
// We can check if mockSet was called twice
|
||||
expect(mockSet).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Check transactions created
|
||||
expect(mockInsert).toHaveBeenCalledWith(transactions);
|
||||
});
|
||||
|
||||
it("should throw if amount is non-positive", async () => {
|
||||
expect(economyService.transfer("1", "2", 0n)).rejects.toThrow("Amount must be positive");
|
||||
expect(economyService.transfer("1", "2", -10n)).rejects.toThrow("Amount must be positive");
|
||||
});
|
||||
|
||||
it("should throw if transferring to self", async () => {
|
||||
expect(economyService.transfer("1", "1", 50n)).rejects.toThrow("Cannot transfer to self");
|
||||
});
|
||||
|
||||
it("should throw if sender not found", async () => {
|
||||
mockFindFirst.mockResolvedValue(undefined);
|
||||
expect(economyService.transfer("1", "2", 50n)).rejects.toThrow("Sender not found");
|
||||
});
|
||||
|
||||
it("should throw if insufficient funds", async () => {
|
||||
mockFindFirst.mockResolvedValue({ id: 1n, balance: 20n });
|
||||
expect(economyService.transfer("1", "2", 50n)).rejects.toThrow("Insufficient funds");
|
||||
});
|
||||
});
|
||||
|
||||
describe("claimDaily", () => {
|
||||
beforeEach(() => {
|
||||
setSystemTime(new Date("2023-01-01T12:00:00Z"));
|
||||
});
|
||||
|
||||
it("should claim daily reward successfully", async () => {
|
||||
const recentPast = new Date("2023-01-01T11:00:00Z"); // 1 hour ago
|
||||
|
||||
// First call finds cooldown (expired recently), second finds user
|
||||
mockFindFirst
|
||||
.mockResolvedValueOnce({ expiresAt: recentPast }) // Cooldown check - expired -> ready
|
||||
.mockResolvedValueOnce({ id: 1n, dailyStreak: 5, balance: 1000n }); // User check
|
||||
|
||||
const result = await economyService.claimDaily("1");
|
||||
|
||||
expect(result.claimed).toBe(true);
|
||||
// Streak should increase: 5 + 1 = 6
|
||||
expect(result.streak).toBe(6);
|
||||
// Base 100 + (6-1)*10 = 150
|
||||
expect(result.amount).toBe(150n);
|
||||
expect(result.isWeekly).toBe(false);
|
||||
|
||||
// Check updates
|
||||
expect(mockUpdate).toHaveBeenCalledWith(users);
|
||||
expect(mockInsert).toHaveBeenCalledWith(userTimers);
|
||||
expect(mockInsert).toHaveBeenCalledWith(transactions);
|
||||
});
|
||||
|
||||
it("should claim weekly bonus correctly on 7th day", async () => {
|
||||
const recentPast = new Date("2023-01-01T11:00:00Z");
|
||||
|
||||
mockFindFirst
|
||||
.mockResolvedValueOnce({ expiresAt: recentPast })
|
||||
.mockResolvedValueOnce({ id: 1n, dailyStreak: 6, balance: 1000n }); // User currently at 6 days
|
||||
|
||||
const result = await economyService.claimDaily("1");
|
||||
|
||||
expect(result.claimed).toBe(true);
|
||||
// Streak should increase: 6 + 1 = 7
|
||||
expect(result.streak).toBe(7);
|
||||
|
||||
// Base: 100
|
||||
// Streak Bonus: (7-1)*10 = 60
|
||||
// Weekly Bonus: 50
|
||||
// Total: 210
|
||||
expect(result.amount).toBe(210n);
|
||||
expect(result.isWeekly).toBe(true);
|
||||
expect(result.weeklyBonus).toBe(50n);
|
||||
});
|
||||
|
||||
it("should throw if cooldown is active", async () => {
|
||||
const future = new Date("2023-01-02T12:00:00Z"); // +24h
|
||||
mockFindFirst.mockResolvedValue({ expiresAt: future });
|
||||
expect(economyService.claimDaily("1")).rejects.toThrow("Daily already claimed");
|
||||
});
|
||||
|
||||
it("should set cooldown to next UTC midnight", async () => {
|
||||
// 2023-01-01T12:00:00Z -> Should be 2023-01-02T00:00:00Z
|
||||
|
||||
mockFindFirst
|
||||
.mockResolvedValueOnce(undefined) // No cooldown
|
||||
.mockResolvedValueOnce({ id: 1n, dailyStreak: 5, balance: 1000n });
|
||||
|
||||
const result = await economyService.claimDaily("1");
|
||||
|
||||
const expectedReset = new Date("2023-01-02T00:00:00Z");
|
||||
expect(result.nextReadyAt.toISOString()).toBe(expectedReset.toISOString());
|
||||
});
|
||||
|
||||
it("should reset streak if missed a day (long time gap)", async () => {
|
||||
// Expired 3 days ago
|
||||
const past = new Date("2023-01-01T00:00:00Z"); // now is 12:00
|
||||
// Wait, logic says: if (timeSinceReady > 24h)
|
||||
// now - expiresAt.
|
||||
// If cooldown expired 2022-12-30. Now is 2023-01-01. Gap is > 24h.
|
||||
|
||||
const expiredAt = new Date("2022-12-30T12:00:00Z");
|
||||
|
||||
mockFindFirst
|
||||
.mockResolvedValueOnce({ expiresAt: expiredAt })
|
||||
.mockResolvedValueOnce({ id: 1n, dailyStreak: 5 });
|
||||
|
||||
const result = await economyService.claimDaily("1");
|
||||
|
||||
// timeSinceReady = 48h.
|
||||
// streak should reset to 1
|
||||
expect(result.streak).toBe(1);
|
||||
});
|
||||
|
||||
it("should preserve streak if cooldown is missing but user has a streak", async () => {
|
||||
mockFindFirst
|
||||
.mockResolvedValueOnce(undefined) // No cooldown
|
||||
.mockResolvedValueOnce({ id: 1n, dailyStreak: 10 });
|
||||
|
||||
const result = await economyService.claimDaily("1");
|
||||
expect(result.streak).toBe(11);
|
||||
});
|
||||
|
||||
it("should prevent weekly bonus exploit by resetting streak", async () => {
|
||||
// Mock user at streak 7.
|
||||
// Mock time as 24h + 1m after expiry.
|
||||
|
||||
const expiredAt = new Date("2023-01-01T11:59:00Z"); // now is 12:00 next day, plus 1 min gap?
|
||||
// no, 'now' is 2023-01-01T12:00:00Z set in beforeEach
|
||||
|
||||
// We want gap > 24h.
|
||||
// If expiry was yesterday 11:59:59. Gap is 24h + 1s.
|
||||
|
||||
const expiredAtExploit = new Date("2022-12-31T11:59:00Z"); // Over 24h ago
|
||||
|
||||
mockFindFirst
|
||||
.mockResolvedValueOnce({ expiresAt: expiredAtExploit })
|
||||
.mockResolvedValueOnce({ id: 1n, dailyStreak: 7 });
|
||||
|
||||
const result = await economyService.claimDaily("1");
|
||||
|
||||
// Should reset to 1
|
||||
expect(result.streak).toBe(1);
|
||||
expect(result.isWeekly).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("modifyUserBalance", () => {
|
||||
it("should add balance successfully", async () => {
|
||||
mockReturning.mockResolvedValue([{ id: 1n, balance: 150n }]);
|
||||
|
||||
const result = await economyService.modifyUserBalance("1", 50n, "TEST", "Test add");
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith(users);
|
||||
expect(mockInsert).toHaveBeenCalledWith(transactions);
|
||||
});
|
||||
|
||||
it("should throw if insufficient funds when negative", async () => {
|
||||
mockFindFirst.mockResolvedValue({ id: 1n, balance: 20n });
|
||||
|
||||
expect(economyService.modifyUserBalance("1", -50n, "TEST", "Test sub")).rejects.toThrow("Insufficient funds");
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user