95 Commits

Author SHA1 Message Date
syntaxbullet
fe58380d58 fix: Add null check for regex capture group and non-null assertion for type safety. 2025-12-24 20:59:10 +01:00
syntaxbullet
64cf47ee03 feat: add moderation module with case tracking database schema 2025-12-24 20:55:56 +01:00
syntaxbullet
37ac0ee934 feat: implement message pruning command with dedicated service and UI components. 2025-12-24 20:45:40 +01:00
syntaxbullet
5ab19bf826 fix: improve feedback type parsing from custom IDs and add validation for feedback types in interaction and view logic. 2025-12-24 20:23:52 +01:00
syntaxbullet
42d2313933 feat: Introduce a comprehensive feedback system with a slash command, interactive UI, and configuration. 2025-12-24 20:16:47 +01:00
syntaxbullet
cddd8cdf57 refactor: move terminal message and channel ID persistence from a dedicated file to the main application configuration. 2025-12-24 19:57:00 +01:00
syntaxbullet
eaaf569f4f feat: Implement cumulative XP leveling system with new helper functions and update XP bar to show progress within the current level. 2025-12-24 18:52:40 +01:00
syntaxbullet
8c28fe60fc feat: Enable Components V2, use user mentions, and enhance transaction log display in terminal service. 2025-12-24 18:07:50 +01:00
syntaxbullet
6d725b73db feat: Add MessageFlags.IsComponentsV2 to terminal message updates and remove redundant SectionBuilder wrappers. 2025-12-24 17:54:56 +01:00
syntaxbullet
da048eaad1 fix: Update Discord.js message edit property from containers to components for layout. 2025-12-24 17:47:53 +01:00
syntaxbullet
56da4818dc feat: Refactor terminal message to use new Discord.js UI builders for structured output. 2025-12-24 17:43:56 +01:00
syntaxbullet
ca443491cb refactor: Remove backticks and square brackets from terminal activity timestamp. 2025-12-24 17:26:40 +01:00
syntaxbullet
345e05f821 feat: Introduce dynamic Aurora Observatory terminal with admin command, scheduled updates, and lootdrop event integration. 2025-12-24 17:19:22 +01:00
syntaxbullet
419059904c refactor: relocate interaction routes from events to lib directory 2025-12-24 14:36:14 +01:00
syntaxbullet
7698a3abaa chore: delete test-student-id.png 2025-12-24 14:30:50 +01:00
syntaxbullet
83984faeae feat: Configure app service to restart automatically in Docker Compose and replace file-based restart trigger with process.exit(). 2025-12-24 14:28:23 +01:00
syntaxbullet
2106f06f8f chore: remove student id testing script 2025-12-24 14:23:55 +01:00
syntaxbullet
16d507991c feat: Enhance update requirements check to include database migrations and rename related interfaces and methods. 2025-12-24 14:17:02 +01:00
syntaxbullet
e2aa5ee760 feat: Implement stateful admin update with post-restart context, database migrations, and dedicated view components. 2025-12-24 14:03:15 +01:00
syntaxbullet
e084b6fa4e refactor: Reorganize admin update flow to prepare restart context before update and explicitly trigger restart. 2025-12-24 13:50:37 +01:00
syntaxbullet
3f6da16f89 feat: Add post-update dependency installation, refactor restart logic, and update completion messages. 2025-12-24 13:46:32 +01:00
syntaxbullet
71de87d3da chore: Remove migration generation command from update service. 2025-12-24 13:43:04 +01:00
syntaxbullet
fc7afd7d22 feat: implement a dedicated update service to centralize bot update logic, dependency checks, and post-restart handling. 2025-12-24 13:38:45 +01:00
syntaxbullet
fcc82292f2 feat: Introduce modular inventory item effect handling and centralize Discord interaction routing. 2025-12-24 12:20:42 +01:00
syntaxbullet
f75cc217e9 refactor: extract further UI components into views 2025-12-24 11:44:43 +01:00
syntaxbullet
5c36b9be25 refactor: Extract UI component creation into new view files for lootdrop, trade, item wizard, and enrollment. 2025-12-24 11:36:19 +01:00
syntaxbullet
eaf97572a4 refactor: replace direct EmbedBuilder usage with a new createBaseEmbed helper for consistent embed creation 2025-12-24 11:17:59 +01:00
syntaxbullet
1189483244 refactor: clean up unused imports and dead code across commands, services, and tests. 2025-12-24 11:02:13 +01:00
syntaxbullet
f39ccee0d3 fix: Cast user ID to string for member fetching 2025-12-24 10:07:51 +01:00
syntaxbullet
10282a2570 feat: Add admin command to create color roles and corresponding shop items. 2025-12-23 21:56:45 +01:00
syntaxbullet
a3099b80c5 feat: Add color role item effect with role swapping and implement item consumption toggle. 2025-12-23 21:12:36 +01:00
syntaxbullet
67d6298793 revert(pay): ping in separate message content due to API limitations 2025-12-23 18:50:35 +01:00
syntaxbullet
808fbef11b fix: allow included mentions on payment 2025-12-23 18:44:17 +01:00
syntaxbullet
b833796fb9 fix: make payments public 2025-12-23 18:42:13 +01:00
syntaxbullet
58ea8b92f1 fix: timestamp rendering issues 2025-12-23 18:36:22 +01:00
syntaxbullet
fbd2bd990f fix: correct Discord timestamp formatting in daily command 2025-12-22 14:32:33 +01:00
syntaxbullet
f859618367 feat: introduce weekly bonus for daily rewards, updating calculations, configuration, and command UI. 2025-12-22 13:16:44 +01:00
syntaxbullet
b7b1dd87b8 style: Standardize template literal spacing and remove extraneous markdown code block delimiters. 2025-12-22 13:08:35 +01:00
syntaxbullet
f3b6af019d refactor: remove unused import, markdown fences, and standardize string interpolation formatting. 2025-12-22 13:07:11 +01:00
syntaxbullet
0dea266a6d feat(commands): improve error feedback for economy and admin commands 2025-12-22 12:59:46 +01:00
syntaxbullet
fbcac51370 refactor(modules): propagate UserError in quest, trade, and class services 2025-12-22 12:58:47 +01:00
syntaxbullet
75e586cee8 feat(commands): improve error feedback for use command 2025-12-22 12:56:37 +01:00
syntaxbullet
6e1e6abf2d fix(config): enforce runtime schema validation 2025-12-22 12:56:20 +01:00
syntaxbullet
4a0a2a5878 refactor(inventory): propagate UserError for predictable failures 2025-12-22 12:55:46 +01:00
syntaxbullet
216189b0a4 fix: grammatical errors in daily warning cooldown message 2025-12-21 11:31:21 +01:00
syntaxbullet
ca1339728a chore: change cooldown display to use relative timestamps for better UX with international users. 2025-12-21 11:26:53 +01:00
syntaxbullet
5833224ba9 feat: Implement welcome messages for new enrollments using a new webhook utility and refactor the admin webhook command to utilize it. 2025-12-20 20:59:44 +01:00
syntaxbullet
65f5dc3721 chore: remove 'src' directory from config file path definition. 2025-12-20 20:48:37 +01:00
syntaxbullet
637f0826db feat: conditionally assign student and class roles to new members if returning, otherwise assign visitor role. 2025-12-20 20:12:27 +01:00
syntaxbullet
578987caea chore: update readme 2025-12-20 11:49:50 +01:00
syntaxbullet
064efb0ed2 test: add tests for item wizard 2025-12-20 11:41:53 +01:00
syntaxbullet
4229e5338f refactor: rename bot client, environment variables, and project name from Kyoko to Aurora. 2025-12-20 11:23:39 +01:00
syntaxbullet
1f7679e5a1 test: refactor mocks to use spyOn for better isolation 2025-12-19 13:31:14 +01:00
syntaxbullet
4e228bb7a3 test: add tests for trade service 2025-12-19 12:15:48 +01:00
syntaxbullet
95d5202d7f test: add tests for quest service 2025-12-19 12:15:32 +01:00
syntaxbullet
6c150f753e test: add tests for leveling service 2025-12-19 11:18:35 +01:00
syntaxbullet
c881b305f0 test: add tests for inventory service 2025-12-19 11:08:43 +01:00
syntaxbullet
ae5ef4c802 test: add tests for lootdrop service 2025-12-19 11:05:25 +01:00
syntaxbullet
2b365cb96d test: add tests for economy service 2025-12-19 11:04:00 +01:00
syntaxbullet
bcbbcaa6a4 test: add tests for class service 2025-12-19 11:02:31 +01:00
syntaxbullet
bdb8456f34 feat: add initial unit tests for user service and configure bun test script. 2025-12-19 10:59:06 +01:00
syntaxbullet
acaca46298 chore: add database migrations 2025-12-19 10:53:01 +01:00
syntaxbullet
7b831fa17c feat: log successful visitor role assignment and member's updated roles 2025-12-18 23:23:05 +01:00
syntaxbullet
c128c96aa8 feat: log new guild member joins with their tag and ID 2025-12-18 23:10:24 +01:00
syntaxbullet
d0f53dc37b fix: add guildmembers intent 2025-12-18 22:42:24 +01:00
syntaxbullet
28936a7f7a fix: properly give visitor role to new members 2025-12-18 22:32:45 +01:00
syntaxbullet
4642cf7f6a chore: remove internal class information from enrollment message 2025-12-18 20:35:18 +01:00
syntaxbullet
528a66a7ef feat: Implement user enrollment interaction to assign a random class role and add new role configurations. 2025-12-18 20:09:19 +01:00
syntaxbullet
a97a24f72a chore: updated listing command with autocomplete from items table 2025-12-18 19:41:50 +01:00
syntaxbullet
7bd4d811cd feat: Add script and configuration for remote Drizzle Studio access via SSH tunnel. 2025-12-18 19:34:05 +01:00
syntaxbullet
2ce768013d feat: implement interactive item creation wizard via new /createitem command 2025-12-18 19:16:43 +01:00
syntaxbullet
3c20b23cc1 fix: add missing fields to config schema 2025-12-18 17:39:46 +01:00
syntaxbullet
71fefb3a14 feat: Move database migration execution from update command to post-restart ready event. 2025-12-18 17:29:37 +01:00
syntaxbullet
1d650bb2c7 feat: add zod validation to config 2025-12-18 17:22:11 +01:00
syntaxbullet
7cf8d68d39 feat: persistent lootbox states, update command now runs db migrations 2025-12-18 17:02:21 +01:00
syntaxbullet
83cd33e439 refactor: Optimize item autocomplete by moving name filtering to the database query and increasing the limit. 2025-12-18 16:51:22 +01:00
syntaxbullet
34cbea2753 remove old reload command 2025-12-18 16:37:10 +01:00
syntaxbullet
ce7d4525b2 feat: split reload command into refresh for command reloading and update for git-based bot restarts with update checking and confirmation. 2025-12-18 16:36:23 +01:00
syntaxbullet
4ac8b4759e feat: Add /config admin command to dynamically edit and save bot configuration. 2025-12-18 16:25:54 +01:00
syntaxbullet
56ad5b49cd feat: Introduce lootdrop functionality, enabling activity-based spawning and interactive claiming, alongside new configuration parameters. 2025-12-18 16:09:52 +01:00
syntaxbullet
e8f6a56057 git: modify gitignore 2025-12-18 15:01:50 +01:00
syntaxbullet
a7f66a98b9 chore: Ignore the src/config directory. 2025-12-18 15:00:34 +01:00
syntaxbullet
6d54695325 feat: add new exam economy command with its configuration. 2025-12-18 14:48:40 +01:00
syntaxbullet
8c1f80981b feat: Introduce a dedicated autocomplete handler for commands and refactor the inventory use command to utilize it. 2025-12-18 14:34:47 +01:00
syntaxbullet
3a96b67e89 feat: Allow item effects to specify durations in hours, minutes, or seconds. 2025-12-15 23:26:51 +01:00
syntaxbullet
d3ade218ec feat: add /use command for inventory items with effects, implement XP boosts, and enhance scheduler for temporary role removal. 2025-12-15 23:22:51 +01:00
syntaxbullet
1d4263e178 feat: Introduced an admin listing command and shop interaction module, replacing the sell command, and added a type-checking script. 2025-12-15 22:52:26 +01:00
syntaxbullet
727b63b4dc refactor: trigger application reload by appending to entry file instead of updating its times. 2025-12-15 22:38:32 +01:00
syntaxbullet
d2edde77e6 build: Install git system dependency in Dockerfile. 2025-12-15 22:32:27 +01:00
syntaxbullet
3acb5304f5 feat: Introduce admin webhook and enhanced reload commands with redeploy functionality, implement post-restart notifications, and update Docker container names from Kyoko to Aurora. 2025-12-15 22:29:03 +01:00
syntaxbullet
9333d6ac6c feat: Prevent inventory, profile, balance, and pay commands from targeting bot users. 2025-12-15 22:21:29 +01:00
syntaxbullet
7e986fae5a feat: Implement custom error classes, a Drizzle transaction utility, and update Discord.js ephemeral message flags. 2025-12-15 22:14:17 +01:00
syntaxbullet
3c81fd8396 refactor: rename game.json to config.json and update file path references 2025-12-15 22:02:35 +01:00
syntaxbullet
3984d6112b refactor: rename KyokoClient to BotClient and update all imports. 2025-12-15 22:01:19 +01:00
syntaxbullet
ac6283e60c feat: Introduce dynamic JSON-based configuration for game settings and command toggling via a new admin command. 2025-12-15 21:59:28 +01:00
102 changed files with 8365 additions and 782 deletions

View File

@@ -1,9 +1,12 @@
DB_USER=kyoko DB_USER=aurora
DB_PASSWORD=kyoko DB_PASSWORD=aurora
DB_NAME=kyoko DB_NAME=aurora
DB_PORT=5432 DB_PORT=5432
DB_HOST=db DB_HOST=db
DISCORD_BOT_TOKEN=your-discord-bot-token DISCORD_BOT_TOKEN=your-discord-bot-token
DISCORD_CLIENT_ID=your-discord-client-id DISCORD_CLIENT_ID=your-discord-client-id
DISCORD_GUILD_ID=your-discord-guild-id DISCORD_GUILD_ID=your-discord-guild-id
DATABASE_URL=postgres://kyoko:kyoko@db:5432/kyoko DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
VPS_USER=your-vps-user
VPS_HOST=your-vps-ip

4
.gitignore vendored
View File

@@ -4,8 +4,11 @@ db-logs
db-data db-data
.cursor .cursor
# dependencies (bun install) # dependencies (bun install)
node_modules node_modules
config/
# output # output
out out
dist dist
@@ -40,3 +43,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
src/db/data src/db/data
src/db/log src/db/log
scratchpad/

View File

@@ -1,6 +1,9 @@
FROM oven/bun:latest AS base FROM oven/bun:latest AS base
WORKDIR /app WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y git
# Install dependencies # Install dependencies
COPY package.json bun.lock ./ COPY package.json bun.lock ./
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile

131
README.md
View File

@@ -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 ![Version](https://img.shields.io/badge/version-1.0.0-blue.svg)
![Bun](https://img.shields.io/badge/Bun-1.0+-black)
![Discord.js](https://img.shields.io/badge/Discord.js-14.x-5865F2)
![Drizzle ORM](https://img.shields.io/badge/Drizzle_ORM-0.30+-C5F74F)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-336791)
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. ## ✨ Features
- **`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.
## 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 ```bash
bun install bun install
``` ```
2. **Environment Variables**: 3. **Environment Setup**
Copy `.env.example` to `.env` (create one if it doesn't exist) and fill in the required values: Copy the example environment file and configure it:
```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**:
```bash ```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 ```bash
bun run db:push # Apply schema changes bun run migrate
bun run generate # Generate migrations
``` ```
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
check.sh Executable file
View File

@@ -0,0 +1 @@
tsc --noEmit

View File

@@ -1,7 +1,7 @@
services: services:
db: db:
image: postgres:17-alpine image: postgres:17-alpine
container_name: kyoko_db container_name: aurora_db
environment: environment:
- POSTGRES_USER=${DB_USER} - POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD} - POSTGRES_PASSWORD=${DB_PASSWORD}
@@ -12,8 +12,9 @@ services:
- ./src/db/data:/var/lib/postgresql/data - ./src/db/data:/var/lib/postgresql/data
- ./src/db/log:/var/log/postgresql - ./src/db/log:/var/log/postgresql
app: app:
container_name: kyoko_app container_name: aurora_app
image: kyoko-app restart: unless-stopped
image: aurora-app
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -38,8 +39,8 @@ services:
command: bun run dev command: bun run dev
studio: studio:
container_name: kyoko_studio container_name: aurora_studio
image: kyoko-app image: aurora-app
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile

View 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;

View 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")
);

View 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": {}
}
}

View 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": {}
}
}

View File

@@ -0,0 +1,20 @@
{
"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
}
]
}

View File

@@ -15,8 +15,11 @@
"generate": "docker compose run --rm app drizzle-kit generate", "generate": "docker compose run --rm app drizzle-kit generate",
"migrate": "docker compose run --rm app drizzle-kit migrate", "migrate": "docker compose run --rm app drizzle-kit migrate",
"db:push": "docker compose run --rm app drizzle-kit push", "db:push": "docker compose run --rm app drizzle-kit push",
"db:push:local": "drizzle-kit push",
"dev": "bun --watch src/index.ts", "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": { "dependencies": {
"@napi-rs/canvas": "^0.1.84", "@napi-rs/canvas": "^0.1.84",

25
scripts/remote-studio.sh Executable file
View 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

View File

@@ -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();

View 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
}
}
}
});

View 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}`)] });
}
}
});

View 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 });
}
});

View 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!` });
}
}
});

View 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
}))
);
}
});

179
src/commands/admin/prune.ts Normal file
View 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)]
});
}
}
});

View 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")] });
}
}
});

View 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." });
}
}
}
});

View 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)] });
}
}
});

View File

@@ -1,6 +1,7 @@
import { createCommand } from "@/lib/utils"; 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 { createErrorEmbed } from "@/lib/embeds";
import { sendWebhookMessage } from "@/lib/webhookUtils";
export const webhook = createCommand({ export const webhook = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -13,7 +14,7 @@ export const webhook = createCommand({
.setRequired(true) .setRequired(true)
), ),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ ephemeral: true }); await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const payloadString = interaction.options.getString("payload", true); const payloadString = interaction.options.getString("payload", true);
let payload; let payload;
@@ -36,37 +37,17 @@ export const webhook = createCommand({
return; return;
} }
let webhook;
try { try {
webhook = await channel.createWebhook({ await sendWebhookMessage(
name: `${interaction.client.user.username} - Proxy`, channel,
avatar: interaction.client.user.displayAvatarURL(), payload,
reason: `Proxy message requested by ${interaction.user.tag}` interaction.client.user,
}); `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 interaction.editReply({ content: "Message sent successfully!" }); await interaction.editReply({ content: "Message sent successfully!" });
} catch (error) { } catch (error) {
console.error("Webhook error:", 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({ 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")] embeds: [createErrorEmbed("Failed to send message via webhook. Ensure the bot has 'Manage Webhooks' permission and the payload is valid.", "Delivery Failed")]
}); });

View File

@@ -1,6 +1,7 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, EmbedBuilder } from "discord.js"; import { SlashCommandBuilder } from "discord.js";
import { userService } from "@/modules/user/user.service"; import { userService } from "@/modules/user/user.service";
import { createBaseEmbed } from "@lib/embeds";
export const balance = createCommand({ export const balance = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -15,12 +16,15 @@ export const balance = createCommand({
await interaction.deferReply(); await interaction.deferReply();
const targetUser = interaction.options.getUser("user") || interaction.user; const targetUser = interaction.options.getUser("user") || interaction.user;
if (targetUser.bot) {
return;
}
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username); const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
const embed = new EmbedBuilder() const embed = createBaseEmbed(undefined, `**Balance**: ${user.balance || 0n} AU`, "Yellow")
.setAuthor({ name: targetUser.username, iconURL: targetUser.displayAvatarURL() }) .setAuthor({ name: targetUser.username, iconURL: targetUser.displayAvatarURL() });
.setDescription(`**Balance**: ${user.balance || 0n} AU`)
.setColor("Yellow");
await interaction.editReply({ embeds: [embed] }); await interaction.editReply({ embeds: [embed] });
} }

View File

@@ -1,7 +1,9 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, EmbedBuilder } from "discord.js"; import { SlashCommandBuilder } from "discord.js";
import { economyService } from "@/modules/economy/economy.service"; 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({ export const daily = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -11,26 +13,23 @@ export const daily = createCommand({
try { try {
const result = await economyService.claimDaily(interaction.user.id); const result = await economyService.claimDaily(interaction.user.id);
const embed = new EmbedBuilder() const embed = createSuccessEmbed(`You claimed ** ${result.amount}** Astral Units!${result.isWeekly ? `\n🎉 **Weekly Bonus!** +${result.weeklyBonus} extra!` : ''}`, "💰 Daily Reward Claimed!")
.setTitle("💰 Daily Reward Claimed!")
.setDescription(`You claimed **${result.amount}** Astral Units!`)
.addFields( .addFields(
{ name: "Streak", value: `🔥 ${result.streak} days`, inline: true }, { 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") .setColor("Gold");
.setTimestamp();
await interaction.reply({ embeds: [embed] }); await interaction.reply({ embeds: [embed] });
} catch (error: any) { } catch (error: any) {
if (error.message.includes("Daily already claimed")) { if (error instanceof UserError) {
await interaction.reply({ embeds: [createWarningEmbed(error.message, "Cooldown")], ephemeral: true }); await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
return; } 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 });
} }
} }
}); });

View File

@@ -0,0 +1,195 @@
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";
const EXAM_TIMER_TYPE = '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);
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);
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
const metadata: ExamMetadata = {
examDay: currentDay,
lastXp: user.xp.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}:F> (<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
if (now < new Date(timer.expiresAt)) {
// Calculate time remaining
const expiresAt = new Date(timer.expiresAt);
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}:F> (<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);
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
const newMetadata: ExamMetadata = {
examDay: examDay,
lastXp: user.xp.toString() // Reset tracking
};
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}:F> (<t:${nextExamTimestamp}:R>)`,
"Exam Failed"
)]
});
return;
}
// 5. Reward Calculation
const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case
const currentXp = user.xp;
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);
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}:F>`,
"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 });
}
}
}
});

View File

@@ -1,9 +1,11 @@
import { createCommand } from "@/lib/utils"; 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 { economyService } from "@/modules/economy/economy.service";
import { userService } from "@/modules/user/user.service"; import { userService } from "@/modules/user/user.service";
import { GameConfig } from "@/config/game"; import { config } from "@/lib/config";
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds"; import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@/lib/errors";
export const pay = createCommand({ export const pay = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -22,38 +24,41 @@ export const pay = createCommand({
), ),
execute: async (interaction) => { execute: async (interaction) => {
const targetUser = await userService.getOrCreateUser(interaction.options.getUser("user", true).id, interaction.options.getUser("user", true).username); const targetUser = await userService.getOrCreateUser(interaction.options.getUser("user", true).id, interaction.options.getUser("user", true).username);
const discordUser = interaction.options.getUser("user", true);
if (discordUser.bot) {
await interaction.reply({ embeds: [createErrorEmbed("You cannot send money to bots.")], flags: MessageFlags.Ephemeral });
return;
}
const amount = BigInt(interaction.options.getInteger("amount", true)); const amount = BigInt(interaction.options.getInteger("amount", true));
const senderId = interaction.user.id; const senderId = interaction.user.id;
const receiverId = targetUser.id; const receiverId = targetUser.id;
if (amount < GameConfig.economy.transfers.minAmount) { if (amount < config.economy.transfers.minAmount) {
await interaction.reply({ embeds: [createWarningEmbed(`Amount must be at least ${GameConfig.economy.transfers.minAmount}.`)], ephemeral: true }); await interaction.reply({ embeds: [createErrorEmbed(`Amount must be at least ${config.economy.transfers.minAmount}.`)], flags: MessageFlags.Ephemeral });
return; return;
} }
if (senderId === receiverId) { if (senderId === receiverId) {
await interaction.reply({ embeds: [createWarningEmbed("You cannot pay yourself.")], ephemeral: true }); await interaction.reply({ embeds: [createErrorEmbed("You cannot pay yourself.")], flags: MessageFlags.Ephemeral });
return; return;
} }
try { try {
await interaction.deferReply();
await economyService.transfer(senderId, receiverId, amount); await economyService.transfer(senderId, receiverId, amount);
const embed = new EmbedBuilder() const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
.setTitle("💸 Transfer Successful") await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
.setDescription(`Successfully sent **${amount}** Astral Units to <@${targetUser.id}>.`)
.setColor("Green")
.setTimestamp();
await interaction.reply({ embeds: [embed] });
} catch (error: any) { } catch (error: any) {
if (error.message.includes("Insufficient funds")) { if (error instanceof UserError) {
await interaction.reply({ embeds: [createWarningEmbed("Insufficient funds.")], ephemeral: true }); await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
return; } 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 });
} }
} }
}); });

View File

@@ -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 });
}
}
}

View File

@@ -1,6 +1,7 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, EmbedBuilder, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, ThreadAutoArchiveDuration } from "discord.js"; import { SlashCommandBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags } from "discord.js";
import { TradeService } from "@/modules/trade/trade.service"; import { TradeService } from "@/modules/trade/trade.service";
import { getTradeDashboard } from "@/modules/trade/trade.view";
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds"; import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
export const trade = createCommand({ export const trade = createCommand({
@@ -16,19 +17,19 @@ export const trade = createCommand({
const targetUser = interaction.options.getUser("user", true); const targetUser = interaction.options.getUser("user", true);
if (targetUser.id === interaction.user.id) { 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; return;
} }
if (targetUser.bot) { 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; return;
} }
// Create Thread // Create Thread
const channel = interaction.channel; const channel = interaction.channel;
if (!channel || channel.type === ChannelType.DM) { 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; return;
} }
@@ -53,37 +54,20 @@ export const trade = createCommand({
} catch (err) { } catch (err) {
console.error("Failed to delete setup message", 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; return;
} }
// Setup Session // Setup Session
TradeService.createSession(thread.id, const session = TradeService.createSession(thread.id,
{ id: interaction.user.id, username: interaction.user.username }, { id: interaction.user.id, username: interaction.user.username },
{ id: targetUser.id, username: targetUser.username } { id: targetUser.id, username: targetUser.username }
); );
// Send Dashboard to Thread // Send Dashboard to Thread
const embed = new EmbedBuilder() const dashboard = getTradeDashboard(session);
.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 row = new ActionRowBuilder<ButtonBuilder>() await thread.send({ content: `${interaction.user} ${targetUser} Welcome to your trading session!`, ...dashboard });
.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] });
// Update original reply // Update original reply
await interaction.editReply({ content: `✅ Trade opened: <#${thread.id}>` }); await interaction.editReply({ content: `✅ Trade opened: <#${thread.id}>` });

View 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
});
}
});

View File

@@ -1,8 +1,8 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, EmbedBuilder } from "discord.js"; import { SlashCommandBuilder } from "discord.js";
import { inventoryService } from "@/modules/inventory/inventory.service"; import { inventoryService } from "@/modules/inventory/inventory.service";
import { userService } from "@/modules/user/user.service"; import { userService } from "@/modules/user/user.service";
import { createWarningEmbed } from "@lib/embeds"; import { createWarningEmbed, createBaseEmbed } from "@lib/embeds";
export const inventory = createCommand({ export const inventory = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -17,6 +17,12 @@ export const inventory = createCommand({
await interaction.deferReply(); await interaction.deferReply();
const targetUser = interaction.options.getUser("user") || interaction.user; 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 user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
const items = await inventoryService.getInventory(user.id); const items = await inventoryService.getInventory(user.id);
@@ -29,11 +35,7 @@ export const inventory = createCommand({
return `**${entry.item.name}** x${entry.quantity}`; return `**${entry.item.name}** x${entry.quantity}`;
}).join("\n"); }).join("\n");
const embed = new EmbedBuilder() const embed = createBaseEmbed(`${user.username}'s Inventory`, description, "Blue");
.setTitle(`${user.username}'s Inventory`)
.setDescription(description)
.setColor("Blue")
.setTimestamp();
await interaction.editReply({ embeds: [embed] }); await interaction.editReply({ embeds: [embed] });
} }

View File

@@ -0,0 +1,101 @@
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, createSuccessEmbed } from "@lib/embeds";
import { inventory, items } from "@/db/schema";
import { eq, and, like } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient";
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);
try {
const result = await inventoryService.useItem(user.id, 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 = createSuccessEmbed(
result.results.map(r => `${r}`).join("\n"),
`Used ${result.usageData.effects.length > 0 ? 'Item' : 'Item'}` // Generic title, improves below
);
embed.setTitle("Item Used!");
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;
// Fetch owned items that match the search query
// We join with items table to filter by name directly in the database
const entries = await DrizzleClient.select({
quantity: inventory.quantity,
item: items
})
.from(inventory)
.innerJoin(items, eq(inventory.itemId, items.id))
.where(and(
eq(inventory.userId, BigInt(userId)),
like(items.name, `%${focusedValue}%`)
))
.limit(20); // Fetch up to 20 matching items
const filtered = entries.filter(entry => {
const usageData = entry.item.usageData as ItemUsageData | null;
const isUsable = usageData && usageData.effects && usageData.effects.length > 0;
return isUsable;
});
await interaction.respond(
filtered.map(entry => ({ name: `${entry.item.name} (${entry.quantity})`, value: entry.item.id }))
);
}
});

View File

@@ -1,9 +1,9 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, EmbedBuilder } from "discord.js"; import { SlashCommandBuilder } from "discord.js";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { DrizzleClient } from "@/lib/DrizzleClient";
import { users } from "@/db/schema"; import { users } from "@/db/schema";
import { desc } from "drizzle-orm"; import { desc } from "drizzle-orm";
import { createWarningEmbed } from "@lib/embeds"; import { createWarningEmbed, createBaseEmbed } from "@lib/embeds";
export const leaderboard = createCommand({ export const leaderboard = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -40,11 +40,7 @@ export const leaderboard = createCommand({
return `${medal} **${user.username}** — ${value}`; return `${medal} **${user.username}** — ${value}`;
}).join("\n"); }).join("\n");
const embed = new EmbedBuilder() const embed = createBaseEmbed(isXp ? "🏆 XP Leaderboard" : "💰 Richest Players", description, "Gold");
.setTitle(isXp ? "🏆 XP Leaderboard" : "💰 Richest Players")
.setDescription(description)
.setColor("Gold")
.setTimestamp();
await interaction.editReply({ embeds: [embed] }); await interaction.editReply({ embeds: [embed] });
} }

View File

@@ -1,14 +1,14 @@
import { createCommand } from "@/lib/utils"; 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 { questService } from "@/modules/quest/quest.service";
import { createWarningEmbed } from "@lib/embeds"; import { createWarningEmbed, createBaseEmbed } from "@lib/embeds";
export const quests = createCommand({ export const quests = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("quests") .setName("quests")
.setDescription("View your active quests"), .setDescription("View your active quests"),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ ephemeral: true }); await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const userQuests = await questService.getUserQuests(interaction.user.id); const userQuests = await questService.getUserQuests(interaction.user.id);
@@ -17,10 +17,7 @@ export const quests = createCommand({
return; return;
} }
const embed = new EmbedBuilder() const embed = createBaseEmbed("📜 Quest Log", undefined, "Blue");
.setTitle("📜 Quest Log")
.setColor("Blue")
.setTimestamp();
userQuests.forEach(entry => { userQuests.forEach(entry => {
const status = entry.completedAt ? "✅ Completed" : "In Progress"; const status = entry.completedAt ? "✅ Completed" : "In Progress";

View File

@@ -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")] });
}
}
});

View File

@@ -2,6 +2,7 @@ import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, AttachmentBuilder } from "discord.js"; import { SlashCommandBuilder, AttachmentBuilder } from "discord.js";
import { userService } from "@/modules/user/user.service"; import { userService } from "@/modules/user/user.service";
import { generateStudentIdCard } from "@/graphics/studentID"; import { generateStudentIdCard } from "@/graphics/studentID";
import { createWarningEmbed } from "@/lib/embeds";
export const profile = createCommand({ export const profile = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -16,6 +17,12 @@ export const profile = createCommand({
await interaction.deferReply(); await interaction.deferReply();
const targetUser = interaction.options.getUser("user") || interaction.user; 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 user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
const cardBuffer = await generateStudentIdCard({ const cardBuffer = await generateStudentIdCard({

View File

@@ -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;

View File

@@ -21,6 +21,7 @@ export const classes = pgTable('classes', {
id: bigint('id', { mode: 'bigint' }).primaryKey(), id: bigint('id', { mode: 'bigint' }).primaryKey(),
name: varchar('name', { length: 255 }).unique().notNull(), name: varchar('name', { length: 255 }).unique().notNull(),
balance: bigint('balance', { mode: 'bigint' }).default(0n), balance: bigint('balance', { mode: 'bigint' }).default(0n),
roleId: varchar('role_id', { length: 255 }),
}); });
// 2. Users // 2. Users
@@ -130,8 +131,36 @@ export const userTimers = pgTable('user_timers', {
}, (table) => [ }, (table) => [
primaryKey({ columns: [table.userId, table.type, table.key] }) primaryKey({ columns: [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'),
});
// --- RELATIONS ---
export const classesRelations = relations(classes, ({ many }) => ({ export const classesRelations = relations(classes, ({ many }) => ({
users: many(users), users: many(users),
@@ -206,3 +235,18 @@ export const itemTransactionsRelations = relations(itemTransactions, ({ one }) =
references: [items.id], 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],
}),
}));

View File

@@ -1,12 +1,34 @@
import { Events } from "discord.js"; import { Events } from "discord.js";
import type { Event } from "@lib/types"; 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> = { const event: Event<Events.GuildMemberAdd> = {
name: Events.GuildMemberAdd, name: Events.GuildMemberAdd,
execute: async (member) => { execute: async (member) => {
const role = member.guild.roles.cache.find(role => role.name === "Visitor"); console.log(`👤 New member joined: ${member.user.tag} (${member.id})`);
if (!role) return; try {
await member.roles.add(role); 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);
}
}, },
}; };

View File

@@ -1,5 +1,5 @@
import { Events, MessageFlags } from "discord.js"; import { Events, MessageFlags } from "discord.js";
import { KyokoClient } from "@lib/KyokoClient"; import { AuroraClient } from "@/lib/BotClient";
import { userService } from "@/modules/user/user.service"; import { userService } from "@/modules/user/user.service";
import { createErrorEmbed } from "@lib/embeds"; import { createErrorEmbed } from "@lib/embeds";
import type { Event } from "@lib/types"; import type { Event } from "@lib/types";
@@ -9,15 +9,36 @@ const event: Event<Events.InteractionCreate> = {
execute: async (interaction) => { execute: async (interaction) => {
// Handle Trade Interactions // Handle Trade Interactions
if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) { if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
if (interaction.customId.startsWith("trade_") || interaction.customId === "amount") { const { interactionRoutes } = await import("@lib/interaction.routes");
await import("@/modules/trade/trade.interaction").then(m => m.handleTradeInteraction(interaction));
for (const route of interactionRoutes) {
if (route.predicate(interaction)) {
const module = await route.handler();
const handlerMethod = module[route.method];
if (typeof handlerMethod === 'function') {
await handlerMethod(interaction);
return; return;
} else {
console.error(`Handler method ${route.method} not found in module`);
} }
} }
}
}
if (interaction.isAutocomplete()) {
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);
}
return;
}
if (!interaction.isChatInputCommand()) return; if (!interaction.isChatInputCommand()) return;
const command = KyokoClient.commands.get(interaction.commandName); const command = AuroraClient.commands.get(interaction.commandName);
if (!command) { if (!command) {
console.error(`No command matching ${interaction.commandName} was found.`); console.error(`No command matching ${interaction.commandName} was found.`);

View File

@@ -13,6 +13,9 @@ const event: Event<Events.MessageCreate> = {
if (!user) return; if (!user) return;
levelingService.processChatXp(message.author.id); levelingService.processChatXp(message.author.id);
// Activity Tracking for Lootdrops
import("@/modules/economy/lootdrop.service").then(m => m.lootdropService.processMessage(message));
}, },
}; };

View File

@@ -8,6 +8,10 @@ const event: Event<Events.ClientReady> = {
execute: async (c) => { execute: async (c) => {
console.log(`Ready! Logged in as ${c.user.tag}`); console.log(`Ready! Logged in as ${c.user.tag}`);
schedulerService.start(); schedulerService.start();
// Handle post-update tasks
const { UpdateService } = await import("@/modules/admin/update.service");
await UpdateService.handlePostRestart(c);
}, },
}; };

View File

@@ -97,9 +97,12 @@ export async function generateStudentIdCard(data: StudentCardData): Promise<Buff
ctx.restore(); ctx.restore();
// Draw XP Bar // 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 xpBarMaxWidth = 382;
const xpBarWidth = xpBarMaxWidth * Number(data.xp) / Number(xpForNextLevel); const xpBarWidth = Math.max(0, Math.min(xpBarMaxWidth, xpBarMaxWidth * currentLevelProgress / xpForThisLevel));
const xpBarHeight = 3; const xpBarHeight = 3;
ctx.save(); ctx.save();
ctx.fillStyle = '#B3AD93'; ctx.fillStyle = '#B3AD93';

View File

@@ -1,14 +1,14 @@
import { KyokoClient } from "@lib/KyokoClient"; import { AuroraClient } from "@/lib/BotClient";
import { env } from "@lib/env"; import { env } from "@lib/env";
// Load commands & events // Load commands & events
await KyokoClient.loadCommands(); await AuroraClient.loadCommands();
await KyokoClient.loadEvents(); await AuroraClient.loadEvents();
await KyokoClient.deployCommands(); await AuroraClient.deployCommands();
// login with the token from .env // login with the token from .env
if (!env.DISCORD_BOT_TOKEN) { if (!env.DISCORD_BOT_TOKEN) {
throw new Error("❌ DISCORD_BOT_TOKEN is not set in environment variables."); throw new Error("❌ DISCORD_BOT_TOKEN is not set in environment variables.");
} }
KyokoClient.login(env.DISCORD_BOT_TOKEN); AuroraClient.login(env.DISCORD_BOT_TOKEN);

View File

@@ -3,6 +3,7 @@ import { readdir } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import type { Command, Event } from "@lib/types"; import type { Command, Event } from "@lib/types";
import { env } from "@lib/env"; import { env } from "@lib/env";
import { config } from "@lib/config";
class Client extends DiscordClient { class Client extends DiscordClient {
@@ -56,8 +57,23 @@ class Client extends DiscordClient {
continue; continue;
} }
// Extract category from parent directory name
// filePath is like /path/to/commands/admin/features.ts
// we want "admin"
const pathParts = filePath.split('/');
const category = pathParts[pathParts.length - 2];
for (const command of commands) { for (const command of commands) {
if (this.isValidCommand(command)) { if (this.isValidCommand(command)) {
command.category = category; // Inject category
const isEnabled = config.commands[command.data.name] !== false; // Default true if undefined
if (!isEnabled) {
console.log(`🚫 Skipping disabled command: ${command.data.name}`);
continue;
}
this.commands.set(command.data.name, command); this.commands.set(command.data.name, command);
console.log(`✅ Loaded command: ${command.data.name}`); console.log(`✅ Loaded command: ${command.data.name}`);
} else { } else {
@@ -169,4 +185,4 @@ class Client extends DiscordClient {
} }
} }
export const KyokoClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages] }); export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers] });

202
src/lib/config.ts Normal file
View File

@@ -0,0 +1,202 @@
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;
};
};
}
// 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
}
})
});
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
View 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));
}
};

15
src/lib/db.ts Normal file
View File

@@ -0,0 +1,15 @@
import { DrizzleClient } from "./DrizzleClient";
import type { Transaction } from "./types";
export const withTransaction = async <T>(
callback: (tx: Transaction) => Promise<T>,
tx?: Transaction
): Promise<T> => {
if (tx) {
return await callback(tx);
} else {
return await DrizzleClient.transaction(async (newTx) => {
return await callback(newTx);
});
}
};

View File

@@ -1,4 +1,4 @@
import { EmbedBuilder, Colors } from "discord.js"; import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js";
/** /**
* Creates a standardized error embed. * Creates a standardized error embed.
@@ -55,3 +55,21 @@ export function createInfoEmbed(message: string, title: string = "Info"): EmbedB
.setColor(Colors.Blue) .setColor(Colors.Blue)
.setTimestamp(); .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;
}

18
src/lib/errors.ts Normal file
View 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);
}
}

View File

@@ -0,0 +1,42 @@
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
type InteractionHandler = (interaction: any) => Promise<void>;
interface InteractionRoute {
predicate: (interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction) => boolean;
handler: () => Promise<any>;
method: string;
}
export const interactionRoutes: InteractionRoute[] = [
{
predicate: (i) => i.customId.startsWith("trade_") || i.customId === "amount",
handler: () => import("@/modules/trade/trade.interaction"),
method: 'handleTradeInteraction'
},
{
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'
},
{
predicate: (i) => i.customId.startsWith("createitem_"),
handler: () => import("@/modules/admin/item_wizard"),
method: 'handleItemWizardInteraction'
},
{
predicate: (i) => i.isButton() && i.customId === "enrollment",
handler: () => import("@/modules/user/enrollment.interaction"),
method: 'handleEnrollmentInteraction'
},
{
predicate: (i) => i.customId.startsWith("feedback_"),
handler: () => import("@/modules/feedback/feedback.interaction"),
method: 'handleFeedbackInteraction'
}
];

View File

@@ -1,8 +1,10 @@
import type { ChatInputCommandInteraction, ClientEvents, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder } from "discord.js"; import type { AutocompleteInteraction, ChatInputCommandInteraction, ClientEvents, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder } from "discord.js";
export interface Command { export interface Command {
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder; data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder;
execute: (interaction: ChatInputCommandInteraction) => Promise<void> | void; execute: (interaction: ChatInputCommandInteraction) => Promise<void> | void;
autocomplete?: (interaction: AutocompleteInteraction) => Promise<void> | void;
category?: string;
} }
export interface Event<K extends keyof ClientEvents> { export interface Event<K extends keyof ClientEvents> {
@@ -10,3 +12,21 @@ export interface Event<K extends keyof ClientEvents> {
once?: boolean; once?: boolean;
execute: (...args: ClientEvents[K]) => Promise<void> | void; execute: (...args: ClientEvents[K]) => Promise<void> | void;
} }
export type ItemEffect =
| { type: 'ADD_XP'; amount: number }
| { type: 'ADD_BALANCE'; amount: number }
| { type: 'XP_BOOST'; multiplier: number; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
| { type: 'TEMP_ROLE'; roleId: string; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
| { type: 'REPLY_MESSAGE'; message: string }
| { type: 'COLOR_ROLE'; roleId: string };
export interface ItemUsageData {
consume: boolean;
effects: ItemEffect[];
}
import { DrizzleClient } from "./DrizzleClient";
export type DbClient = typeof DrizzleClient;
export type Transaction = Parameters<Parameters<DbClient['transaction']>[0]>[0];

56
src/lib/webhookUtils.ts Normal file
View 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;
}
}

View 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");
});
});

View File

@@ -0,0 +1,242 @@
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";
// --- 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: "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 === "ADD_XP" || type === "ADD_BALANCE") {
const amount = parseInt(interaction.fields.getTextInputValue("amount"));
if (!isNaN(amount)) effect = { type: type as any, amount };
}
else if (type === "REPLY_MESSAGE") {
effect = { type: "REPLY_MESSAGE", message: interaction.fields.getTextInputValue("message") };
}
else if (type === "XP_BOOST") {
const multiplier = parseFloat(interaction.fields.getTextInputValue("multiplier"));
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: "XP_BOOST", multiplier, durationSeconds: duration };
}
else if (type === "TEMP_ROLE") {
const roleId = interaction.fields.getTextInputValue("role_id");
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
if (roleId && !isNaN(duration)) effect = { type: "TEMP_ROLE", roleId: roleId, durationSeconds: duration };
}
else if (type === "COLOR_ROLE") {
const roleId = interaction.fields.getTextInputValue("role_id");
if (roleId) effect = { type: "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 });
}
}
};

View 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;
}

View File

@@ -0,0 +1,134 @@
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";
const getItemTypeOptions = () => [
{ label: "Material", value: "MATERIAL", description: "Used for crafting or trading" },
{ label: "Consumable", value: "CONSUMABLE", description: "Can be used to gain effects" },
{ label: "Equipment", value: "EQUIPMENT", description: "Can be equipped (Not yet implemented)" },
{ label: "Quest Item", value: "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;
};

View 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();
});
});
});

View 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
}
}
}

View 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");
}

View 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();
});
});
});

View File

@@ -1,7 +1,7 @@
import { classes, users } from "@/db/schema"; import { classes, users } from "@/db/schema";
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { DrizzleClient } from "@/lib/DrizzleClient";
import { UserError } from "@/lib/errors";
export const classService = { export const classService = {
getAllClasses: async () => { getAllClasses: async () => {
@@ -14,7 +14,7 @@ export const classService = {
where: eq(classes.id, classId), 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) const [user] = await txFn.update(users)
.set({ classId: classId }) .set({ classId: classId })
@@ -37,15 +37,15 @@ export const classService = {
where: eq(classes.id, classId), 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) { if ((cls.balance ?? 0n) < amount) {
throw new Error("Insufficient class funds"); throw new UserError("Insufficient class funds");
} }
const [updatedClass] = await txFn.update(classes) const [updatedClass] = await txFn.update(classes)
.set({ .set({
balance: sql`${classes.balance} + ${amount}`, balance: sql`${classes.balance} + ${amount} `,
}) })
.where(eq(classes.id, classId)) .where(eq(classes.id, classId))
.returning(); .returning();
@@ -64,5 +64,22 @@ export const classService = {
return updatedClass; return updatedClass;
}; };
return tx ? await execute(tx) : await DrizzleClient.transaction(execute); return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
},
createClass: async (data: typeof classes.$inferInsert, tx?: any) => {
const execute = async (txFn: any) => {
const [newClass] = await txFn.insert(classes)
.values(data)
.returning();
return newClass;
};
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
},
deleteClass: async (id: bigint, tx?: any) => {
const execute = async (txFn: any) => {
await txFn.delete(classes).where(eq(classes.id, id));
};
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
} }
}; };

View File

@@ -0,0 +1,217 @@
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 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 = (5+1) - floor(48h / 24h) = 6 - 2 = 4.
expect(result.streak).toBe(4);
});
});
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");
});
});
});

View File

@@ -1,30 +1,32 @@
import { users, transactions, userTimers } from "@/db/schema"; import { users, transactions, userTimers } from "@/db/schema";
import { eq, sql, and } from "drizzle-orm"; import { eq, sql, and } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { config } from "@/lib/config";
import { GameConfig } from "@/config/game"; import { withTransaction } from "@/lib/db";
import type { Transaction } from "@/lib/types";
import { UserError } from "@/lib/errors";
export const economyService = { export const economyService = {
transfer: async (fromUserId: string, toUserId: string, amount: bigint, tx?: any) => { transfer: async (fromUserId: string, toUserId: string, amount: bigint, tx?: Transaction) => {
if (amount <= 0n) { if (amount <= 0n) {
throw new Error("Amount must be positive"); throw new UserError("Amount must be positive");
} }
if (fromUserId === toUserId) { if (fromUserId === toUserId) {
throw new Error("Cannot transfer to self"); throw new UserError("Cannot transfer to self");
} }
const execute = async (txFn: any) => { return await withTransaction(async (txFn) => {
// Check sender balance // Check sender balance
const sender = await txFn.query.users.findFirst({ const sender = await txFn.query.users.findFirst({
where: eq(users.id, BigInt(fromUserId)), where: eq(users.id, BigInt(fromUserId)),
}); });
if (!sender) { if (!sender) {
throw new Error("Sender not found"); throw new UserError("Sender not found");
} }
if ((sender.balance ?? 0n) < amount) { if ((sender.balance ?? 0n) < amount) {
throw new Error("Insufficient funds"); throw new UserError("Insufficient funds");
} }
// Deduct from sender // Deduct from sender
@@ -59,19 +61,11 @@ export const economyService = {
}); });
return { success: true, amount }; return { success: true, amount };
}; }, tx);
if (tx) {
return await execute(tx);
} else {
return await DrizzleClient.transaction(async (t) => {
return await execute(t);
});
}
}, },
claimDaily: async (userId: string, tx?: any) => { claimDaily: async (userId: string, tx?: Transaction) => {
const execute = async (txFn: any) => { return await withTransaction(async (txFn) => {
const now = new Date(); const now = new Date();
const startOfDay = new Date(now); const startOfDay = new Date(now);
startOfDay.setHours(0, 0, 0, 0); startOfDay.setHours(0, 0, 0, 0);
@@ -86,7 +80,7 @@ export const economyService = {
}); });
if (cooldown && cooldown.expiresAt > now) { if (cooldown && cooldown.expiresAt > now) {
throw new Error(`Daily already claimed. Ready at ${cooldown.expiresAt}`); throw new UserError(`Daily already claimed. Ready <t:${Math.floor(cooldown.expiresAt.getTime() / 1000)}:R>`);
} }
// Get user for streak logic // Get user for streak logic
@@ -95,7 +89,7 @@ export const economyService = {
}); });
if (!user) { if (!user) {
throw new Error("User not found"); throw new Error("User not found"); // This might be system error because user should exist if authenticated, but keeping simple for now
} }
let streak = (user.dailyStreak || 0) + 1; let streak = (user.dailyStreak || 0) + 1;
@@ -110,9 +104,13 @@ export const economyService = {
streak = 1; streak = 1;
} }
const bonus = (BigInt(streak) - 1n) * GameConfig.economy.daily.streakBonus; const bonus = (BigInt(streak) - 1n) * config.economy.daily.streakBonus;
const totalReward = GameConfig.economy.daily.amount + bonus; // Weekly bonus check
const isWeeklyCurrent = streak > 0 && streak % 7 === 0;
const weeklyBonusAmount = isWeeklyCurrent ? config.economy.daily.weeklyBonus : 0n;
const totalReward = config.economy.daily.amount + bonus + weeklyBonusAmount;
await txFn.update(users) await txFn.update(users)
.set({ .set({
balance: sql`${users.balance} + ${totalReward}`, balance: sql`${users.balance} + ${totalReward}`,
@@ -122,7 +120,7 @@ export const economyService = {
.where(eq(users.id, BigInt(userId))); .where(eq(users.id, BigInt(userId)));
// Set new cooldown (now + 24h) // Set new cooldown (now + 24h)
const nextReadyAt = new Date(now.getTime() + GameConfig.economy.daily.cooldownMs); const nextReadyAt = new Date(now.getTime() + config.economy.daily.cooldownMs);
await txFn.insert(userTimers) await txFn.insert(userTimers)
.values({ .values({
@@ -144,27 +142,19 @@ export const economyService = {
description: `Daily reward (Streak: ${streak})`, description: `Daily reward (Streak: ${streak})`,
}); });
return { claimed: true, amount: totalReward, streak, nextReadyAt }; return { claimed: true, amount: totalReward, streak, nextReadyAt, isWeekly: isWeeklyCurrent, weeklyBonus: weeklyBonusAmount };
}; }, tx);
if (tx) {
return await execute(tx);
} else {
return await DrizzleClient.transaction(async (t) => {
return await execute(t);
});
}
}, },
modifyUserBalance: async (id: string, amount: bigint, type: string, description: string, relatedUserId?: string | null, tx?: any) => { modifyUserBalance: async (id: string, amount: bigint, type: string, description: string, relatedUserId?: string | null, tx?: Transaction) => {
const execute = async (txFn: any) => { return await withTransaction(async (txFn) => {
if (amount < 0n) { if (amount < 0n) {
// Check sufficient funds if removing // Check sufficient funds if removing
const user = await txFn.query.users.findFirst({ const user = await txFn.query.users.findFirst({
where: eq(users.id, BigInt(id)) where: eq(users.id, BigInt(id))
}); });
if (!user || (user.balance ?? 0n) < -amount) { if (!user || (user.balance ?? 0n) < -amount) {
throw new Error("Insufficient funds"); throw new UserError("Insufficient funds");
} }
} }
@@ -184,14 +174,6 @@ export const economyService = {
}); });
return user; return user;
}; }, tx);
if (tx) {
return await execute(tx);
} else {
return await DrizzleClient.transaction(async (t) => {
return await execute(t);
});
}
}, },
}; };

View File

@@ -0,0 +1,36 @@
import { ButtonInteraction } from "discord.js";
import { lootdropService } from "./lootdrop.service";
import { createErrorEmbed } from "@/lib/embeds";
import { getLootdropClaimedMessage } from "./lootdrop.view";
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
if (interaction.customId === "lootdrop_claim") {
await interaction.deferReply({ ephemeral: true });
const result = await lootdropService.tryClaim(interaction.message.id, interaction.user.id, interaction.user.username);
if (result.success) {
await interaction.editReply({
content: `🎉 You successfully claimed **${result.amount} ${result.currency}**!`
});
// Update original message to show claimed state
const originalEmbed = interaction.message.embeds[0];
if (!originalEmbed) return;
const { embeds, components } = getLootdropClaimedMessage(
originalEmbed.title || "💰 LOOTDROP!",
interaction.user.id,
result.amount || 0,
result.currency || "Coins"
);
await interaction.message.edit({ embeds, components });
} else {
await interaction.editReply({
embeds: [createErrorEmbed(result.error || "Failed to claim.")]
});
}
}
}

View File

@@ -0,0 +1,216 @@
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
import { lootdropService } from "./lootdrop.service";
import { lootdrops } from "@/db/schema";
import { economyService } from "./economy.service";
// Mock dependencies BEFORE using service functionality
const mockInsert = mock();
const mockUpdate = mock();
const mockDelete = mock();
const mockSelect = mock();
const mockValues = mock();
const mockReturning = mock();
const mockSet = mock();
const mockWhere = mock();
const mockFrom = mock();
// Mock setup
mockInsert.mockReturnValue({ values: mockValues });
mockUpdate.mockReturnValue({ set: mockSet });
mockSet.mockReturnValue({ where: mockWhere });
mockWhere.mockReturnValue({ returning: mockReturning });
mockSelect.mockReturnValue({ from: mockFrom });
mockFrom.mockReturnValue({ where: mockWhere });
// Mock DrizzleClient
mock.module("@/lib/DrizzleClient", () => {
return {
DrizzleClient: {
insert: mockInsert,
update: mockUpdate,
delete: mockDelete,
select: mockSelect,
}
};
});
// Mock Config
mock.module("@/lib/config", () => ({
config: {
lootdrop: {
activityWindowMs: 60000,
minMessages: 3,
spawnChance: 0.5,
cooldownMs: 10000,
reward: {
min: 10,
max: 100,
currency: "GOLD"
}
}
}
}));
describe("lootdropService", () => {
let originalRandom: any;
let mockModifyUserBalance: any;
beforeEach(() => {
mockInsert.mockClear();
mockUpdate.mockClear();
mockDelete.mockClear();
mockValues.mockClear();
mockReturning.mockClear();
mockSet.mockClear();
mockWhere.mockClear();
mockSelect.mockClear();
mockFrom.mockClear();
// Reset internal state
(lootdropService as any).channelActivity = new Map();
(lootdropService as any).channelCooldowns = new Map();
// Mock Math.random
originalRandom = Math.random;
// Spy
mockModifyUserBalance = spyOn(economyService, 'modifyUserBalance').mockResolvedValue({} as any);
});
afterEach(() => {
Math.random = originalRandom;
mockModifyUserBalance.mockRestore();
});
describe("processMessage", () => {
it("should track activity but not spawn if minMessages not reached", async () => {
const mockChannel = { id: "chan1", send: mock() };
const mockMessage = {
author: { bot: false },
guild: {},
channel: mockChannel
};
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
// Expect no spawn attempt
expect(mockChannel.send).not.toHaveBeenCalled();
// Internal state check if possible, or just behavior
});
it("should spawn lootdrop if minMessages reached and chance hits", async () => {
const mockChannel = { id: "chan1", send: mock() };
const mockMessage = {
author: { bot: false },
guild: {},
channel: mockChannel
};
mockChannel.send.mockResolvedValue({ id: "msg1" });
Math.random = () => 0.01; // Force hit (0.01 < 0.5)
// Send 3 messages
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
expect(mockChannel.send).toHaveBeenCalled();
expect(mockInsert).toHaveBeenCalledWith(lootdrops);
// Verify DB insert
expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({
channelId: "chan1",
messageId: "msg1",
currency: "GOLD"
}));
});
it("should not spawn if chance fails", async () => {
const mockChannel = { id: "chan1", send: mock() };
const mockMessage = {
author: { bot: false },
guild: {},
channel: mockChannel
};
Math.random = () => 0.99; // Force fail (0.99 > 0.5)
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
expect(mockChannel.send).not.toHaveBeenCalled();
});
it("should respect cooldowns", async () => {
const mockChannel = { id: "chan1", send: mock() };
const mockMessage = {
author: { bot: false },
guild: {},
channel: mockChannel
};
mockChannel.send.mockResolvedValue({ id: "msg1" });
Math.random = () => 0.01; // Force hit
// Trigger spawn
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
expect(mockChannel.send).toHaveBeenCalledTimes(1);
mockChannel.send.mockClear();
// Try again immediately (cooldown active)
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
expect(mockChannel.send).not.toHaveBeenCalled();
});
});
describe("tryClaim", () => {
it("should claim successfully if available", async () => {
mockReturning.mockResolvedValue([{
messageId: "1001",
rewardAmount: 50,
currency: "GOLD",
channelId: "100"
}]);
const result = await lootdropService.tryClaim("1001", "123", "UserOne");
expect(result.success).toBe(true);
expect(result.amount).toBe(50);
expect(mockModifyUserBalance).toHaveBeenCalledWith("123", 50n, "LOOTDROP_CLAIM", expect.any(String));
});
it("should fail if already claimed", async () => {
// Update returns empty (failed condition)
mockReturning.mockResolvedValue([]);
// Select check returns non-empty (exists)
const mockWhereSelect = mock().mockResolvedValue([{ messageId: "1001", claimedBy: 123n }]);
mockFrom.mockReturnValue({ where: mockWhereSelect });
const result = await lootdropService.tryClaim("1001", "123", "UserOne");
expect(result.success).toBe(false);
expect(result.error).toBe("This lootdrop has already been claimed.");
});
it("should fail if expired/not found", async () => {
mockReturning.mockResolvedValue([]);
const mockWhereSelect = mock().mockResolvedValue([]); // Empty result
mockFrom.mockReturnValue({ where: mockWhereSelect });
const result = await lootdropService.tryClaim("1001", "123", "UserOne");
expect(result.success).toBe(false);
expect(result.error).toBe("This lootdrop has expired.");
});
});
});

View File

@@ -0,0 +1,163 @@
import { Message, TextChannel } from "discord.js";
import { getLootdropMessage } from "./lootdrop.view";
import { config } from "@/lib/config";
import { economyService } from "./economy.service";
import { terminalService } from "@/modules/terminal/terminal.service";
import { lootdrops } from "@/db/schema";
import { DrizzleClient } from "@/lib/DrizzleClient";
import { eq, and, isNull, lt } from "drizzle-orm";
interface Lootdrop {
messageId: string;
channelId: string;
rewardAmount: number;
currency: string;
claimedBy?: string;
createdAt: Date;
}
class LootdropService {
private channelActivity: Map<string, number[]> = new Map();
private channelCooldowns: Map<string, number> = new Map();
constructor() {
// Cleanup interval for activity tracking and expired lootdrops
setInterval(() => {
this.cleanupActivity();
this.cleanupExpiredLootdrops();
}, 60000);
}
private cleanupActivity() {
const now = Date.now();
const window = config.lootdrop.activityWindowMs;
for (const [channelId, timestamps] of this.channelActivity.entries()) {
const validTimestamps = timestamps.filter(t => now - t < window);
if (validTimestamps.length === 0) {
this.channelActivity.delete(channelId);
} else {
this.channelActivity.set(channelId, validTimestamps);
}
}
}
private async cleanupExpiredLootdrops() {
try {
const now = new Date();
await DrizzleClient.delete(lootdrops)
.where(and(
isNull(lootdrops.claimedBy),
lt(lootdrops.expiresAt, now)
));
} catch (error) {
console.error("Failed to cleanup lootdrops:", error);
}
}
public async processMessage(message: Message) {
if (message.author.bot || !message.guild) return;
const channelId = message.channel.id;
const now = Date.now();
// Check cooldown
const cooldown = this.channelCooldowns.get(channelId);
if (cooldown && now < cooldown) return;
// Track activity
const timestamps = this.channelActivity.get(channelId) || [];
timestamps.push(now);
this.channelActivity.set(channelId, timestamps);
// Filter for window
const window = config.lootdrop.activityWindowMs;
const recentActivity = timestamps.filter(t => now - t < window);
if (recentActivity.length >= config.lootdrop.minMessages) {
// Chance to spawn
if (Math.random() < config.lootdrop.spawnChance) {
await this.spawnLootdrop(message.channel as TextChannel);
// Set cooldown
this.channelCooldowns.set(channelId, now + config.lootdrop.cooldownMs);
this.channelActivity.set(channelId, []);
}
}
}
private async spawnLootdrop(channel: TextChannel) {
const min = config.lootdrop.reward.min;
const max = config.lootdrop.reward.max;
const reward = Math.floor(Math.random() * (max - min + 1)) + min;
const currency = config.lootdrop.reward.currency;
const { embeds, components } = getLootdropMessage(reward, currency);
try {
const message = await channel.send({ embeds, components });
// Persist to DB
await DrizzleClient.insert(lootdrops).values({
messageId: message.id,
channelId: channel.id,
rewardAmount: reward,
currency: currency,
createdAt: new Date(),
// Expire after 10 mins
expiresAt: new Date(Date.now() + 600000)
});
// Trigger Terminal Update
terminalService.update();
} catch (error) {
console.error("Failed to spawn lootdrop:", error);
}
}
public async tryClaim(messageId: string, userId: string, username: string): Promise<{ success: boolean; amount?: number; currency?: string; error?: string }> {
try {
// Atomic update: Try to set claimedBy where it is currently null
// This acts as a lock and check in one query
const result = await DrizzleClient.update(lootdrops)
.set({ claimedBy: BigInt(userId) })
.where(and(
eq(lootdrops.messageId, messageId),
isNull(lootdrops.claimedBy)
))
.returning();
if (result.length === 0 || !result[0]) {
// If update affected 0 rows, check if it was because it doesn't exist or is already claimed
const check = await DrizzleClient.select().from(lootdrops).where(eq(lootdrops.messageId, messageId));
if (check.length === 0) {
return { success: false, error: "This lootdrop has expired." };
}
return { success: false, error: "This lootdrop has already been claimed." };
}
const drop = result[0];
await economyService.modifyUserBalance(
userId,
BigInt(drop.rewardAmount),
"LOOTDROP_CLAIM",
`Claimed lootdrop in channel ${drop.channelId}`
);
// Trigger Terminal Update
terminalService.update();
return { success: true, amount: drop.rewardAmount, currency: drop.currency };
} catch (error) {
console.error("Error claiming lootdrop:", error);
return { success: false, error: "An error occurred while processing the reward." };
}
}
}
export const lootdropService = new LootdropService();

View File

@@ -0,0 +1,41 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js";
import { createBaseEmbed } from "@lib/embeds";
export function getLootdropMessage(reward: number, currency: string) {
const embed = createBaseEmbed(
"💰 LOOTDROP!",
`A lootdrop has appeared! Click the button below to claim **${reward} ${currency}**!`,
"#FFD700"
);
const claimButton = new ButtonBuilder()
.setCustomId("lootdrop_claim")
.setLabel("CLAIM REWARD")
.setStyle(ButtonStyle.Success)
.setEmoji("💸");
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(claimButton);
return { embeds: [embed], components: [row] };
}
export function getLootdropClaimedMessage(originalTitle: string, userId: string, amount: number, currency: string) {
const newEmbed = createBaseEmbed(
originalTitle || "💰 LOOTDROP!",
`✅ Claimed by <@${userId}> for **${amount} ${currency}**!`,
"#00FF00"
);
const newRow = new ActionRowBuilder<ButtonBuilder>()
.addComponents(
new ButtonBuilder()
.setCustomId("lootdrop_claim_disabled")
.setLabel("CLAIMED")
.setStyle(ButtonStyle.Secondary)
.setEmoji("✅")
.setDisabled(true)
);
return { embeds: [newEmbed], components: [newRow] };
}

View File

@@ -0,0 +1,40 @@
import { ButtonInteraction, MessageFlags } from "discord.js";
import { inventoryService } from "@/modules/inventory/inventory.service";
import { userService } from "@/modules/user/user.service";
import { createErrorEmbed, createWarningEmbed } from "@/lib/embeds";
export async function handleShopInteraction(interaction: ButtonInteraction) {
if (!interaction.customId.startsWith("shop_buy_")) return;
try {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const itemId = parseInt(interaction.customId.replace("shop_buy_", ""));
if (isNaN(itemId)) {
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Item ID.")] });
return;
}
const item = await inventoryService.getItem(itemId);
if (!item || !item.price) {
await interaction.editReply({ embeds: [createErrorEmbed("Item not found or not for sale.")] });
return;
}
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
// Double check balance here too, although service handles it, we want a nice message
if ((user.balance ?? 0n) < item.price) {
await interaction.editReply({ embeds: [createWarningEmbed(`You need ${item.price} 🪙 to buy this item. You have ${user.balance} 🪙.`)] });
return;
}
const result = await inventoryService.buyItem(user.id, item.id, 1n);
await interaction.editReply({ content: `✅ **Success!** You bought **${item.name}** for ${item.price} 🪙.` });
} catch (error: any) {
console.error("Shop Purchase Error:", error);
await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An error occurred while processing your purchase.")] });
}
}

View File

@@ -0,0 +1,20 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
import { createBaseEmbed } from "@/lib/embeds";
export function getShopListingMessage(item: { id: number; name: string; description: string | null; formattedPrice: string; iconUrl: string | null; imageUrl: string | null; price: number | bigint }) {
const embed = createBaseEmbed(`Shop: ${item.name}`, item.description || "No description available.", "Green")
.addFields({ name: "Price", value: item.formattedPrice, inline: true })
.setThumbnail(item.iconUrl || null)
.setImage(item.imageUrl || null)
.setFooter({ text: "Click the button below to purchase instantly." });
const buyButton = new ButtonBuilder()
.setCustomId(`shop_buy_${item.id}`)
.setLabel(`Buy for ${item.price} 🪙`)
.setStyle(ButtonStyle.Success)
.setEmoji("🛒");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton);
return { embeds: [embed], components: [row] };
}

View File

@@ -0,0 +1,112 @@
import type { Interaction } from "discord.js";
import { TextChannel, MessageFlags } from "discord.js";
import { config } from "@/lib/config";
import { AuroraClient } from "@/lib/BotClient";
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
import { createErrorEmbed, createSuccessEmbed } from "@/lib/embeds";
export const handleFeedbackInteraction = async (interaction: Interaction) => {
// Handle select menu for choosing feedback type
if (interaction.isStringSelectMenu() && interaction.customId === "feedback_select_type") {
const feedbackType = interaction.values[0] as FeedbackType;
if (!feedbackType) {
await interaction.reply({
embeds: [createErrorEmbed("Invalid feedback type selected.")],
ephemeral: true
});
return;
}
const modal = getFeedbackModal(feedbackType);
await interaction.showModal(modal);
return;
}
// Handle modal submission
if (interaction.isModalSubmit() && interaction.customId.startsWith(FEEDBACK_CUSTOM_IDS.MODAL)) {
// Extract feedback type from customId (format: feedback_modal_FEATURE_REQUEST)
const parts = interaction.customId.split("_");
const feedbackType = parts.slice(2).join("_") as FeedbackType;
console.log(`Processing feedback modal. CustomId: ${interaction.customId}, Extracted type: ${feedbackType}`);
if (!feedbackType || !["FEATURE_REQUEST", "BUG_REPORT", "GENERAL"].includes(feedbackType)) {
console.error(`Invalid feedback type extracted: ${feedbackType} from customId: ${interaction.customId}`);
await interaction.reply({
embeds: [createErrorEmbed("An error occurred processing your feedback. Please try again.")],
ephemeral: true
});
return;
}
if (!config.feedbackChannelId) {
await interaction.reply({
embeds: [createErrorEmbed("Feedback channel is not configured. Please contact an administrator.")],
ephemeral: true
});
return;
}
try {
// Parse modal inputs
const title = interaction.fields.getTextInputValue(FEEDBACK_CUSTOM_IDS.TITLE_FIELD);
const description = interaction.fields.getTextInputValue(FEEDBACK_CUSTOM_IDS.DESCRIPTION_FIELD);
// Build feedback data
const feedbackData: FeedbackData = {
type: feedbackType,
title,
description,
userId: interaction.user.id,
username: interaction.user.username,
timestamp: new Date()
};
// Get feedback channel
const channel = await AuroraClient.channels.fetch(config.feedbackChannelId).catch(() => null) as TextChannel | null;
if (!channel) {
await interaction.reply({
embeds: [createErrorEmbed("Feedback channel not found. Please contact an administrator.")],
ephemeral: true
});
return;
}
// Build and send beautiful message
const containers = buildFeedbackMessage(feedbackData);
const feedbackMessage = await channel.send({
components: containers as any,
flags: MessageFlags.IsComponentsV2
});
// Add reaction votes
await feedbackMessage.react("👍");
await feedbackMessage.react("👎");
// Confirm to user
await interaction.reply({
embeds: [createSuccessEmbed("Your feedback has been submitted successfully! Thank you for helping improve Aurora.", "✨ Feedback Submitted")],
ephemeral: true
});
} catch (error: any) {
console.error("Error submitting feedback:", error);
if (!interaction.replied && !interaction.deferred) {
await interaction.reply({
embeds: [createErrorEmbed("An error occurred while submitting your feedback. Please try again later.")],
ephemeral: true
});
} else {
await interaction.followUp({
embeds: [createErrorEmbed("An error occurred while submitting your feedback. Please try again later.")],
ephemeral: true
});
}
}
}
};

View File

@@ -0,0 +1,23 @@
export type FeedbackType = "FEATURE_REQUEST" | "BUG_REPORT" | "GENERAL";
export interface FeedbackData {
type: FeedbackType;
title: string;
description: string;
userId: string;
username: string;
timestamp: Date;
}
export const FEEDBACK_TYPE_LABELS: Record<FeedbackType, string> = {
FEATURE_REQUEST: "💡 Feature Request",
BUG_REPORT: "🐛 Bug Report",
GENERAL: "💬 General Feedback"
};
export const FEEDBACK_CUSTOM_IDS = {
MODAL: "feedback_modal",
TYPE_FIELD: "feedback_type",
TITLE_FIELD: "feedback_title",
DESCRIPTION_FIELD: "feedback_description"
} as const;

View File

@@ -0,0 +1,123 @@
import {
ModalBuilder,
TextInputBuilder,
TextInputStyle,
ActionRowBuilder,
StringSelectMenuBuilder,
ActionRowBuilder as MessageActionRowBuilder,
ContainerBuilder,
TextDisplayBuilder,
ButtonBuilder,
ButtonStyle
} from "discord.js";
import { FEEDBACK_TYPE_LABELS, FEEDBACK_CUSTOM_IDS, type FeedbackData, type FeedbackType } from "./feedback.types";
export function getFeedbackTypeMenu() {
const select = new StringSelectMenuBuilder()
.setCustomId("feedback_select_type")
.setPlaceholder("Choose feedback type")
.addOptions([
{
label: "💡 Feature Request",
description: "Suggest a new feature or improvement",
value: "FEATURE_REQUEST"
},
{
label: "🐛 Bug Report",
description: "Report a bug or issue",
value: "BUG_REPORT"
},
{
label: "💬 General Feedback",
description: "Share your thoughts or suggestions",
value: "GENERAL"
}
]);
const row = new MessageActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
return { components: [row] };
}
export function getFeedbackModal(feedbackType: FeedbackType) {
const modal = new ModalBuilder()
.setCustomId(`${FEEDBACK_CUSTOM_IDS.MODAL}_${feedbackType}`)
.setTitle(FEEDBACK_TYPE_LABELS[feedbackType]);
// Title Input
const titleInput = new TextInputBuilder()
.setCustomId(FEEDBACK_CUSTOM_IDS.TITLE_FIELD)
.setLabel("Title")
.setStyle(TextInputStyle.Short)
.setPlaceholder("Brief summary of your feedback")
.setRequired(true)
.setMaxLength(100);
const titleRow = new ActionRowBuilder<TextInputBuilder>().addComponents(titleInput);
// Description Input
const descriptionInput = new TextInputBuilder()
.setCustomId(FEEDBACK_CUSTOM_IDS.DESCRIPTION_FIELD)
.setLabel("Description")
.setStyle(TextInputStyle.Paragraph)
.setPlaceholder("Provide detailed information about your feedback")
.setRequired(true)
.setMaxLength(1000);
const descriptionRow = new ActionRowBuilder<TextInputBuilder>().addComponents(descriptionInput);
modal.addComponents(titleRow, descriptionRow);
return modal;
}
export function buildFeedbackMessage(feedback: FeedbackData) {
// Define colors/themes for each feedback type
const themes = {
FEATURE_REQUEST: {
icon: "💡",
color: "Blue",
title: "FEATURE REQUEST",
description: "A new starlight suggestion has been received"
},
BUG_REPORT: {
icon: "🐛",
color: "Red",
title: "BUG REPORT",
description: "A cosmic anomaly has been detected"
},
GENERAL: {
icon: "💬",
color: "Gray",
title: "GENERAL FEEDBACK",
description: "A message from the cosmos"
}
};
const theme = themes[feedback.type];
if (!theme) {
console.error(`Unknown feedback type: ${feedback.type}`);
throw new Error(`Invalid feedback type: ${feedback.type}`);
}
const timestamp = Math.floor(feedback.timestamp.getTime() / 1000);
// Header Container
const headerContainer = new ContainerBuilder()
.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`# ${theme.icon} ${theme.title}`),
new TextDisplayBuilder().setContent(`*${theme.description}*`)
);
// Content Container
const contentContainer = new ContainerBuilder()
.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`## ${feedback.title}`),
new TextDisplayBuilder().setContent(`> ${feedback.description.split('\n').join('\n> ')}`),
new TextDisplayBuilder().setContent(
`**Submitted by:** <@${feedback.userId}>\n**Time:** <t:${timestamp}:F> (<t:${timestamp}:R>)`
)
);
return [headerContainer, contentContainer];
}

View File

@@ -0,0 +1,62 @@
import { levelingService } from "@/modules/leveling/leveling.service";
import { economyService } from "@/modules/economy/economy.service";
import { userTimers } from "@/db/schema";
import type { EffectHandler } from "./types";
// Helper to extract duration in seconds
const getDuration = (effect: any): number => {
if (effect.durationHours) return effect.durationHours * 3600;
if (effect.durationMinutes) return effect.durationMinutes * 60;
return effect.durationSeconds || 60; // Default to 60s if nothing provided
};
export const handleAddXp: EffectHandler = async (userId, effect, txFn) => {
await levelingService.addXp(userId, BigInt(effect.amount), txFn);
return `Gained ${effect.amount} XP`;
};
export const handleAddBalance: EffectHandler = async (userId, effect, txFn) => {
await economyService.modifyUserBalance(userId, BigInt(effect.amount), 'ITEM_USE', `Used Item`, null, txFn);
return `Gained ${effect.amount} 🪙`;
};
export const handleReplyMessage: EffectHandler = async (_userId, effect, _txFn) => {
return effect.message;
};
export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
const boostDuration = getDuration(effect);
const expiresAt = new Date(Date.now() + boostDuration * 1000);
await txFn.insert(userTimers).values({
userId: BigInt(userId),
type: 'EFFECT',
key: 'xp_boost',
expiresAt: expiresAt,
metadata: { multiplier: effect.multiplier }
}).onConflictDoUpdate({
target: [userTimers.userId, userTimers.type, userTimers.key],
set: { expiresAt: expiresAt, metadata: { multiplier: effect.multiplier } }
});
return `XP Boost (${effect.multiplier}x) active for ${Math.floor(boostDuration / 60)}m`;
};
export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
const roleDuration = getDuration(effect);
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
await txFn.insert(userTimers).values({
userId: BigInt(userId),
type: 'ACCESS',
key: `role_${effect.roleId}`,
expiresAt: roleExpiresAt,
metadata: { roleId: effect.roleId }
}).onConflictDoUpdate({
target: [userTimers.userId, userTimers.type, userTimers.key],
set: { expiresAt: roleExpiresAt }
});
// Actual role assignment happens in the Command layer
return `Temporary Role granted for ${Math.floor(roleDuration / 60)}m`;
};
export const handleColorRole: EffectHandler = async (_userId, _effect, _txFn) => {
return "Color Role Equipped";
};

View File

@@ -0,0 +1,18 @@
import {
handleAddXp,
handleAddBalance,
handleReplyMessage,
handleXpBoost,
handleTempRole,
handleColorRole
} from "./handlers";
import type { EffectHandler } from "./types";
export const effectHandlers: Record<string, EffectHandler> = {
'ADD_XP': handleAddXp,
'ADD_BALANCE': handleAddBalance,
'REPLY_MESSAGE': handleReplyMessage,
'XP_BOOST': handleXpBoost,
'TEMP_ROLE': handleTempRole,
'COLOR_ROLE': handleColorRole
};

View File

@@ -0,0 +1,4 @@
import type { Transaction } from "@/lib/types";
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<string>;

View File

@@ -0,0 +1,242 @@
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
import { inventoryService } from "./inventory.service";
import { inventory, userTimers } from "@/db/schema";
// Helper to mock resolved value for spyOn
import { economyService } from "@/modules/economy/economy.service";
import { levelingService } from "@/modules/leveling/leveling.service";
// Mock dependencies
const mockFindFirst = mock();
const mockFindMany = mock();
const mockInsert = mock();
const mockUpdate = mock();
const mockDelete = mock();
const mockValues = mock();
const mockReturning = mock();
const mockSet = mock();
const mockWhere = mock();
const mockSelect = mock();
const mockFrom = mock();
const mockOnConflictDoUpdate = mock();
// Chain setup
mockInsert.mockReturnValue({ values: mockValues });
mockValues.mockReturnValue({
returning: mockReturning,
onConflictDoUpdate: mockOnConflictDoUpdate
});
mockOnConflictDoUpdate.mockResolvedValue({});
mockUpdate.mockReturnValue({ set: mockSet });
mockSet.mockReturnValue({ where: mockWhere });
mockWhere.mockReturnValue({ returning: mockReturning });
mockDelete.mockReturnValue({ where: mockWhere });
mockSelect.mockReturnValue({ from: mockFrom });
mockFrom.mockReturnValue({ where: mockWhere });
// Mock DrizzleClient
mock.module("@/lib/DrizzleClient", () => {
const createMockTx = () => ({
query: {
inventory: { findFirst: mockFindFirst, findMany: mockFindMany },
items: { findFirst: mockFindFirst },
userTimers: { findFirst: mockFindFirst },
},
insert: mockInsert,
update: mockUpdate,
delete: mockDelete,
select: mockSelect,
});
return {
DrizzleClient: {
...createMockTx(),
transaction: async (cb: any) => cb(createMockTx()),
}
};
});
mock.module("@/lib/config", () => ({
config: {
inventory: {
maxStackSize: 100n,
maxSlots: 10
}
}
}));
describe("inventoryService", () => {
let mockModifyUserBalance: any;
let mockAddXp: any;
beforeEach(() => {
mockFindFirst.mockReset();
mockFindMany.mockReset();
mockInsert.mockClear();
mockUpdate.mockClear();
mockDelete.mockClear();
mockValues.mockClear();
mockReturning.mockClear();
mockSet.mockClear();
mockWhere.mockClear();
mockSelect.mockClear();
mockFrom.mockClear();
mockOnConflictDoUpdate.mockClear();
// Setup Spies
mockModifyUserBalance = spyOn(economyService, 'modifyUserBalance').mockResolvedValue({} as any);
mockAddXp = spyOn(levelingService, 'addXp').mockResolvedValue({} as any);
});
afterEach(() => {
mockModifyUserBalance.mockRestore();
mockAddXp.mockRestore();
});
describe("addItem", () => {
it("should add new item if slot available", async () => {
// Check existing (none) -> Check count (0) -> Insert
mockFindFirst.mockResolvedValue(null);
const mockCountResult = mock().mockResolvedValue([{ count: 0 }]);
mockFrom.mockReturnValue({ where: mockCountResult });
mockReturning.mockResolvedValue([{ itemId: 1, quantity: 5n }]);
const result = await inventoryService.addItem("1", 1, 5n);
expect(result).toEqual({ itemId: 1, quantity: 5n } as any);
expect(mockInsert).toHaveBeenCalledWith(inventory);
expect(mockValues).toHaveBeenCalledWith({
userId: 1n,
itemId: 1,
quantity: 5n
});
});
it("should stack existing item up to limit", async () => {
// Check existing (found with 10)
mockFindFirst.mockResolvedValue({ quantity: 10n });
mockReturning.mockResolvedValue([{ itemId: 1, quantity: 15n }]);
const result = await inventoryService.addItem("1", 1, 5n);
expect(result).toBeDefined();
expect(result?.quantity).toBe(15n);
expect(mockUpdate).toHaveBeenCalledWith(inventory);
expect(mockSet).toHaveBeenCalledWith({ quantity: 15n });
});
it("should throw if max stack exceeded", async () => {
mockFindFirst.mockResolvedValue({ quantity: 99n });
// Max is 100
expect(inventoryService.addItem("1", 1, 5n)).rejects.toThrow("Cannot exceed max stack size");
});
it("should throw if inventory full", async () => {
mockFindFirst.mockResolvedValue(null);
const mockCountResult = mock().mockResolvedValue([{ count: 10 }]); // Max slots 10
mockFrom.mockReturnValue({ where: mockCountResult });
expect(inventoryService.addItem("1", 1, 1n)).rejects.toThrow("Inventory full");
});
});
describe("removeItem", () => {
it("should decrease quantity if enough", async () => {
mockFindFirst.mockResolvedValue({ quantity: 10n });
mockReturning.mockResolvedValue([{ quantity: 5n }]);
await inventoryService.removeItem("1", 1, 5n);
expect(mockUpdate).toHaveBeenCalledWith(inventory);
// mockSet uses sql template, hard to check exact value, checking call presence
expect(mockSet).toHaveBeenCalled();
});
it("should delete item if quantity becomes 0", async () => {
mockFindFirst.mockResolvedValue({ quantity: 5n });
const result = await inventoryService.removeItem("1", 1, 5n);
expect(mockDelete).toHaveBeenCalledWith(inventory);
expect(result).toBeDefined();
expect(result?.quantity).toBe(0n);
});
it("should throw if insufficient quantity", async () => {
mockFindFirst.mockResolvedValue({ quantity: 2n });
expect(inventoryService.removeItem("1", 1, 5n)).rejects.toThrow("Insufficient item quantity");
});
});
describe("buyItem", () => {
it("should buy item successfully", async () => {
const mockItem = { id: 1, name: "Potion", price: 100n };
mockFindFirst.mockResolvedValue(mockItem);
// For addItem internal call, we need to mock findFirst again or ensure it works.
// DrizzleClient.transaction calls callback.
// buyItem calls findFirst for item.
// buyItem calls modifyUserBalance.
// buyItem calls addItem.
// addItem calls findFirst for inventory.
// So mockFindFirst needs to return specific values in sequence.
mockFindFirst
.mockResolvedValueOnce(mockItem) // Item check
.mockResolvedValueOnce(null); // addItem -> existing check (null = new)
// addItem -> count check
const mockCountResult = mock().mockResolvedValue([{ count: 0 }]);
mockFrom.mockReturnValue({ where: mockCountResult });
mockReturning.mockResolvedValue([{}]);
const result = await inventoryService.buyItem("1", 1, 2n);
expect(result.success).toBe(true);
expect(mockModifyUserBalance).toHaveBeenCalledWith("1", -200n, 'PURCHASE', expect.stringContaining("Bought 2x"), null, expect.anything());
expect(mockInsert).toHaveBeenCalledWith(inventory); // from addItem
});
});
describe("useItem", () => {
it("should apply effects and consume item", async () => {
const mockItem = {
id: 1,
name: "XP Potion",
usageData: {
consume: true,
effects: [
{ type: "ADD_XP", amount: 100 },
{ type: "XP_BOOST", durationMinutes: 60, multiplier: 2.0 }
]
}
};
// inventory entry
mockFindFirst.mockResolvedValue({ quantity: 1n, item: mockItem });
// For removeItem:
// removeItem calls findFirst (inventory).
// So sequence:
// 1. useItem -> findFirst (inventory + item)
// 2. removeItem -> findFirst (inventory)
mockFindFirst
.mockResolvedValueOnce({ quantity: 1n, item: mockItem }) // useItem check
.mockResolvedValueOnce({ quantity: 1n }); // removeItem check
const result = await inventoryService.useItem("1", 1);
expect(result.success).toBe(true);
expect(mockAddXp).toHaveBeenCalledWith("1", 100n, expect.anything());
expect(mockInsert).toHaveBeenCalledWith(userTimers); // XP Boost
expect(mockDelete).toHaveBeenCalledWith(inventory); // Consume
});
});
});

View File

@@ -1,13 +1,18 @@
import { inventory, items, users, userTimers } from "@/db/schema";
import { inventory, items, users } from "@/db/schema";
import { eq, and, sql, count } from "drizzle-orm"; import { eq, and, sql, count } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { DrizzleClient } from "@/lib/DrizzleClient";
import { economyService } from "@/modules/economy/economy.service"; import { economyService } from "@/modules/economy/economy.service";
import { GameConfig } from "@/config/game"; import { levelingService } from "@/modules/leveling/leveling.service";
import { config } from "@/lib/config";
import { UserError } from "@/lib/errors";
import { withTransaction } from "@/lib/db";
import type { Transaction, ItemUsageData } from "@/lib/types";
export const inventoryService = { export const inventoryService = {
addItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: any) => { addItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => {
const execute = async (txFn: any) => { return await withTransaction(async (txFn) => {
// Check if item exists in inventory // Check if item exists in inventory
const existing = await txFn.query.inventory.findFirst({ const existing = await txFn.query.inventory.findFirst({
where: and( where: and(
@@ -18,8 +23,8 @@ export const inventoryService = {
if (existing) { if (existing) {
const newQuantity = (existing.quantity ?? 0n) + quantity; const newQuantity = (existing.quantity ?? 0n) + quantity;
if (newQuantity > GameConfig.inventory.maxStackSize) { if (newQuantity > config.inventory.maxStackSize) {
throw new Error(`Cannot exceed max stack size of ${GameConfig.inventory.maxStackSize}`); throw new UserError(`Cannot exceed max stack size of ${config.inventory.maxStackSize}`);
} }
const [entry] = await txFn.update(inventory) const [entry] = await txFn.update(inventory)
@@ -39,12 +44,12 @@ export const inventoryService = {
.from(inventory) .from(inventory)
.where(eq(inventory.userId, BigInt(userId))); .where(eq(inventory.userId, BigInt(userId)));
if (inventoryCount.count >= GameConfig.inventory.maxSlots) { if (inventoryCount && inventoryCount.count >= config.inventory.maxSlots) {
throw new Error(`Inventory full (Max ${GameConfig.inventory.maxSlots} slots)`); throw new UserError(`Inventory full (Max ${config.inventory.maxSlots} slots)`);
} }
if (quantity > GameConfig.inventory.maxStackSize) { if (quantity > config.inventory.maxStackSize) {
throw new Error(`Cannot exceed max stack size of ${GameConfig.inventory.maxStackSize}`); throw new UserError(`Cannot exceed max stack size of ${config.inventory.maxStackSize}`);
} }
const [entry] = await txFn.insert(inventory) const [entry] = await txFn.insert(inventory)
@@ -56,12 +61,11 @@ export const inventoryService = {
.returning(); .returning();
return entry; return entry;
} }
}; }, tx);
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
}, },
removeItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: any) => { removeItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => {
const execute = async (txFn: any) => { return await withTransaction(async (txFn) => {
const existing = await txFn.query.inventory.findFirst({ const existing = await txFn.query.inventory.findFirst({
where: and( where: and(
eq(inventory.userId, BigInt(userId)), eq(inventory.userId, BigInt(userId)),
@@ -70,7 +74,7 @@ export const inventoryService = {
}); });
if (!existing || (existing.quantity ?? 0n) < quantity) { if (!existing || (existing.quantity ?? 0n) < quantity) {
throw new Error("Insufficient item quantity"); throw new UserError("Insufficient item quantity");
} }
if ((existing.quantity ?? 0n) === quantity) { if ((existing.quantity ?? 0n) === quantity) {
@@ -93,8 +97,7 @@ export const inventoryService = {
.returning(); .returning();
return entry; return entry;
} }
}; }, tx);
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
}, },
getInventory: async (userId: string) => { getInventory: async (userId: string) => {
@@ -106,14 +109,14 @@ export const inventoryService = {
}); });
}, },
buyItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: any) => { buyItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => {
const execute = async (txFn: any) => { return await withTransaction(async (txFn) => {
const item = await txFn.query.items.findFirst({ const item = await txFn.query.items.findFirst({
where: eq(items.id, itemId), where: eq(items.id, itemId),
}); });
if (!item) throw new Error("Item not found"); if (!item) throw new UserError("Item not found");
if (!item.price) throw new Error("Item is not for sale"); if (!item.price) throw new UserError("Item is not for sale");
const totalPrice = item.price * quantity; const totalPrice = item.price * quantity;
@@ -123,9 +126,7 @@ export const inventoryService = {
await inventoryService.addItem(userId, itemId, quantity, txFn); await inventoryService.addItem(userId, itemId, quantity, txFn);
return { success: true, item, totalPrice }; return { success: true, item, totalPrice };
}; }, tx);
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
}, },
getItem: async (itemId: number) => { getItem: async (itemId: number) => {
@@ -133,4 +134,52 @@ export const inventoryService = {
where: eq(items.id, itemId), where: eq(items.id, itemId),
}); });
}, },
useItem: async (userId: string, itemId: number, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
// 1. Check Ownership & Quantity
const entry = await txFn.query.inventory.findFirst({
where: and(
eq(inventory.userId, BigInt(userId)),
eq(inventory.itemId, itemId)
),
with: { item: true }
});
if (!entry || (entry.quantity ?? 0n) < 1n) {
throw new UserError("You do not own this item.");
}
const item = entry.item;
const usageData = item.usageData as ItemUsageData | null;
if (!usageData || !usageData.effects || usageData.effects.length === 0) {
throw new UserError("This item cannot be used.");
}
const results: string[] = [];
// 2. Apply Effects
// 2. Apply Effects
const { effectHandlers } = await import("./effects/registry");
for (const effect of usageData.effects) {
const handler = effectHandlers[effect.type];
if (handler) {
const result = await handler(userId, effect, txFn);
results.push(result);
} else {
console.warn(`No handler found for effect type: ${effect.type}`);
results.push(`Effect ${effect.type} applied (no description)`);
}
}
// 3. Consume
if (usageData.consume) {
await inventoryService.removeItem(userId, itemId, 1n, txFn);
}
return { success: true, results, usageData };
}, tx);
}
}; };

View File

@@ -0,0 +1,209 @@
import { describe, it, expect, mock, beforeEach, afterEach, setSystemTime } from "bun:test";
import { levelingService } from "./leveling.service";
import { users, userTimers } from "@/db/schema";
// Mock dependencies
const mockFindFirst = mock();
const mockUpdate = mock();
const mockSet = mock();
const mockWhere = mock();
const mockReturning = mock();
const mockInsert = mock();
const mockValues = mock();
const mockOnConflictDoUpdate = mock();
// Chain setup
mockUpdate.mockReturnValue({ set: mockSet });
mockSet.mockReturnValue({ where: mockWhere });
mockWhere.mockReturnValue({ returning: mockReturning });
mockInsert.mockReturnValue({ values: mockValues });
mockValues.mockReturnValue({ onConflictDoUpdate: mockOnConflictDoUpdate });
mockOnConflictDoUpdate.mockResolvedValue({});
mock.module("@/lib/DrizzleClient", () => {
const createMockTx = () => ({
query: {
users: { findFirst: mockFindFirst },
userTimers: { findFirst: mockFindFirst },
},
update: mockUpdate,
insert: mockInsert,
});
return {
DrizzleClient: {
...createMockTx(),
transaction: async (cb: any) => cb(createMockTx()),
}
};
});
mock.module("@/lib/config", () => ({
config: {
leveling: {
base: 100,
exponent: 1.5,
chat: {
minXp: 10,
maxXp: 20,
cooldownMs: 60000
}
}
}
}));
describe("levelingService", () => {
let originalRandom: any;
beforeEach(() => {
mockFindFirst.mockReset();
mockUpdate.mockClear();
mockSet.mockClear();
mockWhere.mockClear();
mockReturning.mockClear();
mockInsert.mockClear();
mockValues.mockClear();
mockOnConflictDoUpdate.mockClear();
originalRandom = Math.random;
});
afterEach(() => {
Math.random = originalRandom;
});
describe("getXpForLevel", () => {
it("should calculate correct XP", () => {
// base 100, exp 1.5
// lvl 1: 100 * 1^1.5 = 100
// lvl 2: 100 * 2^1.5 = 100 * 2.828 = 282
expect(levelingService.getXpForLevel(1)).toBe(100);
expect(levelingService.getXpForLevel(2)).toBe(282);
});
});
describe("addXp", () => {
it("should add XP without level up", async () => {
// User current: level 1, xp 0
// Add 50
// Next level (1) needed: 100. (Note: Logic in service seems to use currentLevel for calculation of next step.
// Service implementation:
// let xpForNextLevel = ... getXpForLevel(currentLevel)
// wait, if I am level 1, I need X XP to reach level 2?
// Service code:
// while (newXp >= xpForNextLevel) { ... currentLevel++ }
// So if I am level 1, calling getXpForLevel(1) returns 100.
// If I have 100 XP, I level up to 2.
mockFindFirst.mockResolvedValue({ xp: 0n, level: 1 });
mockReturning.mockResolvedValue([{ xp: 50n, level: 1 }]);
const result = await levelingService.addXp("1", 50n);
expect(result.levelUp).toBe(false);
expect(result.currentLevel).toBe(1);
expect(mockUpdate).toHaveBeenCalledWith(users);
expect(mockSet).toHaveBeenCalledWith({ xp: 50n, level: 1 });
});
it("should level up if XP sufficient", async () => {
// Current: Lvl 1, XP 0. Next Lvl needed: 100.
// Add 120.
// newXp = 120.
// 120 >= 100.
// newXp -= 100 -> 20.
// currentLevel -> 2.
// Next needed for Lvl 2 -> 282.
// 20 < 282. Loop ends.
mockFindFirst.mockResolvedValue({ xp: 0n, level: 1 });
mockReturning.mockResolvedValue([{ xp: 20n, level: 2 }]);
const result = await levelingService.addXp("1", 120n);
expect(result.levelUp).toBe(true);
expect(result.currentLevel).toBe(2);
expect(mockSet).toHaveBeenCalledWith({ xp: 20n, level: 2 });
});
it("should handle multiple level ups", async () => {
// Lvl 1 (100 needed). Lvl 2 (282 needed). Total for Lvl 3 = 100 + 282 = 382.
// Add 400.
// 400 >= 100 -> rem 300, Lvl 2.
// 300 >= 282 -> rem 18, Lvl 3.
mockFindFirst.mockResolvedValue({ xp: 0n, level: 1 });
mockReturning.mockResolvedValue([{ xp: 18n, level: 3 }]);
const result = await levelingService.addXp("1", 400n);
expect(result.currentLevel).toBe(3);
});
it("should throw if user not found", async () => {
mockFindFirst.mockResolvedValue(undefined);
expect(levelingService.addXp("1", 50n)).rejects.toThrow("User not found");
});
});
describe("processChatXp", () => {
beforeEach(() => {
setSystemTime(new Date("2023-01-01T12:00:00Z"));
});
it("should award XP if no cooldown", async () => {
mockFindFirst
.mockResolvedValueOnce(undefined) // Cooldown check
.mockResolvedValueOnce(undefined) // XP Boost check
.mockResolvedValueOnce({ xp: 0n, level: 1 }); // addXp -> getUser
mockReturning.mockResolvedValue([{ xp: 15n, level: 1 }]); // addXp -> update
Math.random = () => 0.5; // mid range? 10-20.
// floor(0.5 * (20 - 10 + 1)) + 10 = floor(0.5 * 11) + 10 = floor(5.5) + 10 = 15.
const result = await levelingService.processChatXp("1");
expect(result.awarded).toBe(true);
expect((result as any).amount).toBe(15n);
expect(mockInsert).toHaveBeenCalledWith(userTimers); // Cooldown set
});
it("should respect cooldown", async () => {
const future = new Date("2023-01-01T12:00:10Z");
mockFindFirst.mockResolvedValue({ expiresAt: future });
const result = await levelingService.processChatXp("1");
expect(result.awarded).toBe(false);
expect(result.reason).toBe("cooldown");
expect(mockUpdate).not.toHaveBeenCalled();
});
it("should apply XP boost", async () => {
const now = new Date();
const future = new Date(now.getTime() + 10000);
mockFindFirst
.mockResolvedValueOnce(undefined) // Cooldown
.mockResolvedValueOnce({ expiresAt: future, metadata: { multiplier: 2.0 } }) // Boost
.mockResolvedValueOnce({ xp: 0n, level: 1 }); // User
Math.random = () => 0.0; // Min value = 10.
// Boost 2x -> 20.
mockReturning.mockResolvedValue([{ xp: 20n, level: 1 }]);
const result = await levelingService.processChatXp("1");
// Check if amount passed to addXp was boosted
// Wait, result.amount is the returned amount from addXp ??
// processChatXp returns { awarded: true, amount, ...resultFromAddXp }
// So result.amount is the calculated amount.
expect((result as any).amount).toBe(20n);
// Implementation: amount = floor(amount * multiplier)
// min 10 * 2 = 20.
});
});
});

View File

@@ -1,17 +1,48 @@
import { users, userTimers } from "@/db/schema"; import { users, userTimers } from "@/db/schema";
import { eq, sql, and } from "drizzle-orm"; import { eq, sql, and } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { withTransaction } from "@/lib/db";
import { GameConfig } from "@/config/game"; import { config } from "@/lib/config";
import type { Transaction } from "@/lib/types";
export const levelingService = { export const levelingService = {
// Calculate XP required for a specific level // Calculate total XP required to REACH a specific level (Cumulative)
getXpForLevel: (level: number) => { // Level 1 = 0 XP
return Math.floor(GameConfig.leveling.base * Math.pow(level, GameConfig.leveling.exponent)); // Level 2 = Base * (1^Exp)
// Level 3 = Level 2 + Base * (2^Exp)
// ...
getXpToReachLevel: (level: number) => {
let total = 0;
for (let l = 1; l < level; l++) {
total += Math.floor(config.leveling.base * Math.pow(l, config.leveling.exponent));
}
return total;
}, },
// Pure XP addition - No cooldown checks // Calculate level from Total XP
addXp: async (id: string, amount: bigint, tx?: any) => { getLevelFromXp: (totalXp: bigint) => {
const execute = async (txFn: any) => { let level = 1;
let xp = Number(totalXp);
while (true) {
// XP needed to complete current level and reach next
const xpForNext = Math.floor(config.leveling.base * Math.pow(level, config.leveling.exponent));
if (xp < xpForNext) {
return level;
}
xp -= xpForNext;
level++;
}
},
// Get XP needed to complete the current level (for calculating next level threshold in isolation)
// Used internally or for display
getXpForNextLevel: (currentLevel: number) => {
return Math.floor(config.leveling.base * Math.pow(currentLevel, config.leveling.exponent));
},
// Cumulative XP addition
addXp: async (id: string, amount: bigint, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
// Get current state // Get current state
const user = await txFn.query.users.findFirst({ const user = await txFn.query.users.findFirst({
where: eq(users.id, BigInt(id)), where: eq(users.id, BigInt(id)),
@@ -19,44 +50,30 @@ export const levelingService = {
if (!user) throw new Error("User not found"); if (!user) throw new Error("User not found");
let newXp = (user.xp ?? 0n) + amount; const currentXp = user.xp ?? 0n;
let currentLevel = user.level ?? 1; const newXp = currentXp + amount;
let levelUp = false;
// Check for level up loop // Calculate new level based on TOTAL accumulated XP
let xpForNextLevel = BigInt(levelingService.getXpForLevel(currentLevel)); const newLevel = levelingService.getLevelFromXp(newXp);
const currentLevel = user.level ?? 1;
while (newXp >= xpForNextLevel) { const levelUp = newLevel > currentLevel;
newXp -= xpForNextLevel;
currentLevel++;
levelUp = true;
xpForNextLevel = BigInt(levelingService.getXpForLevel(currentLevel));
}
// Update user // Update user
const [updatedUser] = await txFn.update(users) const [updatedUser] = await txFn.update(users)
.set({ .set({
xp: newXp, xp: newXp,
level: currentLevel, level: newLevel,
}) })
.where(eq(users.id, BigInt(id))) .where(eq(users.id, BigInt(id)))
.returning(); .returning();
return { user: updatedUser, levelUp, currentLevel }; return { user: updatedUser, levelUp, currentLevel: newLevel };
}; }, tx);
if (tx) {
return await execute(tx);
} else {
return await DrizzleClient.transaction(async (t) => {
return await execute(t);
})
}
}, },
// Handle chat XP with cooldowns // Handle chat XP with cooldowns
processChatXp: async (id: string, tx?: any) => { processChatXp: async (id: string, tx?: Transaction) => {
const execute = async (txFn: any) => { return await withTransaction(async (txFn) => {
// check if an xp cooldown is in place // check if an xp cooldown is in place
const cooldown = await txFn.query.userTimers.findFirst({ const cooldown = await txFn.query.userTimers.findFirst({
where: and( where: and(
@@ -72,13 +89,27 @@ export const levelingService = {
} }
// Calculate random XP // Calculate random XP
const amount = BigInt(Math.floor(Math.random() * (GameConfig.leveling.chat.maxXp - GameConfig.leveling.chat.minXp + 1)) + GameConfig.leveling.chat.minXp); let amount = BigInt(Math.floor(Math.random() * (config.leveling.chat.maxXp - config.leveling.chat.minXp + 1)) + config.leveling.chat.minXp);
// Check for XP Boost
const xpBoost = await txFn.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, BigInt(id)),
eq(userTimers.type, 'EFFECT'),
eq(userTimers.key, 'xp_boost')
)
});
if (xpBoost && xpBoost.expiresAt > now) {
const multiplier = (xpBoost.metadata as any)?.multiplier || 1;
amount = BigInt(Math.floor(Number(amount) * multiplier));
}
// Add XP // Add XP
const result = await levelingService.addXp(id, amount, txFn); const result = await levelingService.addXp(id, amount, txFn);
// Update/Set Cooldown // Update/Set Cooldown
const nextReadyAt = new Date(now.getTime() + GameConfig.leveling.chat.cooldownMs); const nextReadyAt = new Date(now.getTime() + config.leveling.chat.cooldownMs);
await txFn.insert(userTimers) await txFn.insert(userTimers)
.values({ .values({
@@ -93,14 +124,6 @@ export const levelingService = {
}); });
return { awarded: true, amount, ...result }; return { awarded: true, amount, ...result };
}; }, tx);
if (tx) {
return await execute(tx);
} else {
return await DrizzleClient.transaction(async (t) => {
return await execute(t);
})
}
} }
}; };

View File

@@ -0,0 +1,158 @@
import { moderationCases } from "@/db/schema";
import { eq, and, desc } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient";
import type { CreateCaseOptions, ClearCaseOptions, SearchCasesFilter, CaseType } from "./moderation.types";
export class ModerationService {
/**
* Generate the next sequential case ID
*/
private static async getNextCaseId(): Promise<string> {
const latestCase = await DrizzleClient.query.moderationCases.findFirst({
orderBy: [desc(moderationCases.id)],
});
if (!latestCase) {
return "CASE-0001";
}
// Extract number from case ID (e.g., "CASE-0042" -> 42)
const match = latestCase.caseId.match(/CASE-(\d+)/);
if (!match || !match[1]) {
return "CASE-0001";
}
const nextNumber = parseInt(match[1], 10) + 1;
return `CASE-${nextNumber.toString().padStart(4, '0')}`;
}
/**
* Create a new moderation case
*/
static async createCase(options: CreateCaseOptions) {
const caseId = await this.getNextCaseId();
const [newCase] = await DrizzleClient.insert(moderationCases).values({
caseId,
type: options.type,
userId: BigInt(options.userId),
username: options.username,
moderatorId: BigInt(options.moderatorId),
moderatorName: options.moderatorName,
reason: options.reason,
metadata: options.metadata || {},
active: options.type === 'warn' ? true : false, // Only warnings are "active" by default
}).returning();
return newCase;
}
/**
* Get a case by its case ID
*/
static async getCaseById(caseId: string) {
return await DrizzleClient.query.moderationCases.findFirst({
where: eq(moderationCases.caseId, caseId),
});
}
/**
* Get all cases for a specific user
*/
static async getUserCases(userId: string, activeOnly: boolean = false) {
const conditions = [eq(moderationCases.userId, BigInt(userId))];
if (activeOnly) {
conditions.push(eq(moderationCases.active, true));
}
return await DrizzleClient.query.moderationCases.findMany({
where: and(...conditions),
orderBy: [desc(moderationCases.createdAt)],
});
}
/**
* Get active warnings for a user
*/
static async getUserWarnings(userId: string) {
return await DrizzleClient.query.moderationCases.findMany({
where: and(
eq(moderationCases.userId, BigInt(userId)),
eq(moderationCases.type, 'warn'),
eq(moderationCases.active, true)
),
orderBy: [desc(moderationCases.createdAt)],
});
}
/**
* Get all notes for a user
*/
static async getUserNotes(userId: string) {
return await DrizzleClient.query.moderationCases.findMany({
where: and(
eq(moderationCases.userId, BigInt(userId)),
eq(moderationCases.type, 'note')
),
orderBy: [desc(moderationCases.createdAt)],
});
}
/**
* Clear/resolve a warning
*/
static async clearCase(options: ClearCaseOptions) {
const [updatedCase] = await DrizzleClient.update(moderationCases)
.set({
active: false,
resolvedAt: new Date(),
resolvedBy: BigInt(options.clearedBy),
resolvedReason: options.reason || 'Manually cleared',
})
.where(eq(moderationCases.caseId, options.caseId))
.returning();
return updatedCase;
}
/**
* Search cases with various filters
*/
static async searchCases(filter: SearchCasesFilter) {
const conditions = [];
if (filter.userId) {
conditions.push(eq(moderationCases.userId, BigInt(filter.userId)));
}
if (filter.moderatorId) {
conditions.push(eq(moderationCases.moderatorId, BigInt(filter.moderatorId)));
}
if (filter.type) {
conditions.push(eq(moderationCases.type, filter.type));
}
if (filter.active !== undefined) {
conditions.push(eq(moderationCases.active, filter.active));
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
return await DrizzleClient.query.moderationCases.findMany({
where: whereClause,
orderBy: [desc(moderationCases.createdAt)],
limit: filter.limit || 50,
offset: filter.offset || 0,
});
}
/**
* Get total count of active warnings for a user (useful for auto-timeout)
*/
static async getActiveWarningCount(userId: string): Promise<number> {
const warnings = await this.getUserWarnings(userId);
return warnings.length;
}
}

View File

@@ -0,0 +1,44 @@
export type CaseType = 'warn' | 'timeout' | 'kick' | 'ban' | 'note' | 'prune';
export interface CreateCaseOptions {
type: CaseType;
userId: string;
username: string;
moderatorId: string;
moderatorName: string;
reason: string;
metadata?: Record<string, any>;
}
export interface ClearCaseOptions {
caseId: string;
clearedBy: string;
clearedByName: string;
reason?: string;
}
export interface ModerationCase {
id: bigint;
caseId: string;
type: string;
userId: bigint;
username: string;
moderatorId: bigint;
moderatorName: string;
reason: string;
metadata: Record<string, any>;
active: boolean;
createdAt: Date;
resolvedAt: Date | null;
resolvedBy: bigint | null;
resolvedReason: string | null;
}
export interface SearchCasesFilter {
userId?: string;
moderatorId?: string;
type?: CaseType;
active?: boolean;
limit?: number;
offset?: number;
}

View File

@@ -0,0 +1,241 @@
import { EmbedBuilder, Colors, time, TimestampStyles } from "discord.js";
import type { ModerationCase } from "./moderation.types";
/**
* Get color based on case type
*/
function getCaseColor(type: string): number {
switch (type) {
case 'warn': return Colors.Yellow;
case 'timeout': return Colors.Orange;
case 'kick': return Colors.Red;
case 'ban': return Colors.DarkRed;
case 'note': return Colors.Blue;
case 'prune': return Colors.Grey;
default: return Colors.Grey;
}
}
/**
* Get emoji based on case type
*/
function getCaseEmoji(type: string): string {
switch (type) {
case 'warn': return '⚠️';
case 'timeout': return '🔇';
case 'kick': return '👢';
case 'ban': return '🔨';
case 'note': return '📝';
case 'prune': return '🧹';
default: return '📋';
}
}
/**
* Display a single case
*/
export function getCaseEmbed(moderationCase: ModerationCase): EmbedBuilder {
const emoji = getCaseEmoji(moderationCase.type);
const color = getCaseColor(moderationCase.type);
const embed = new EmbedBuilder()
.setTitle(`${emoji} Case ${moderationCase.caseId}`)
.setColor(color)
.addFields(
{ name: 'Type', value: moderationCase.type.toUpperCase(), inline: true },
{ name: 'Status', value: moderationCase.active ? '🟢 Active' : '⚫ Resolved', inline: true },
{ name: '\u200B', value: '\u200B', inline: true },
{ name: 'User', value: `${moderationCase.username} (${moderationCase.userId})`, inline: false },
{ name: 'Moderator', value: moderationCase.moderatorName, inline: true },
{ name: 'Date', value: time(moderationCase.createdAt, TimestampStyles.ShortDateTime), inline: true }
)
.addFields({ name: 'Reason', value: moderationCase.reason })
.setTimestamp(moderationCase.createdAt);
// Add resolution info if resolved
if (!moderationCase.active && moderationCase.resolvedAt) {
embed.addFields(
{ name: '\u200B', value: '**Resolution**' },
{ name: 'Resolved At', value: time(moderationCase.resolvedAt, TimestampStyles.ShortDateTime), inline: true }
);
if (moderationCase.resolvedReason) {
embed.addFields({ name: 'Resolution Reason', value: moderationCase.resolvedReason });
}
}
// Add metadata if present
if (moderationCase.metadata && Object.keys(moderationCase.metadata).length > 0) {
const metadataStr = JSON.stringify(moderationCase.metadata, null, 2);
if (metadataStr.length < 1024) {
embed.addFields({ name: 'Additional Info', value: `\`\`\`json\n${metadataStr}\n\`\`\`` });
}
}
return embed;
}
/**
* Display a list of cases
*/
export function getCasesListEmbed(
cases: ModerationCase[],
title: string,
description?: string
): EmbedBuilder {
const embed = new EmbedBuilder()
.setTitle(title)
.setColor(Colors.Blue)
.setTimestamp();
if (description) {
embed.setDescription(description);
}
if (cases.length === 0) {
embed.setDescription('No cases found.');
return embed;
}
// Group by type for better display
const casesByType: Record<string, ModerationCase[]> = {};
for (const c of cases) {
if (!casesByType[c.type]) {
casesByType[c.type] = [];
}
casesByType[c.type]!.push(c);
}
// Add fields for each type
for (const [type, typeCases] of Object.entries(casesByType)) {
const emoji = getCaseEmoji(type);
const caseList = typeCases.slice(0, 5).map(c => {
const status = c.active ? '🟢' : '⚫';
const date = time(c.createdAt, TimestampStyles.ShortDate);
return `${status} **${c.caseId}** - ${c.reason.substring(0, 50)}${c.reason.length > 50 ? '...' : ''} (${date})`;
}).join('\n');
embed.addFields({
name: `${emoji} ${type.toUpperCase()} (${typeCases.length})`,
value: caseList || 'None',
inline: false
});
if (typeCases.length > 5) {
embed.addFields({
name: '\u200B',
value: `_...and ${typeCases.length - 5} more_`,
inline: false
});
}
}
return embed;
}
/**
* Display user's active warnings
*/
export function getWarningsEmbed(warnings: ModerationCase[], username: string): EmbedBuilder {
const embed = new EmbedBuilder()
.setTitle(`⚠️ Active Warnings for ${username}`)
.setColor(Colors.Yellow)
.setTimestamp();
if (warnings.length === 0) {
embed.setDescription('No active warnings.');
return embed;
}
embed.setDescription(`**Total Active Warnings:** ${warnings.length}`);
for (const warning of warnings.slice(0, 10)) {
const date = time(warning.createdAt, TimestampStyles.ShortDateTime);
embed.addFields({
name: `${warning.caseId} - ${date}`,
value: `**Moderator:** ${warning.moderatorName}\n**Reason:** ${warning.reason}`,
inline: false
});
}
if (warnings.length > 10) {
embed.addFields({
name: '\u200B',
value: `_...and ${warnings.length - 10} more warnings. Use \`/cases\` to view all._`,
inline: false
});
}
return embed;
}
/**
* Success message after warning a user
*/
export function getWarnSuccessEmbed(caseId: string, username: string, reason: string): EmbedBuilder {
return new EmbedBuilder()
.setTitle('✅ Warning Issued')
.setDescription(`**${username}** has been warned.`)
.addFields(
{ name: 'Case ID', value: caseId, inline: true },
{ name: 'Reason', value: reason, inline: false }
)
.setColor(Colors.Green)
.setTimestamp();
}
/**
* Success message after adding a note
*/
export function getNoteSuccessEmbed(caseId: string, username: string): EmbedBuilder {
return new EmbedBuilder()
.setTitle('✅ Note Added')
.setDescription(`Staff note added for **${username}**.`)
.addFields({ name: 'Case ID', value: caseId, inline: true })
.setColor(Colors.Green)
.setTimestamp();
}
/**
* Success message after clearing a warning
*/
export function getClearSuccessEmbed(caseId: string): EmbedBuilder {
return new EmbedBuilder()
.setTitle('✅ Warning Cleared')
.setDescription(`Case **${caseId}** has been resolved.`)
.setColor(Colors.Green)
.setTimestamp();
}
/**
* Error embed for moderation operations
*/
export function getModerationErrorEmbed(message: string): EmbedBuilder {
return new EmbedBuilder()
.setTitle('❌ Error')
.setDescription(message)
.setColor(Colors.Red)
.setTimestamp();
}
/**
* Warning embed to send to user via DM
*/
export function getUserWarningEmbed(
serverName: string,
reason: string,
caseId: string,
warningCount: number
): EmbedBuilder {
return new EmbedBuilder()
.setTitle('⚠️ You have received a warning')
.setDescription(`You have been warned in **${serverName}**.`)
.addFields(
{ name: 'Reason', value: reason, inline: false },
{ name: 'Case ID', value: caseId, inline: true },
{ name: 'Total Warnings', value: warningCount.toString(), inline: true }
)
.setColor(Colors.Yellow)
.setTimestamp()
.setFooter({ text: 'Please review the server rules to avoid further action.' });
}

View File

@@ -0,0 +1,198 @@
import { Collection, Message, PermissionFlagsBits } from "discord.js";
import type { TextBasedChannel } from "discord.js";
import type { PruneOptions, PruneResult, PruneProgress } from "./prune.types";
import { config } from "@/lib/config";
export class PruneService {
/**
* Delete messages from a channel based on provided options
*/
static async deleteMessages(
channel: TextBasedChannel,
options: PruneOptions,
progressCallback?: (progress: PruneProgress) => Promise<void>
): Promise<PruneResult> {
// Validate channel permissions
if (!('permissionsFor' in channel)) {
throw new Error("Cannot check permissions for this channel type");
}
const permissions = channel.permissionsFor(channel.client.user!);
if (!permissions?.has(PermissionFlagsBits.ManageMessages)) {
throw new Error("Missing permission to manage messages in this channel");
}
const { amount, userId, all } = options;
const batchSize = config.moderation.prune.batchSize;
const batchDelay = config.moderation.prune.batchDelayMs;
let totalDeleted = 0;
let totalSkipped = 0;
let requestedCount = amount || 10;
let lastMessageId: string | undefined;
let username: string | undefined;
if (all) {
// Delete all messages in batches
const estimatedTotal = await this.estimateMessageCount(channel);
requestedCount = estimatedTotal;
while (true) {
const messages = await this.fetchMessages(channel, batchSize, lastMessageId);
if (messages.size === 0) break;
const { deleted, skipped } = await this.processBatch(
channel,
messages,
userId
);
totalDeleted += deleted;
totalSkipped += skipped;
// Update progress
if (progressCallback) {
await progressCallback({
current: totalDeleted,
total: estimatedTotal
});
}
// If we deleted fewer than we fetched, we've hit old messages
if (deleted < messages.size) {
break;
}
// Get the ID of the last message for pagination
const lastMessage = Array.from(messages.values()).pop();
lastMessageId = lastMessage?.id;
// Delay to avoid rate limits
if (messages.size >= batchSize) {
await this.delay(batchDelay);
}
}
} else {
// Delete specific amount
const limit = Math.min(amount || 10, config.moderation.prune.maxAmount);
const messages = await this.fetchMessages(channel, limit, undefined);
const { deleted, skipped } = await this.processBatch(
channel,
messages,
userId
);
totalDeleted = deleted;
totalSkipped = skipped;
requestedCount = limit;
}
// Get username if filtering by user
if (userId && totalDeleted > 0) {
try {
const user = await channel.client.users.fetch(userId);
username = user.username;
} catch {
username = "Unknown User";
}
}
return {
deletedCount: totalDeleted,
requestedCount,
filtered: !!userId,
username,
skippedOld: totalSkipped > 0 ? totalSkipped : undefined
};
}
/**
* Fetch messages from a channel
*/
private static async fetchMessages(
channel: TextBasedChannel,
limit: number,
before?: string
): Promise<Collection<string, Message>> {
if (!('messages' in channel)) {
return new Collection();
}
return await channel.messages.fetch({
limit,
before
});
}
/**
* Process a batch of messages for deletion
*/
private static async processBatch(
channel: TextBasedChannel,
messages: Collection<string, Message>,
userId?: string
): Promise<{ deleted: number; skipped: number }> {
if (!('bulkDelete' in channel)) {
throw new Error("This channel type does not support bulk deletion");
}
// Filter by user if specified
let messagesToDelete = messages;
if (userId) {
messagesToDelete = messages.filter(msg => msg.author.id === userId);
}
if (messagesToDelete.size === 0) {
return { deleted: 0, skipped: 0 };
}
try {
// bulkDelete with filterOld=true will automatically skip messages >14 days
const deleted = await channel.bulkDelete(messagesToDelete, true);
const skipped = messagesToDelete.size - deleted.size;
return {
deleted: deleted.size,
skipped
};
} catch (error) {
console.error("Error during bulk delete:", error);
throw new Error("Failed to delete messages");
}
}
/**
* Estimate the total number of messages in a channel
*/
static async estimateMessageCount(channel: TextBasedChannel): Promise<number> {
if (!('messages' in channel)) {
return 0;
}
try {
// Fetch a small sample to get the oldest message
const sample = await channel.messages.fetch({ limit: 1 });
if (sample.size === 0) return 0;
// This is a rough estimate - Discord doesn't provide exact counts
// We'll return a conservative estimate
const oldestMessage = sample.first();
const channelAge = Date.now() - (oldestMessage?.createdTimestamp || Date.now());
const estimatedRate = 100; // messages per day (conservative)
const daysOld = channelAge / (1000 * 60 * 60 * 24);
return Math.max(100, Math.round(daysOld * estimatedRate));
} catch {
return 100; // Default estimate
}
}
/**
* Helper to delay execution
*/
private static delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

View File

@@ -0,0 +1,18 @@
export interface PruneOptions {
amount?: number;
userId?: string;
all?: boolean;
}
export interface PruneResult {
deletedCount: number;
requestedCount: number;
filtered: boolean;
username?: string;
skippedOld?: number;
}
export interface PruneProgress {
current: number;
total: number;
}

View File

@@ -0,0 +1,115 @@
import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, Colors } from "discord.js";
import type { PruneResult, PruneProgress } from "./prune.types";
/**
* Creates a confirmation message for prune operations
*/
export function getConfirmationMessage(
amount: number | 'all',
estimatedCount?: number
): { embeds: EmbedBuilder[], components: ActionRowBuilder<ButtonBuilder>[] } {
const isAll = amount === 'all';
const messageCount = isAll ? estimatedCount : amount;
const embed = new EmbedBuilder()
.setTitle("⚠️ Confirm Deletion")
.setDescription(
isAll
? `You are about to delete **ALL messages** in this channel.\n\n` +
`Estimated messages: **~${estimatedCount || 'Unknown'}**\n` +
`This action **cannot be undone**.`
: `You are about to delete **${amount} messages**.\n\n` +
`This action **cannot be undone**.`
)
.setColor(Colors.Orange)
.setTimestamp();
const confirmButton = new ButtonBuilder()
.setCustomId("confirm_prune")
.setLabel("Confirm")
.setStyle(ButtonStyle.Danger);
const cancelButton = new ButtonBuilder()
.setCustomId("cancel_prune")
.setLabel("Cancel")
.setStyle(ButtonStyle.Secondary);
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(confirmButton, cancelButton);
return { embeds: [embed], components: [row] };
}
/**
* Creates a progress embed for ongoing deletions
*/
export function getProgressEmbed(progress: PruneProgress): EmbedBuilder {
const percentage = Math.round((progress.current / progress.total) * 100);
return new EmbedBuilder()
.setTitle("🔄 Deleting Messages")
.setDescription(
`Progress: **${progress.current}/${progress.total}** (${percentage}%)\n\n` +
`Please wait...`
)
.setColor(Colors.Blue)
.setTimestamp();
}
/**
* Creates a success embed after deletion
*/
export function getSuccessEmbed(result: PruneResult): EmbedBuilder {
let description = `Successfully deleted **${result.deletedCount} messages**.`;
if (result.filtered && result.username) {
description = `Successfully deleted **${result.deletedCount} messages** from **${result.username}**.`;
}
if (result.skippedOld && result.skippedOld > 0) {
description += `\n\n⚠ **${result.skippedOld} messages** were older than 14 days and could not be deleted.`;
}
if (result.deletedCount < result.requestedCount && !result.skippedOld) {
description += `\n\n Only **${result.deletedCount}** messages were available to delete.`;
}
return new EmbedBuilder()
.setTitle("✅ Messages Deleted")
.setDescription(description)
.setColor(Colors.Green)
.setTimestamp();
}
/**
* Creates an error embed
*/
export function getPruneErrorEmbed(message: string): EmbedBuilder {
return new EmbedBuilder()
.setTitle("❌ Prune Failed")
.setDescription(message)
.setColor(Colors.Red)
.setTimestamp();
}
/**
* Creates a warning embed
*/
export function getPruneWarningEmbed(message: string): EmbedBuilder {
return new EmbedBuilder()
.setTitle("⚠️ Warning")
.setDescription(message)
.setColor(Colors.Yellow)
.setTimestamp();
}
/**
* Creates a cancelled embed
*/
export function getCancelledEmbed(): EmbedBuilder {
return new EmbedBuilder()
.setTitle("🚫 Deletion Cancelled")
.setDescription("Message deletion has been cancelled.")
.setColor(Colors.Grey)
.setTimestamp();
}

View File

@@ -0,0 +1,151 @@
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
import { questService } from "./quest.service";
import { userQuests } from "@/db/schema";
import { eq, and } from "drizzle-orm";
import { economyService } from "@/modules/economy/economy.service";
import { levelingService } from "@/modules/leveling/leveling.service";
// Mock dependencies
const mockFindFirst = mock();
const mockFindMany = mock();
const mockInsert = mock();
const mockUpdate = mock();
const mockDelete = mock();
const mockValues = mock();
const mockReturning = mock();
const mockSet = mock();
const mockWhere = mock();
const mockOnConflictDoNothing = mock();
// Chain setup
mockInsert.mockReturnValue({ values: mockValues });
mockValues.mockReturnValue({
onConflictDoNothing: mockOnConflictDoNothing
});
mockOnConflictDoNothing.mockReturnValue({ returning: mockReturning });
mockUpdate.mockReturnValue({ set: mockSet });
mockSet.mockReturnValue({ where: mockWhere });
mockWhere.mockReturnValue({ returning: mockReturning });
// Mock DrizzleClient
mock.module("@/lib/DrizzleClient", () => {
const createMockTx = () => ({
query: {
userQuests: { findFirst: mockFindFirst, findMany: mockFindMany },
},
insert: mockInsert,
update: mockUpdate,
delete: mockDelete,
});
return {
DrizzleClient: {
...createMockTx(),
transaction: async (cb: any) => cb(createMockTx()),
}
};
});
describe("questService", () => {
let mockModifyUserBalance: any;
let mockAddXp: any;
beforeEach(() => {
mockFindFirst.mockReset();
mockFindMany.mockReset();
mockInsert.mockClear();
mockUpdate.mockClear();
mockValues.mockClear();
mockReturning.mockClear();
mockSet.mockClear();
mockWhere.mockClear();
mockOnConflictDoNothing.mockClear();
// Setup Spies
mockModifyUserBalance = spyOn(economyService, 'modifyUserBalance').mockResolvedValue({} as any);
mockAddXp = spyOn(levelingService, 'addXp').mockResolvedValue({} as any);
});
afterEach(() => {
mockModifyUserBalance.mockRestore();
mockAddXp.mockRestore();
});
describe("assignQuest", () => {
it("should assign quest", async () => {
mockReturning.mockResolvedValue([{ userId: 1n, questId: 101 }]);
const result = await questService.assignQuest("1", 101);
expect(result).toEqual([{ userId: 1n, questId: 101 }] as any);
expect(mockInsert).toHaveBeenCalledWith(userQuests);
expect(mockValues).toHaveBeenCalledWith({
userId: 1n,
questId: 101,
progress: 0
});
});
});
describe("updateProgress", () => {
it("should update progress", async () => {
mockReturning.mockResolvedValue([{ userId: 1n, questId: 101, progress: 50 }]);
const result = await questService.updateProgress("1", 101, 50);
expect(result).toEqual([{ userId: 1n, questId: 101, progress: 50 }] as any);
expect(mockUpdate).toHaveBeenCalledWith(userQuests);
expect(mockSet).toHaveBeenCalledWith({ progress: 50 });
});
});
describe("completeQuest", () => {
it("should complete quest and grant rewards", async () => {
const mockUserQuest = {
userId: 1n,
questId: 101,
completedAt: null,
quest: {
rewards: { balance: 100, xp: 50 }
}
};
mockFindFirst.mockResolvedValue(mockUserQuest);
const result = await questService.completeQuest("1", 101);
expect(result.success).toBe(true);
expect(result.rewards.balance).toBe(100n);
expect(result.rewards.xp).toBe(50n);
// Check updates
expect(mockUpdate).toHaveBeenCalledWith(userQuests);
expect(mockSet).toHaveBeenCalledWith({ completedAt: expect.any(Date) });
// Check service calls
expect(mockModifyUserBalance).toHaveBeenCalledWith("1", 100n, 'QUEST_REWARD', expect.any(String), null, expect.anything());
expect(mockAddXp).toHaveBeenCalledWith("1", 50n, expect.anything());
});
it("should throw if quest not assigned", async () => {
mockFindFirst.mockResolvedValue(null);
expect(questService.completeQuest("1", 101)).rejects.toThrow("Quest not assigned");
});
it("should throw if already completed", async () => {
mockFindFirst.mockResolvedValue({ completedAt: new Date() });
expect(questService.completeQuest("1", 101)).rejects.toThrow("Quest already completed");
});
});
describe("getUserQuests", () => {
it("should return user quests", async () => {
const mockData = [{ questId: 1 }, { questId: 2 }];
mockFindMany.mockResolvedValue(mockData);
const result = await questService.getUserQuests("1");
expect(result).toEqual(mockData as any);
});
});
});

View File

@@ -1,13 +1,15 @@
import { userQuests } from "@/db/schema";
import { quests, userQuests, users } from "@/db/schema"; import { eq, and } from "drizzle-orm";
import { eq, and, sql } from "drizzle-orm"; import { UserError } from "@/lib/errors";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { DrizzleClient } from "@/lib/DrizzleClient";
import { economyService } from "@/modules/economy/economy.service"; import { economyService } from "@/modules/economy/economy.service";
import { levelingService } from "@/modules/leveling/leveling.service"; import { levelingService } from "@/modules/leveling/leveling.service";
import { withTransaction } from "@/lib/db";
import type { Transaction } from "@/lib/types";
export const questService = { export const questService = {
assignQuest: async (userId: string, questId: number, tx?: any) => { assignQuest: async (userId: string, questId: number, tx?: Transaction) => {
const execute = async (txFn: any) => { return await withTransaction(async (txFn) => {
return await txFn.insert(userQuests) return await txFn.insert(userQuests)
.values({ .values({
userId: BigInt(userId), userId: BigInt(userId),
@@ -16,12 +18,11 @@ export const questService = {
}) })
.onConflictDoNothing() // Ignore if already assigned .onConflictDoNothing() // Ignore if already assigned
.returning(); .returning();
}; }, tx);
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
}, },
updateProgress: async (userId: string, questId: number, progress: number, tx?: any) => { updateProgress: async (userId: string, questId: number, progress: number, tx?: Transaction) => {
const execute = async (txFn: any) => { return await withTransaction(async (txFn) => {
return await txFn.update(userQuests) return await txFn.update(userQuests)
.set({ progress: progress }) .set({ progress: progress })
.where(and( .where(and(
@@ -29,12 +30,11 @@ export const questService = {
eq(userQuests.questId, questId) eq(userQuests.questId, questId)
)) ))
.returning(); .returning();
}; }, tx);
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
}, },
completeQuest: async (userId: string, questId: number, tx?: any) => { completeQuest: async (userId: string, questId: number, tx?: Transaction) => {
const execute = async (txFn: any) => { return await withTransaction(async (txFn) => {
const userQuest = await txFn.query.userQuests.findFirst({ const userQuest = await txFn.query.userQuests.findFirst({
where: and( where: and(
eq(userQuests.userId, BigInt(userId)), eq(userQuests.userId, BigInt(userId)),
@@ -45,8 +45,8 @@ export const questService = {
} }
}); });
if (!userQuest) throw new Error("Quest not assigned"); if (!userQuest) throw new UserError("Quest not assigned");
if (userQuest.completedAt) throw new Error("Quest already completed"); if (userQuest.completedAt) throw new UserError("Quest already completed");
// Mark completed // Mark completed
await txFn.update(userQuests) await txFn.update(userQuests)
@@ -73,9 +73,7 @@ export const questService = {
} }
return { success: true, rewards: results }; return { success: true, rewards: results };
}; }, tx);
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
}, },
getUserQuests: async (userId: string) => { getUserQuests: async (userId: string) => {

View File

@@ -1,6 +1,8 @@
import { userTimers } from "@/db/schema"; import { userTimers } from "@/db/schema";
import { eq, and, lt } from "drizzle-orm"; import { eq, and, lt } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { DrizzleClient } from "@/lib/DrizzleClient";
import { AuroraClient } from "@/lib/BotClient";
import { env } from "@/lib/env";
/** /**
* The Janitor responsible for cleaning up expired ACCESS timers * The Janitor responsible for cleaning up expired ACCESS timers
@@ -16,6 +18,12 @@ export const schedulerService = {
setInterval(() => { setInterval(() => {
schedulerService.runJanitor(); schedulerService.runJanitor();
}, 60 * 1000); }, 60 * 1000);
// Terminal Update Loop (every 60s)
const { terminalService } = require("@/modules/terminal/terminal.service");
setInterval(() => {
terminalService.update();
}, 60 * 1000);
}, },
runJanitor: async () => { runJanitor: async () => {
@@ -38,9 +46,30 @@ export const schedulerService = {
console.log(`🧹 Janitor: Found ${expiredAccess.length} expired access timers.`); console.log(`🧹 Janitor: Found ${expiredAccess.length} expired access timers.`);
for (const timer of expiredAccess) { for (const timer of expiredAccess) {
// TODO: Here we would call Discord API to remove roles/overwrites.
const meta = timer.metadata as any; const meta = timer.metadata as any;
const userIdStr = timer.userId.toString();
// Specific Handling for Roles
if (timer.key.startsWith('role_')) {
try {
const roleId = meta?.roleId || timer.key.replace('role_', '');
const guildId = env.DISCORD_GUILD_ID;
if (guildId) {
// We try to fetch, if bot is not in guild or lacks perms, it will catch
const guild = await AuroraClient.guilds.fetch(guildId);
const member = await guild.members.fetch(userIdStr);
await member.roles.remove(roleId);
console.log(`👋 Removed temporary role ${roleId} from ${member.user.tag}`);
}
} catch (err) {
console.error(`Failed to remove role for user ${userIdStr}:`, err);
// We still delete the timer so we don't loop forever on a left user
}
} else {
console.log(`🚫 Revoking access for User ${timer.userId}: Key=${timer.key} (Channel: ${meta?.channelId || 'N/A'})`); console.log(`🚫 Revoking access for User ${timer.userId}: Key=${timer.key} (Channel: ${meta?.channelId || 'N/A'})`);
// TODO: Generic channel permission removal if needed
}
// Delete the timer row // Delete the timer row
await DrizzleClient.delete(userTimers) await DrizzleClient.delete(userTimers)

View File

@@ -0,0 +1,158 @@
import { TextChannel, Message, ContainerBuilder, TextDisplayBuilder, SectionBuilder, MessageFlags } from "discord.js";
import { AuroraClient } from "@/lib/BotClient";
import { DrizzleClient } from "@/lib/DrizzleClient";
import { users, transactions, lootdrops } from "@/db/schema";
import { desc } from "drizzle-orm";
import { config, saveConfig } from "@/lib/config";
export const terminalService = {
init: async (channel: TextChannel) => {
// limit to one terminal for now
if (config.terminal) {
try {
const oldChannel = await AuroraClient.channels.fetch(config.terminal.channelId) as TextChannel;
if (oldChannel) {
const oldMsg = await oldChannel.messages.fetch(config.terminal.messageId);
if (oldMsg) await oldMsg.delete();
}
} catch (e) {
// ignore if old message doesn't exist
}
}
const msg = await channel.send({ content: "🔄 Initializing Aurora Observatory..." });
config.terminal = {
channelId: channel.id,
messageId: msg.id
};
saveConfig(config);
await terminalService.update();
},
update: async () => {
if (!config.terminal) return;
try {
const channel = await AuroraClient.channels.fetch(config.terminal.channelId).catch(() => null) as TextChannel;
if (!channel) {
console.warn("Terminal channel not found");
return;
}
const message = await channel.messages.fetch(config.terminal.messageId).catch(() => null);
if (!message) {
console.warn("Terminal message not found");
return;
}
const containers = await terminalService.buildMessage();
// Components V2 requires the IsComponentsV2 flag and no content/embeds
// Disable allowedMentions to prevent pings from the dashboard
await message.edit({
content: null,
embeds: null as any,
components: containers as any,
flags: MessageFlags.IsComponentsV2,
allowedMentions: { parse: [] }
});
} catch (error) {
console.error("Failed to update terminal:", error);
}
},
buildMessage: async () => {
// 1. Data Fetching
const allUsers = await DrizzleClient.select().from(users);
const totalUsers = allUsers.length;
const totalWealth = allUsers.reduce((acc, u) => acc + (u.balance || 0n), 0n);
// 2. Leaderboards Calculation
const topLevels = [...allUsers].sort((a, b) => (b.level || 0) - (a.level || 0)).slice(0, 3);
const topWealth = [...allUsers].sort((a, b) => Number(b.balance || 0n) - Number(a.balance || 0n)).slice(0, 3);
const formatUser = (u: typeof users.$inferSelect, i: number) => {
const star = i === 0 ? "🌟" : i === 1 ? "⭐" : "✨";
return `${star} <@${u.id}>`;
};
const levelText = topLevels.map((u, i) => `> ${formatUser(u, i)} • Lvl ${u.level}`).join("\n") || "> *The sky is empty...*";
const wealthText = topWealth.map((u, i) => `> ${formatUser(u, i)}${u.balance} AU`).join("\n") || "> *The sky is empty...*";
// 3. Lootdrops Data
const activeDrops = await DrizzleClient.query.lootdrops.findMany({
where: (lootdrops, { isNull }) => isNull(lootdrops.claimedBy),
limit: 1,
orderBy: desc(lootdrops.createdAt)
});
const recentDrops = await DrizzleClient.query.lootdrops.findMany({
where: (lootdrops, { isNotNull }) => isNotNull(lootdrops.claimedBy),
limit: 1,
orderBy: desc(lootdrops.createdAt)
});
// --- CONTAINER 1: Header ---
const headerContainer = new ContainerBuilder()
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("# 🌌 AURORA OBSERVATORY"),
new TextDisplayBuilder().setContent("*Current Moon Phase: Waxing Crescent 🌒*")
);
// --- CONTAINER 2: Observation Log ---
let phenomenaContent = "";
if (activeDrops.length > 0 && activeDrops[0]) {
const drop = activeDrops[0];
phenomenaContent = `\n**SHOOTING STAR DETECTED**\nRadiance: \`${drop.rewardAmount} ${drop.currency}\`\nCoordinates: <#${drop.channelId}>\nImpact: <t:${Math.floor(drop.expiresAt!.getTime() / 1000)}:R>`;
} else if (recentDrops.length > 0 && recentDrops[0]) {
const drop = recentDrops[0];
const claimer = allUsers.find(u => u.id === drop.claimedBy);
phenomenaContent = `\n**RECENT EVENT**\nStar yielded \`${drop.rewardAmount} ${drop.currency}\` to ${claimer ? `<@${claimer.id}>` : '**Unknown**'}`;
}
const logContainer = new ContainerBuilder()
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("## 🔭 OBSERVATION LOG"),
new TextDisplayBuilder().setContent(`> **Stargazers**: \`${totalUsers}\`\n> **Astral Wealth**: \`${totalWealth.toLocaleString()} AU\`${phenomenaContent}`)
);
// --- CONTAINER 3: Leaders ---
const leaderContainer = new ContainerBuilder()
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("## ✨ CONSTELLATION LEADERS"),
new TextDisplayBuilder().setContent(`**Brightest Stars**\n${levelText}`),
new TextDisplayBuilder().setContent(`**Gilded Nebulas**\n${wealthText}`)
);
// --- CONTAINER 4: Echoes ---
const recentTx = await DrizzleClient.query.transactions.findMany({
limit: 5,
orderBy: [desc(transactions.createdAt)]
});
const activityLines = recentTx.map(tx => {
const time = Math.floor(tx.createdAt!.getTime() / 1000);
let icon = "💫";
if (tx.type.includes("LOOT")) icon = "🌠";
if (tx.type.includes("GIFT")) icon = "🌕";
const user = allUsers.find(u => u.id === tx.userId);
// the description might contain a channel id all the way at the end
const channelId = tx.description?.split(" ").pop() || "";
const text = tx.description?.replace(channelId, "<#" + channelId + ">") || "";
return `<t:${time}:F> ${icon} ${user ? `<@${user.id}>` : '**Unknown**'}: ${text}`;
});
const echoesContainer = new ContainerBuilder()
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("## 📡 COSMIC ECHOES"),
new TextDisplayBuilder().setContent(activityLines.join("\n") || "Silence...")
);
return [headerContainer, logContainer, leaderContainer, echoesContainer];
}
};

View File

@@ -1,24 +1,18 @@
import { import {
type Interaction,
ButtonInteraction, ButtonInteraction,
ModalSubmitInteraction, ModalSubmitInteraction,
StringSelectMenuInteraction, StringSelectMenuInteraction,
type Interaction,
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
StringSelectMenuBuilder,
ModalBuilder,
TextInputBuilder,
TextInputStyle,
ThreadChannel, ThreadChannel,
TextChannel, TextChannel,
EmbedBuilder
} from "discord.js"; } from "discord.js";
import { TradeService } from "./trade.service"; import { TradeService } from "./trade.service";
import { inventoryService } from "@/modules/inventory/inventory.service"; import { inventoryService } from "@/modules/inventory/inventory.service";
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds"; import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view";
const EMBED_COLOR = 0xFFD700; // Gold
export async function handleTradeInteraction(interaction: Interaction) { export async function handleTradeInteraction(interaction: Interaction) {
if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return; if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return;
@@ -91,20 +85,7 @@ async function handleLock(interaction: ButtonInteraction | StringSelectMenuInter
async function handleAddMoneyClick(interaction: Interaction) { async function handleAddMoneyClick(interaction: Interaction) {
if (!interaction.isButton()) return; if (!interaction.isButton()) return;
const modal = new ModalBuilder() const modal = getTradeMoneyModal();
.setCustomId('trade_money_modal')
.setTitle('Add Money');
const input = new TextInputBuilder()
.setCustomId('amount')
.setLabel("Amount to trade")
.setStyle(TextInputStyle.Short)
.setPlaceholder("100")
.setRequired(true);
const row = new ActionRowBuilder<TextInputBuilder>().addComponents(input);
modal.addComponents(row);
await interaction.showModal(modal); await interaction.showModal(modal);
} }
@@ -131,17 +112,11 @@ async function handleAddItemClick(interaction: ButtonInteraction, threadId: stri
const options = inventory.slice(0, 25).map(entry => ({ const options = inventory.slice(0, 25).map(entry => ({
label: `${entry.item.name} (${entry.quantity})`, label: `${entry.item.name} (${entry.quantity})`,
value: entry.item.id.toString(), value: entry.item.id.toString(),
description: `Rarity: ${entry.item.rarity}` description: `Rarity: ${entry.item.rarity} `
})); }));
const select = new StringSelectMenuBuilder() const { components } = getItemSelectMenu(options, 'trade_select_item', 'Select an item to add');
.setCustomId('trade_select_item') await interaction.reply({ content: "Select an item to add:", components, ephemeral: true });
.setPlaceholder('Select an item to add')
.addOptions(options);
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
await interaction.reply({ content: "Select an item to add:", components: [row], ephemeral: true });
} }
async function handleItemSelect(interaction: StringSelectMenuInteraction, threadId: string) { async function handleItemSelect(interaction: StringSelectMenuInteraction, threadId: string) {
@@ -175,14 +150,8 @@ async function handleRemoveItemClick(interaction: ButtonInteraction, threadId: s
value: i.id.toString(), value: i.id.toString(),
})); }));
const select = new StringSelectMenuBuilder() const { components } = getItemSelectMenu(options, 'trade_remove_item_select', 'Select an item to remove');
.setCustomId('trade_remove_item_select') await interaction.reply({ content: "Select an item to remove:", components, ephemeral: true });
.setPlaceholder('Select an item to remove')
.addOptions(options);
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
await interaction.reply({ content: "Select an item to remove:", components: [row], ephemeral: true });
} }
async function handleRemoveItemSelect(interaction: StringSelectMenuInteraction, threadId: string) { async function handleRemoveItemSelect(interaction: StringSelectMenuInteraction, threadId: string) {
@@ -207,15 +176,7 @@ export async function updateTradeDashboard(interaction: Interaction, threadId: s
// Execute Trade // Execute Trade
try { try {
await TradeService.executeTrade(threadId); await TradeService.executeTrade(threadId);
const embed = new EmbedBuilder() const embed = getTradeCompletedEmbed(session);
.setTitle("✅ Trade Completed")
.setColor("Green")
.addFields(
{ name: session.userA.username, value: formatOffer(session.userA), inline: true },
{ name: session.userB.username, value: formatOffer(session.userB), inline: true }
)
.setTimestamp();
await updateDashboardMessage(interaction, { embeds: [embed], components: [] }); await updateDashboardMessage(interaction, { embeds: [embed], components: [] });
// Notify and Schedule Cleanup // Notify and Schedule Cleanup
@@ -223,7 +184,7 @@ export async function updateTradeDashboard(interaction: Interaction, threadId: s
const successEmbed = createSuccessEmbed("Trade executed successfully. Items and funds have been transferred.", "Trade Complete"); const successEmbed = createSuccessEmbed("Trade executed successfully. Items and funds have been transferred.", "Trade Complete");
await scheduleThreadCleanup( await scheduleThreadCleanup(
interaction.channel, interaction.channel,
`🎉 Trade successful! <@${session.userA.id}> <@${session.userB.id}>\nThis thread will be deleted in 10 seconds.`, `🎉 Trade successful! < @${session.userA.id}> <@${session.userB.id}>\nThis thread will be deleted in 10 seconds.`,
10000, 10000,
successEmbed successEmbed
); );
@@ -246,33 +207,8 @@ export async function updateTradeDashboard(interaction: Interaction, threadId: s
} }
// Build Status Embed // Build Status Embed
const embed = new EmbedBuilder() const { embeds, components } = getTradeDashboard(session);
.setTitle("🤝 Trading Session") await updateDashboardMessage(interaction, { embeds, components });
.setColor(EMBED_COLOR)
.addFields(
{
name: `${session.userA.username} ${session.userA.locked ? '✅ (Ready)' : '✏️ (Editing)'}`,
value: formatOffer(session.userA),
inline: true
},
{
name: `${session.userB.username} ${session.userB.locked ? '✅ (Ready)' : '✏️ (Editing)'}`,
value: formatOffer(session.userB),
inline: true
}
)
.setFooter({ text: "Both parties must click Lock to confirm trade." });
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 updateDashboardMessage(interaction, { embeds: [embed], components: [row] });
} }
async function updateDashboardMessage(interaction: Interaction, payload: any) { async function updateDashboardMessage(interaction: Interaction, payload: any) {
@@ -300,17 +236,7 @@ async function updateDashboardMessage(interaction: Interaction, payload: any) {
} }
} }
function formatOffer(participant: any) {
let text = "";
if (participant.offer.money > 0n) {
text += `💰 ${participant.offer.money} 🪙\n`;
}
if (participant.offer.items.length > 0) {
text += participant.offer.items.map((i: any) => `- ${i.name} (x${i.quantity})`).join("\n");
}
if (text === "") text = "*Empty Offer*";
return text;
}
async function scheduleThreadCleanup(channel: ThreadChannel | TextChannel, message: string, delayMs: number = 10000, embed?: EmbedBuilder) { async function scheduleThreadCleanup(channel: ThreadChannel | TextChannel, message: string, delayMs: number = 10000, embed?: EmbedBuilder) {
try { try {
@@ -322,7 +248,7 @@ async function scheduleThreadCleanup(channel: ThreadChannel | TextChannel, messa
setTimeout(async () => { setTimeout(async () => {
try { try {
if (channel.isThread()) { if (channel.isThread()) {
console.log(`Deleting thread: ${channel.id}`); console.log(`Deleting thread: ${channel.id} `);
await channel.delete("Trade Session Ended"); await channel.delete("Trade Session Ended");
} }
} catch (e) { } catch (e) {

View File

@@ -0,0 +1,181 @@
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
import { TradeService } from "./trade.service";
import { itemTransactions } from "@/db/schema";
import { economyService } from "@/modules/economy/economy.service";
import { inventoryService } from "@/modules/inventory/inventory.service";
// Mock dependencies
const mockInsert = mock();
const mockValues = mock();
mockInsert.mockReturnValue({ values: mockValues });
// Mock DrizzleClient
mock.module("@/lib/DrizzleClient", () => {
return {
DrizzleClient: {
transaction: async (cb: any) => {
const txMock = {
insert: mockInsert, // For transaction logs
};
return cb(txMock);
}
},
};
});
describe("TradeService", () => {
const userA = { id: "1", username: "UserA" };
const userB = { id: "2", username: "UserB" };
let mockModifyUserBalance: any;
let mockAddItem: any;
let mockRemoveItem: any;
beforeEach(() => {
mockInsert.mockClear();
mockValues.mockClear();
// Clear sessions
(TradeService as any).sessions.clear();
// Spies
mockModifyUserBalance = spyOn(economyService, 'modifyUserBalance').mockResolvedValue({} as any);
mockAddItem = spyOn(inventoryService, 'addItem').mockResolvedValue({} as any);
mockRemoveItem = spyOn(inventoryService, 'removeItem').mockResolvedValue({} as any);
});
afterEach(() => {
mockModifyUserBalance.mockRestore();
mockAddItem.mockRestore();
mockRemoveItem.mockRestore();
});
describe("createSession", () => {
it("should create a new session", () => {
const session = TradeService.createSession("thread1", userA, userB);
expect(session.threadId).toBe("thread1");
expect(session.state).toBe("NEGOTIATING");
expect(session.userA.id).toBe("1");
expect(session.userB.id).toBe("2");
expect(TradeService.getSession("thread1")).toBe(session);
});
});
describe("updateMoney", () => {
it("should update money offer", () => {
TradeService.createSession("thread1", userA, userB);
TradeService.updateMoney("thread1", "1", 100n);
const session = TradeService.getSession("thread1");
expect(session?.userA.offer.money).toBe(100n);
});
it("should unlock participants when offer changes", () => {
const session = TradeService.createSession("thread1", userA, userB);
session.userA.locked = true;
session.userB.locked = true;
TradeService.updateMoney("thread1", "1", 100n);
expect(session.userA.locked).toBe(false);
expect(session.userB.locked).toBe(false);
});
it("should throw if not in trade", () => {
TradeService.createSession("thread1", userA, userB);
expect(() => TradeService.updateMoney("thread1", "3", 100n)).toThrow("User not in trade");
});
});
describe("addItem", () => {
it("should add item to offer", () => {
TradeService.createSession("thread1", userA, userB);
TradeService.addItem("thread1", "1", { id: 10, name: "Sword" }, 1n);
const session = TradeService.getSession("thread1");
expect(session?.userA.offer.items).toHaveLength(1);
expect(session?.userA.offer.items[0]).toEqual({ id: 10, name: "Sword", quantity: 1n });
});
it("should stack items if already offered", () => {
TradeService.createSession("thread1", userA, userB);
TradeService.addItem("thread1", "1", { id: 10, name: "Sword" }, 1n);
TradeService.addItem("thread1", "1", { id: 10, name: "Sword" }, 2n);
const session = TradeService.getSession("thread1");
expect(session?.userA.offer.items[0]!.quantity).toBe(3n);
});
});
describe("removeItem", () => {
it("should remove item from offer", () => {
const session = TradeService.createSession("thread1", userA, userB);
session.userA.offer.items.push({ id: 10, name: "Sword", quantity: 1n });
TradeService.removeItem("thread1", "1", 10);
expect(session.userA.offer.items).toHaveLength(0);
});
});
describe("toggleLock", () => {
it("should toggle lock status", () => {
TradeService.createSession("thread1", userA, userB);
const locked1 = TradeService.toggleLock("thread1", "1");
expect(locked1).toBe(true);
const locked2 = TradeService.toggleLock("thread1", "1");
expect(locked2).toBe(false);
});
});
describe("executeTrade", () => {
it("should execute trade successfully", async () => {
const session = TradeService.createSession("thread1", userA, userB);
// Setup offers
session.userA.offer.money = 100n;
session.userA.offer.items = [{ id: 10, name: "Sword", quantity: 1n }];
session.userB.offer.money = 50n; // B paying 50 back? Or just swap.
session.userB.offer.items = [];
// Lock both
session.userA.locked = true;
session.userB.locked = true;
await TradeService.executeTrade("thread1");
expect(session.state).toBe("COMPLETED");
// Verify Money Transfer A -> B (100)
expect(mockModifyUserBalance).toHaveBeenCalledWith("1", -100n, 'TRADE_OUT', expect.any(String), "2", expect.anything());
expect(mockModifyUserBalance).toHaveBeenCalledWith("2", 100n, 'TRADE_IN', expect.any(String), "1", expect.anything());
// Verify Money Transfer B -> A (50)
expect(mockModifyUserBalance).toHaveBeenCalledWith("2", -50n, 'TRADE_OUT', expect.any(String), "1", expect.anything());
expect(mockModifyUserBalance).toHaveBeenCalledWith("1", 50n, 'TRADE_IN', expect.any(String), "2", expect.anything());
// Verify Item Transfer A -> B (Sword)
expect(mockRemoveItem).toHaveBeenCalledWith("1", 10, 1n, expect.anything());
expect(mockAddItem).toHaveBeenCalledWith("2", 10, 1n, expect.anything());
// Verify DB Logs (Item Transaction)
// 2 calls (sender log, receiver log) for 1 item
expect(mockInsert).toHaveBeenCalledTimes(2);
expect(mockInsert).toHaveBeenCalledWith(itemTransactions);
});
it("should throw if not locked", async () => {
const session = TradeService.createSession("thread1", userA, userB);
session.userA.locked = true;
// B not locked
expect(TradeService.executeTrade("thread1")).rejects.toThrow("Both players must accept");
});
});
});

View File

@@ -3,6 +3,7 @@ import { DrizzleClient } from "@/lib/DrizzleClient";
import { economyService } from "@/modules/economy/economy.service"; import { economyService } from "@/modules/economy/economy.service";
import { inventoryService } from "@/modules/inventory/inventory.service"; import { inventoryService } from "@/modules/inventory/inventory.service";
import { itemTransactions } from "@/db/schema"; import { itemTransactions } from "@/db/schema";
import type { Transaction } from "@/lib/types";
export class TradeService { export class TradeService {
private static sessions = new Map<string, TradeSession>(); private static sessions = new Map<string, TradeSession>();
@@ -136,7 +137,7 @@ export class TradeService {
this.endSession(threadId); this.endSession(threadId);
} }
private static async processTransfer(tx: any, from: TradeParticipant, to: TradeParticipant, threadId: string) { private static async processTransfer(tx: Transaction, from: TradeParticipant, to: TradeParticipant, threadId: string) {
// 1. Money // 1. Money
if (from.offer.money > 0n) { if (from.offer.money > 0n) {
await economyService.modifyUserBalance( await economyService.modifyUserBalance(

View File

@@ -0,0 +1,85 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ModalBuilder, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle } from "discord.js";
import { createBaseEmbed } from "@lib/embeds";
import type { TradeSession, TradeParticipant } from "./trade.types";
const EMBED_COLOR = 0xFFD700; // Gold
function formatOffer(participant: TradeParticipant) {
let text = "";
if (participant.offer.money > 0n) {
text += `💰 ${participant.offer.money} 🪙\n`;
}
if (participant.offer.items.length > 0) {
text += participant.offer.items.map((i) => `- ${i.name} (x${i.quantity})`).join("\n");
}
if (text === "") text = "*Empty Offer*";
return text;
}
export function getTradeDashboard(session: TradeSession) {
const embed = createBaseEmbed("🤝 Trading Session", undefined, EMBED_COLOR)
.addFields(
{
name: `${session.userA.username} ${session.userA.locked ? '✅ (Ready)' : '✏️ (Editing)'}`,
value: formatOffer(session.userA),
inline: true
},
{
name: `${session.userB.username} ${session.userB.locked ? '✅ (Ready)' : '✏️ (Editing)'}`,
value: formatOffer(session.userB),
inline: true
}
)
.setFooter({ text: "Both parties must click Lock to confirm trade." });
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),
);
return { embeds: [embed], components: [row] };
}
export function getTradeCompletedEmbed(session: TradeSession) {
const embed = createBaseEmbed("✅ Trade Completed", undefined, "Green")
.addFields(
{ name: session.userA.username, value: formatOffer(session.userA), inline: true },
{ name: session.userB.username, value: formatOffer(session.userB), inline: true }
)
.setTimestamp();
return embed;
}
export function getTradeMoneyModal() {
const modal = new ModalBuilder()
.setCustomId('trade_money_modal')
.setTitle('Add Money');
const input = new TextInputBuilder()
.setCustomId('amount')
.setLabel("Amount to trade")
.setStyle(TextInputStyle.Short)
.setPlaceholder("100")
.setRequired(true);
const row = new ActionRowBuilder<TextInputBuilder>().addComponents(input);
modal.addComponents(row);
return modal;
}
export function getItemSelectMenu(items: { label: string, value: string, description?: string }[], customId: string, placeholder: string) {
const select = new StringSelectMenuBuilder()
.setCustomId(customId)
.setPlaceholder(placeholder)
.addOptions(items);
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
return { components: [row] };
}

View File

@@ -0,0 +1,120 @@
import { ButtonInteraction, MessageFlags } from "discord.js";
import { config } from "@/lib/config";
import { getEnrollmentErrorEmbed, getEnrollmentSuccessMessage } from "./enrollment.view";
import { classService } from "@modules/class/class.service";
import { userService } from "@modules/user/user.service";
import { sendWebhookMessage } from "@/lib/webhookUtils";
export async function handleEnrollmentInteraction(interaction: ButtonInteraction) {
if (!interaction.inCachedGuild()) {
await interaction.reply({ content: "This action can only be performed in a server.", flags: MessageFlags.Ephemeral });
return;
}
const { studentRole, visitorRole } = config;
if (!studentRole || !visitorRole) {
await interaction.reply({
...getEnrollmentErrorEmbed("No student or visitor role configured for enrollment.", "Configuration Error"),
flags: MessageFlags.Ephemeral
});
return;
}
try {
// 1. Ensure user exists in DB and check current enrollment status
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
// Check DB enrollment
if (user.class) {
await interaction.reply({
...getEnrollmentErrorEmbed("You are already enrolled in a class.", "Enrollment Failed"),
flags: MessageFlags.Ephemeral
});
return;
}
const member = interaction.member;
// Check Discord role enrollment (Double safety)
if (member.roles.cache.has(studentRole)) {
await interaction.reply({
...getEnrollmentErrorEmbed("You already have the student role.", "Enrollment Failed"),
flags: MessageFlags.Ephemeral
});
return;
}
// 2. Get available classes
const allClasses = await classService.getAllClasses();
const validClasses = allClasses.filter(c => c.roleId);
if (validClasses.length === 0) {
await interaction.reply({
...getEnrollmentErrorEmbed("No classes with specified roles found in database.", "Configuration Error"),
flags: MessageFlags.Ephemeral
});
return;
}
// 3. Pick random class
const selectedClass = validClasses[Math.floor(Math.random() * validClasses.length)]!;
const classRoleId = selectedClass.roleId!;
// Check if the role exists in the guild
const classRole = interaction.guild.roles.cache.get(classRoleId);
if (!classRole) {
await interaction.reply({
...getEnrollmentErrorEmbed(`The configured role ID \`${classRoleId}\` for class **${selectedClass.name}** does not exist in this server.`, "Configuration Error"),
flags: MessageFlags.Ephemeral
});
return;
}
// 4. Perform Enrollment Actions
await member.roles.remove(visitorRole);
await member.roles.add(studentRole);
await member.roles.add(classRole);
// Persist to DB
await classService.assignClass(user.id.toString(), selectedClass.id);
await interaction.reply({
...getEnrollmentSuccessMessage(classRole.name),
flags: MessageFlags.Ephemeral
});
// 5. Send Welcome Message (if configured)
if (config.welcomeChannelId) {
const welcomeChannel = interaction.guild.channels.cache.get(config.welcomeChannelId);
if (welcomeChannel && welcomeChannel.isTextBased()) {
const rawMessage = config.welcomeMessage || "Welcome to Aurora, {user}! You have been enrolled as a **{class}**.";
const processedMessage = rawMessage
.replace(/{user}/g, member.toString())
.replace(/{username}/g, member.user.username)
.replace(/{class}/g, selectedClass.name)
.replace(/{guild}/g, interaction.guild.name);
let payload;
try {
payload = JSON.parse(processedMessage);
} catch {
payload = processedMessage;
}
// Fire and forget webhook
sendWebhookMessage(welcomeChannel, payload, interaction.client.user, "New Student Enrollment")
.catch((err: any) => console.error("Failed to send welcome message:", err));
}
}
} catch (error) {
console.error("Enrollment error:", error);
await interaction.reply({
...getEnrollmentErrorEmbed("An unexpected error occurred during enrollment. Please contact an administrator.", "System Error"),
flags: MessageFlags.Ephemeral
});
}
}

View File

@@ -0,0 +1,12 @@
import { createErrorEmbed } from "@/lib/embeds";
export function getEnrollmentErrorEmbed(message: string, title: string = "Enrollment Failed") {
const embed = createErrorEmbed(message, title);
return { embeds: [embed] };
}
export function getEnrollmentSuccessMessage(roleName: string) {
return {
content: `🎉 You have been successfully enrolled! You received the **${roleName}** role.`
};
}

View File

@@ -0,0 +1,99 @@
import { describe, it, expect, mock, beforeEach } from "bun:test";
import { userService } from "./user.service";
// Define mock functions outside so we can control them in tests
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 });
// Mock DrizzleClient
mock.module("@/lib/DrizzleClient", () => {
return {
DrizzleClient: {
query: {
users: {
findFirst: mockFindFirst,
},
},
insert: mockInsert,
update: mockUpdate,
delete: mockDelete,
transaction: async (cb: any) => {
// Pass the mock client itself as the transaction object
// This simplifies things as we use the same structure for tx and client
return cb({
query: {
users: {
findFirst: mockFindFirst,
},
},
insert: mockInsert,
update: mockUpdate,
delete: mockDelete,
});
}
},
};
});
describe("userService", () => {
beforeEach(() => {
mockFindFirst.mockReset();
mockInsert.mockClear();
mockValues.mockClear();
mockReturning.mockClear();
});
describe("getUserById", () => {
it("should return a user when found", async () => {
const mockUser = { id: 123n, username: "testuser", class: null };
mockFindFirst.mockResolvedValue(mockUser);
const result = await userService.getUserById("123");
expect(result).toEqual(mockUser as any);
expect(mockFindFirst).toHaveBeenCalledTimes(1);
});
it("should return undefined when user not found", async () => {
mockFindFirst.mockResolvedValue(undefined);
const result = await userService.getUserById("999");
expect(result).toBeUndefined();
expect(mockFindFirst).toHaveBeenCalledTimes(1);
});
});
describe("createUser", () => {
it("should create and return a new user", async () => {
const newUser = { id: 456n, username: "newuser", classId: null };
mockReturning.mockResolvedValue([newUser]);
const result = await userService.createUser("456", "newuser");
expect(result).toEqual(newUser as any);
expect(mockInsert).toHaveBeenCalledTimes(1);
expect(mockValues).toHaveBeenCalledWith({
id: 456n,
username: "newuser",
classId: undefined
});
});
});
});

View File

@@ -1,5 +1,5 @@
import { users } from "@/db/schema"; import { users } from "@/db/schema";
import { eq, sql } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { DrizzleClient } from "@/lib/DrizzleClient";
export const userService = { export const userService = {

View File

@@ -1,5 +1,5 @@
import { userTimers } from "@/db/schema"; import { userTimers } from "@/db/schema";
import { eq, and, lt } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { DrizzleClient } from "@/lib/DrizzleClient";
export type TimerType = 'COOLDOWN' | 'EFFECT' | 'ACCESS'; export type TimerType = 'COOLDOWN' | 'EFFECT' | 'ACCESS';

Some files were not shown because too many files have changed in this diff Show More