47 Commits

Author SHA1 Message Date
syntaxbullet
894cad91a8 feat: Implement secure static file serving with path traversal protection and XSS prevention for template titles. 2026-01-07 12:51:08 +01:00
syntaxbullet
2a1c4e65ae feat(web): implement web server foundation 2026-01-07 12:40:21 +01:00
syntaxbullet
022f748517 feat: implement agent workflows for ticket creation, development, and code review. 2026-01-07 12:12:57 +01:00
syntaxbullet
ca392749e3 refactor: replace cleanup service with focused temp role service and fix daily streaks 2026-01-07 11:04:34 +01:00
syntaxbullet
4a1e72c5f3 chore: add additional stats to terminal 2026-01-06 21:05:51 +01:00
syntaxbullet
d29a1ec2b7 chore: update terminal adding nicer graphics 2026-01-06 20:51:39 +01:00
syntaxbullet
1dd269bf2f chore: update terminal service 2026-01-06 20:36:26 +01:00
syntaxbullet
69186ff3e9 chore: add more options to cleanup command 2026-01-06 19:44:18 +01:00
syntaxbullet
b989e807dc feat: Add /cleanup admin command and enhance lootdrop cleanup service to optionally include claimed items. 2026-01-06 19:27:41 +01:00
syntaxbullet
2e6bdec38c refactor: switch Drizzle ORM from postgres-js to bun-sql driver. 2026-01-06 18:52:25 +01:00
syntaxbullet
a9d5c806ad feat: Migrate Drizzle ORM to postgres.js, exclude test files from command loading, and adjust postgres dependency type. 2026-01-06 18:46:30 +01:00
syntaxbullet
6f73178375 feat: Bind docker-compose database and server ports to localhost. 2026-01-06 18:37:42 +01:00
syntaxbullet
dd62336571 fix(test): resolve typescript undefined errors in inventory service tests 2026-01-06 18:25:18 +01:00
syntaxbullet
8280111b66 feat(inventory): implement item name autocomplete with rarity and case-insensitive search 2026-01-06 18:24:15 +01:00
syntaxbullet
34347f0c63 feat: centralized constants and enums for project-wide use 2026-01-06 18:15:52 +01:00
syntaxbullet
c807fd4fd0 test: fix lint errors in moderation service tests 2026-01-06 18:05:05 +01:00
syntaxbullet
47b980eff1 feat: add moderation unit tests and refactor warning logic 2026-01-06 18:03:36 +01:00
syntaxbullet
bc89ddf7c0 feat: implement scheduled cleanup job for expired data 2026-01-06 17:44:08 +01:00
syntaxbullet
606d83a7ae feat: add health check command and tracking 2026-01-06 17:30:55 +01:00
syntaxbullet
3351295bdc feat: add database indexes for performance optimization 2026-01-06 17:26:34 +01:00
syntaxbullet
92cb048a7a test: fix mock leakage in db tests 2026-01-06 17:22:43 +01:00
syntaxbullet
6ead0c0393 feat: implement graceful shutdown handling 2026-01-06 17:21:50 +01:00
syntaxbullet
278ef4b6b0 fix: Normalize exam and cooldown dates to the start of the day for consistent calculations. 2026-01-05 17:36:53 +01:00
syntaxbullet
9a32ab298d feat: Implement a net worth leaderboard by aggregating user balance and inventory item values. 2026-01-05 16:40:26 +01:00
syntaxbullet
a2596d4124 docs: Add command reference and database schema documentation. 2026-01-05 13:13:46 +01:00
syntaxbullet
fbc8952e0a docs: Add guides for lootbox creation and configuration options. 2026-01-05 13:07:36 +01:00
syntaxbullet
d0b4cb80de feat: Add user existence checks to economy commands and refactor trade service to expose sessions for testing. 2026-01-05 12:57:22 +01:00
syntaxbullet
599684cde8 feat: Add lootbox item type with weighted rewards and dedicated UI for item usage results. 2026-01-05 12:52:34 +01:00
syntaxbullet
5606fb6e2f fix: Clarify daily claim cooldown message for daily claims. 2026-01-05 12:17:23 +01:00
syntaxbullet
fb260c5beb feat: Set daily claim cooldown to next UTC midnight and reset streak to 1 if missed by over 24 hours. 2026-01-05 12:10:41 +01:00
syntaxbullet
a227e5db59 feat: Implement graphical lootdrop cards for lootdrop and claimed messages. 2025-12-24 23:13:16 +01:00
syntaxbullet
66d5145885 docs: add guide for standard module structure patterns 2025-12-24 22:26:14 +01:00
syntaxbullet
2412098536 refactor(modules): standardize error handling in interaction handlers 2025-12-24 22:26:12 +01:00
syntaxbullet
d0c48188b9 refactor(core): centralize interaction error handling and organize routes 2025-12-24 22:26:10 +01:00
syntaxbullet
1523a392c2 refactor: add leveling view layer
Create leveling.view.ts with UI logic extracted from leaderboard command:
- getLeaderboardEmbed() for leaderboard display (XP and Balance)
- getMedalEmoji() helper for ranking medals (🥇🥈🥉)
- formatLeaderEntry() helper for entry formatting with null safety

Updated leaderboard.ts to use view functions instead of inline formatting.
2025-12-24 22:09:04 +01:00
syntaxbullet
7d6912cdee refactor: add quest view layer
Create quest.view.ts with UI logic extracted from quests command:
- getQuestListEmbed() for quest log display
- formatQuestRewards() helper for reward formatting
- getQuestStatus() helper for status display

Updated quests.ts to use view functions instead of inline embed building.
2025-12-24 22:08:55 +01:00
syntaxbullet
947bbc10d6 refactor: add inventory view layer
Create inventory.view.ts with UI logic extracted from commands:
- getInventoryEmbed() for inventory display
- getItemUseResultEmbed() for item use results

Updated commands with proper type safety:
- inventory.ts: add null check, convert user.id to string
- use.ts: add null check, convert user.id to string

Improves separation of concerns and type safety.
2025-12-24 22:08:51 +01:00
syntaxbullet
2933eaeafc refactor: convert TradeService to object export pattern
Convert from class-based to object-based export for consistency with
other services (economy, inventory, quest, etc).

Changes:
- Move sessions Map and helper functions to module scope
- Convert static methods to object properties
- Update executeTrade to use withTransaction helper
- Update all imports from TradeService to tradeService

Updated files:
- trade.service.ts (main refactor)
- trade.interaction.ts (update usages)
- trade.ts command (update import and usage)

All tests passing with no breaking changes.
2025-12-24 21:57:30 +01:00
syntaxbullet
77d3fafdce refactor: standardize transaction pattern in class.service.ts
Replace manual transaction handling with withTransaction helper pattern
for consistency with other services (economy, inventory, quest, leveling).

Also fix validation bug in modifyClassBalance:
- Before: if (balance < amount)
- After: if (balance + amount < 0n)

This correctly validates negative amounts (debits) to prevent balances
going below zero.
2025-12-24 21:57:14 +01:00
syntaxbullet
10a760edf4 refactor: replace console.* with logger in core lib files
Update loaders, handlers, and BotClient to use centralized logger:
- CommandLoader.ts and EventLoader.ts
- AutocompleteHandler.ts, CommandHandler.ts, ComponentInteractionHandler.ts
- BotClient.ts

Provides consistent formatting across all core library logging.
2025-12-24 21:56:50 +01:00
syntaxbullet
a53d30a0b3 feat: add centralized logger utility
Add logger.ts with consistent emoji prefixes for all log levels:
- info, success, warn, error, debug

This provides a single source of truth for logging and enables
future extensibility for file logging or external services.
2025-12-24 21:56:29 +01:00
syntaxbullet
5420653b2b refactor: Extract interaction handling logic into dedicated ComponentInteractionHandler, AutocompleteHandler, and CommandHandler classes. 2025-12-24 21:38:01 +01:00
syntaxbullet
f13ef781b6 refactor(lib): simplify BotClient using loader classes
- Delegate command loading to CommandLoader
- Delegate event loading to EventLoader
- Remove readCommandsRecursively and readEventsRecursively methods
- Remove isValidCommand and isValidEvent methods (moved to loaders)
- Add summary logging with load statistics
- Export Client class for better type safety
- Reduce file from 188 to 97 lines (48% reduction)

BREAKING CHANGE: Client class is now exported as a named export
2025-12-24 21:32:23 +01:00
syntaxbullet
82a4281f9b feat(lib): extract EventLoader from BotClient
- Create dedicated EventLoader class for event loading logic
- Implement recursive directory scanning
- Add event validation and registration (once vs on)
- Improve error handling with structured results
- Enable better testability and separation of concerns
2025-12-24 21:32:15 +01:00
syntaxbullet
0dbc532c7e feat(lib): extract CommandLoader from BotClient
- Create dedicated CommandLoader class for command loading logic
- Implement recursive directory scanning
- Add category extraction from file paths
- Add command validation and config-based filtering
- Improve error handling with structured results
- Enable better testability and separation of concerns
2025-12-24 21:32:08 +01:00
syntaxbullet
953942f563 feat(lib): add loader types for command/event loading
- Add LoadResult interface to track loading statistics
- Add LoadError interface for structured error reporting
- Foundation for modular loader architecture
2025-12-24 21:31:54 +01:00
syntaxbullet
6334275d02 refactor: modernize transaction patterns and improve type safety
- Refactored user.service.ts to use withTransaction() helper
- Added 14 comprehensive unit tests for user.service.ts
- Removed duplicate user creation in interactionCreate.ts
- Improved type safety in interaction.routes.ts
2025-12-24 21:23:58 +01:00
97 changed files with 5084 additions and 977 deletions

View File

@@ -0,0 +1,57 @@
---
description: Create a new Ticket
---
### Role
You are a Senior Technical Product Manager and Lead Engineer. Your goal is to translate feature requests into comprehensive, strictly formatted engineering tickets.
### Task
When I ask you to "scope a feature" or "create a ticket" for a specific functionality:
1. Analyze the request for technical implications, edge cases, and architectural fit.
2. Generate a new Markdown file.
3. Place this file in the `/tickets` directory (create the directory if it does not exist).
### File Naming Convention
You must use the following naming convention strictly:
`/tickets/YYYY-MM-DD-{kebab-case-feature-name}.md`
*Example:* `/tickets/2024-10-12-user-authentication-flow.md`
### File Content Structure
The markdown file must adhere to the following template exactly. Do not skip sections. If a section is not applicable, write "N/A" but explain why.
```markdown
# [Ticket ID]: [Feature Title]
**Status:** Draft
**Created:** [YYYY-MM-DD]
**Tags:** [comma, separated, tags]
## 1. Context & User Story
* **As a:** [Role]
* **I want to:** [Action]
* **So that:** [Benefit/Value]
## 2. Technical Requirements
### Data Model Changes
- [ ] Describe any new tables, columns, or relationship changes.
- [ ] SQL migration required? (Yes/No)
### API / Interface
- [ ] Define endpoints (method, path) or function signatures.
- [ ] Payload definition (JSON structure or Types).
## 3. Constraints & Validations (CRITICAL)
*This section must be exhaustive. Do not be vague.*
- **Input Validation:** (e.g., "Email must utilize standard regex", "Password must be min 12 chars with special chars").
- **System Constraints:** (e.g., "Image upload max size 5MB", "Request timeout 30s").
- **Business Logic Guardrails:** (e.g., "User cannot upgrade if balance < $0").
## 4. Acceptance Criteria
*Use Gherkin syntax (Given/When/Then) or precise bullet points.*
1. [ ] Criteria 1
2. [ ] Criteria 2
## 5. Implementation Plan
- [ ] Step 1: ...
- [ ] Step 2: ...

View File

@@ -0,0 +1,53 @@
---
description: Review the most recent changes critically.
---
### Role
You are a Lead Security Engineer and Senior QA Automator. Your persona is **"The Hostile Reviewer."**
* **Mindset:** You do not trust the code. You assume it contains bugs, security flaws, and logic gaps.
* **Goal:** Your objective is to reject the most recent git changes by finding legitimate issues. If you cannot find issues, only then do you approve.
### Phase 1: The Security & Logic Audit
Analyze the code changes for specific vulnerabilities. Do not summarize what the code does; look for what it *does wrong*.
1. **TypeScript Strictness:**
* Flag any usage of `any`.
* Flag any use of non-null assertions (`!`) unless strictly guarded.
* Flag forced type casting (`as UnknownType`) without validation.
2. **Bun/Runtime Specifics:**
* Check for unhandled Promises (floating promises).
* Ensure environment variables are not hardcoded.
3. **Security Vectors:**
* **Injection:** Check SQL/NoSQL queries for concatenation.
* **Sanitization:** Are inputs from the generic request body validated against the schema defined in the Ticket?
* **Auth:** Are sensitive routes actually protected by middleware?
### Phase 2: Test Quality Verification
Do not just check if tests pass. Check if the tests are **valid**.
1. **The "Happy Path" Trap:** If the tests only check for success (status 200), **FAIL** the review.
2. **Edge Case Coverage:**
* Did the code handle the *Constraints & Validations* listed in the original ticket?
* *Example:* If the ticket says "Max 5MB upload", is there a test case for a 5.1MB file?
3. **Mocking Integrity:** Are mocks too permissive? (e.g., Mocking a function to always return `true` regardless of input).
### Phase 3: The Verdict
Output your review in the following strict format:
---
# 🛡️ Code Review Report
**Ticket ID:** [Ticket Name]
**Verdict:** [🔴 REJECT / 🟢 APPROVE]
## 🚨 Critical Issues (Must Fix)
*List logic bugs, security risks, or failing tests.*
1. ...
2. ...
## ⚠️ Suggestions (Refactoring)
*List code style improvements, variable naming, or DRY opportunities.*
1. ...
## 🧪 Test Coverage Gap Analysis
*List specific scenarios that are NOT currently tested but should be.*
- [ ] Scenario: ...

View File

@@ -0,0 +1,50 @@
---
description: Pick a Ticket and work on it.
---
### Role
You are an Autonomous Senior Software Engineer specializing in TypeScript and Bun. You are responsible for the full lifecycle of feature implementation: selection, coding, testing, verification, and closure.
### Phase 1: Triage & Selection
1. **Scan:** Read all files in the `/tickets` directory.
2. **Filter:** Ignore tickets marked `Status: Done` or `Status: Archived`.
3. **Prioritize:** Select a single ticket based on the following hierarchy:
* **Tags:** `Critical` > `High Priority` > `Bug` > `Feature`.
* **Age:** Oldest created date first (FIFO).
4. **Announce:** Explicitly state: "I am picking ticket: [Ticket ID/Name] because [Reason]."
### Phase 2: Setup (Non-Destructive)
1. **Branching:** Create a new git branch based on the ticket name.
* *Format:* `feat/{ticket-kebab-name}` or `fix/{ticket-kebab-name}`.
* *Command:* `git checkout -b feat/user-auth-flow`.
2. **Context:** Read the selected ticket markdown file thoroughly, paying special attention to "Constraints & Validations."
### Phase 3: Implementation & Testing (The Loop)
*Iterate until the requirements are met.*
1. **Write Code:** Implement the feature or fix using TypeScript.
2. **Tightened Testing:**
* You must create or update test files (`*.test.ts` or `*.spec.ts`).
* **Requirement:** Tests must cover happy paths AND the edge cases defined in the ticket's "Constraints" section.
* *Mocking:* Mock external dependencies where appropriate to ensure isolation.
3. **Type Safety Check:**
* Run: `bun x tsc --noEmit`
* **CRITICAL:** If there are ANY TypeScript errors, you must fix them immediately. Do not proceed.
4. **Runtime Verification:**
* Run: `bun test`
* Ensure all tests pass. If a test fails, analyze the stack trace, fix the implementation, and rerun.
### Phase 4: Self-Review & Clean Up
Before declaring the task finished, perform a self-review:
1. **Linting:** Check for unused variables, any types, or console logs.
2. **Refactor:** Ensure code is DRY (Don't Repeat Yourself) and strictly typed.
3. **Ticket Update:**
* Modify the Markdown ticket file.
* Change `Status: Draft` to `Status: In Review` or `Status: Done`.
* Add a new section at the bottom: `## Implementation Notes` listing the specific files changed.
### Phase 5: Handover
Only when `bun x tsc` and `bun test` pass with 0 errors:
1. Commit the changes with a semantic message (e.g., `feat: implement user auth logic`).
2. Present a summary of the work done and ask for a human code review.

1
.gitignore vendored
View File

@@ -44,3 +44,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
src/db/data src/db/data
src/db/log src/db/log
scratchpad/ scratchpad/

View File

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

View File

@@ -7,7 +7,7 @@ services:
- POSTGRES_PASSWORD=${DB_PASSWORD} - POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME} - POSTGRES_DB=${DB_NAME}
ports: ports:
- "${DB_PORT}:5432" - "127.0.0.1:${DB_PORT}:5432"
volumes: volumes:
- ./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
@@ -46,7 +46,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
working_dir: /app working_dir: /app
ports: ports:
- "4983:4983" - "127.0.0.1:4983:4983"
volumes: volumes:
- .:/app - .:/app
- /app/node_modules - /app/node_modules

63
docs/COMMANDS.md Normal file
View File

@@ -0,0 +1,63 @@
# Command Reference
This document lists all available slash commands in Aurora, categorized by their function.
## Economy
| Command | Description | Options | Permissions |
|---|---|---|---|
| `/balance` | View your or another user's balance. | `user` (Optional): The user to check. | Everyone |
| `/daily` | Claim your daily currency reward and streak bonus. | None | Everyone |
| `/pay` | Transfer currency to another user. | `user` (Required): Recipient.<br>`amount` (Required): Amount to send. | Everyone |
| `/trade` | Start a trade session with another user. | `user` (Required): The user to trade with. | Everyone |
| `/exam` | Take your weekly exam to earn rewards based on XP gain. | None | Everyone |
## Inventory & Items
| Command | Description | Options | Permissions |
|---|---|---|---|
| `/inventory` | View your or another user's inventory. | `user` (Optional): The user to check. | Everyone |
| `/use` | Use an item from your inventory. | `item` (Required): The item to use (Autocomplete). | Everyone |
## User & Social
| Command | Description | Options | Permissions |
|---|---|---|---|
| `/profile` | View your or another user's Student ID card. | `user` (Optional): The user to view. | Everyone |
| `/leaderboard` | View top players. | `type` (Required): 'Level / XP' or 'Balance'. | Everyone |
| `/feedback` | Submit feedback, bug reports, or suggestions. | None | Everyone |
| `/quests` | View your active quests. | None | Everyone |
## Admin
> [!IMPORTANT]
> These commands require Administrator permissions or specific roles as configured.
### General Management
| Command | Description | Options |
|---|---|---|
| `/config` | Manage bot configuration. | `group` (Req): Section.<br>`key` (Req): Setting.<br>`value` (Req): New value. |
| `/refresh` | Refresh commands or configuration cache. | `type`: 'Commands' or 'Config'. |
| `/update` | Update the bot from the repository. | None |
| `/features` | Enable/Disable system features. | `feature` (Req): Feature name.<br>`enabled` (Req): True/False. |
| `/webhook` | Send a message via webhook. | `payload` (Req): JSON payload. |
### Moderation
| Command | Description | Options |
|---|---|---|
| `/warn` | Warn a user. | `user` (Req): Target.<br>`reason` (Req): Reason. |
| `/warnings` | View active warnings for a user. | `user` (Req): Target. |
| `/clearwarning`| Clear a specific warning. | `case_id` (Req): Case ID. |
| `/case` | View details of a specific moderation case. | `case_id` (Req): Case ID. |
| `/cases` | View moderation history for a user. | `user` (Req): Target. |
| `/note` | Add a note to a user. | `user` (Req): Target.<br>`note` (Req): Content. |
| `/notes` | View notes for a user. | `user` (Req): Target. |
| `/prune` | Bulk delete messages. | `amount` (Req): Number (1-100). |
### Game Admin
| Command | Description | Options |
|---|---|---|
| `/create_item` | Create a new item in the database. | (Modal based interaction) |
| `/create_color`| Create a new color role. | `name` (Req): Role name.<br>`hex` (Req): Hex color code. |
| `/listing` | Manage shop listings (Admin view). | None (Context sensitive?) |
| `/terminal` | Control the terminal display channel. | `action`: 'setup', 'update', 'clear'. |

160
docs/CONFIGURATION.md Normal file
View File

@@ -0,0 +1,160 @@
# Configuration Guide
This document outlines the structure and available options for the `config/config.json` file. The configuration is validated using Zod schemas at runtime (see `src/lib/config.ts`).
## Core Structure
### Leveling
Configuration for the XP and leveling system.
| Field | Type | Description |
|-------|------|-------------|
| `base` | `number` | The base XP required for the first level. |
| `exponent` | `number` | The exponent used to calculate XP curves. |
| `chat.cooldownMs` | `number` | Time in milliseconds between XP gains from chat. |
| `chat.minXp` | `number` | Minimum XP awarded per message. |
| `chat.maxXp` | `number` | Maximum XP awarded per message. |
### Economy
Settings for currency, rewards, and transfers.
#### Daily
| Field | Type | Description |
|-------|------|-------------|
| `amount` | `integer` | Base amount granted by `/daily`. |
| `streakBonus` | `integer` | Bonus amount per streak day. |
| `weeklyBonus` | `integer` | Bonus amount for a 7-day streak. |
| `cooldownMs` | `number` | Cooldown period for the command (usually 24h). |
#### Transfers
| Field | Type | Description |
|-------|------|-------------|
| `allowSelfTransfer` | `boolean` | Whether users can transfer money to themselves. |
| `minAmount` | `integer` | Minimum amount required for a transfer. |
#### Exam
| Field | Type | Description |
|-------|------|-------------|
| `multMin` | `number` | Minimum multiplier for exam rewards. |
| `multMax` | `number` | Maximum multiplier for exam rewards. |
### Inventory
| Field | Type | Description |
|-------|------|-------------|
| `maxStackSize` | `integer` | Maximum count of a single item in one slot. |
| `maxSlots` | `number` | Total number of inventory slots available. |
### Lootdrop
Settings for the random chat loot drop events.
| Field | Type | Description |
|-------|------|-------------|
| `activityWindowMs` | `number` | Time window to track activity for spawning drops. |
| `minMessages` | `number` | Minimum messages required in window to trigger drop. |
| `spawnChance` | `number` | Probability (0-1) of a drop spawning when conditions met. |
| `cooldownMs` | `number` | Minimum time between loot drops. |
| `reward.min` | `number` | Minimum currency reward. |
| `reward.max` | `number` | Maximum currency reward. |
| `reward.currency` | `string` | The currency ID/Symbol used for rewards. |
### Roles
| Field | Type | Description |
|-------|------|-------------|
| `studentRole` | `string` | Discord Role ID for students. |
| `visitorRole` | `string` | Discord Role ID for visitors. |
| `colorRoles` | `string[]` | List of Discord Role IDs available as color roles. |
### Moderation
Automated moderation settings.
#### Prune
| Field | Type | Description |
|-------|------|-------------|
| `maxAmount` | `number` | Maximum messages to delete in one go. |
| `confirmThreshold` | `number` | Amount above which confirmation is required. |
| `batchSize` | `number` | Size of delete batches. |
| `batchDelayMs` | `number` | Delay between batches. |
#### Cases
| Field | Type | Description |
|-------|------|-------------|
| `dmOnWarn` | `boolean` | Whether to DM users when they are warned. |
| `logChannelId` | `string` | (Optional) Channel ID for moderation logs. |
| `autoTimeoutThreshold` | `number` | (Optional) Warn count to trigger auto-timeout. |
### System & Misc
| Field | Type | Description |
|-------|------|-------------|
| `commands` | `Object` | Map of command names (keys) to boolean (values) to enable/disable them. |
| `welcomeChannelId` | `string` | (Optional) Channel ID for welcome messages. |
| `welcomeMessage` | `string` | (Optional) Custom welcome message text. |
| `feedbackChannelId` | `string` | (Optional) Channel ID where feedback is posted. |
| `terminal.channelId` | `string` | (Optional) Channel ID for terminal display. |
| `terminal.messageId` | `string` | (Optional) Message ID for terminal display. |
## Example Config
```json
{
"leveling": {
"base": 100,
"exponent": 1.5,
"chat": {
"cooldownMs": 60000,
"minXp": 15,
"maxXp": 25
}
},
"economy": {
"daily": {
"amount": "100",
"streakBonus": "10",
"weeklyBonus": "500",
"cooldownMs": 86400000
},
"transfers": {
"allowSelfTransfer": false,
"minAmount": "10"
},
"exam": {
"multMin": 1.0,
"multMax": 2.0
}
},
"inventory": {
"maxStackSize": "99",
"maxSlots": 20
},
"lootdrop": {
"activityWindowMs": 300000,
"minMessages": 10,
"spawnChance": 0.05,
"cooldownMs": 3600000,
"reward": {
"min": 50,
"max": 150,
"currency": "CREDITS"
}
},
"commands": {
"example": true
},
"studentRole": "123456789012345678",
"visitorRole": "123456789012345678",
"colorRoles": [],
"moderation": {
"prune": {
"maxAmount": 100,
"confirmThreshold": 50,
"batchSize": 100,
"batchDelayMs": 1000
},
"cases": {
"dmOnWarn": true
}
}
}
```
> [!NOTE]
> Fields marked as `integer` or `bigint` in the types can often be provided as strings in the JSON to ensure precision, but the system handles parsing them.

149
docs/DATABASE.md Normal file
View File

@@ -0,0 +1,149 @@
# Database Schema
This document outlines the database schema for the Aurora project. The database is PostgreSQL, managed via Drizzle ORM.
## Tables
### Users (`users`)
Stores user data, economy, and progression.
| Column | Type | Description |
|---|---|---|
| `id` | `bigint` | Primary Key. Discord User ID. |
| `class_id` | `bigint` | Foreign Key -> `classes.id`. |
| `username` | `varchar(255)` | User's Discord username. |
| `is_active` | `boolean` | Whether the user is active (default: true). |
| `balance` | `bigint` | User's currency balance. |
| `xp` | `bigint` | User's experience points. |
| `level` | `integer` | User's level. |
| `daily_streak` | `integer` | Current streak of daily command usage. |
| `settings` | `jsonb` | User-specific settings. |
| `created_at` | `timestamp` | Record creation time. |
| `updated_at` | `timestamp` | Last update time. |
### Classes (`classes`)
Available character classes.
| Column | Type | Description |
|---|---|---|
| `id` | `bigint` | Primary Key. Custom ID. |
| `name` | `varchar(255)` | Class name (Unique). |
| `balance` | `bigint` | Class bank balance (shared/flavor). |
| `role_id` | `varchar(255)` | Discord Role ID associated with the class. |
### Items (`items`)
Definitions of items available in the game.
| Column | Type | Description |
|---|---|---|
| `id` | `serial` | Primary Key. Auto-incrementing ID. |
| `name` | `varchar(255)` | Item name (Unique). |
| `description` | `text` | Item description. |
| `rarity` | `varchar(20)` | Common, Rare, etc. Default: 'Common'. |
| `type` | `varchar(50)` | MATERIAL, CONSUMABLE, EQUIPMENT, etc. |
| `usage_data` | `jsonb` | Effect data for consumables/usables. |
| `price` | `bigint` | Base value of the item. |
| `icon_url` | `text` | URL for the item's icon. |
| `image_url` | `text` | URL for the item's large image. |
### Inventory (`inventory`)
Items held by users.
| Column | Type | Description |
|---|---|---|
| `user_id` | `bigint` | PK/FK -> `users.id`. |
| `item_id` | `integer` | PK/FK -> `items.id`. |
| `quantity` | `bigint` | Amount held. Must be > 0. |
### Transactions (`transactions`)
Currency transaction history.
| Column | Type | Description |
|---|---|---|
| `id` | `bigserial` | Primary Key. |
| `user_id` | `bigint` | FK -> `users.id`. The user affecting the balance. |
| `related_user_id` | `bigint` | FK -> `users.id`. The other party (if any). |
| `amount` | `bigint` | Amount transferred. |
| `type` | `varchar(50)` | Transaction type identifier. |
| `description` | `text` | Human-readable description. |
| `created_at` | `timestamp` | Time of transaction. |
### Item Transactions (`item_transactions`)
Item flow history.
| Column | Type | Description |
|---|---|---|
| `id` | `bigserial` | Primary Key. |
| `user_id` | `bigint` | FK -> `users.id`. |
| `related_user_id` | `bigint` | FK -> `users.id`. |
| `item_id` | `integer` | FK -> `items.id`. |
| `quantity` | `bigint` | Amount gained (+) or lost (-). |
| `type` | `varchar(50)` | TRADE, SHOP_BUY, DROP, etc. |
| `description` | `text` | Description. |
| `created_at` | `timestamp` | Time of transaction. |
### Quests (`quests`)
Quest definitions.
| Column | Type | Description |
|---|---|---|
| `id` | `serial` | Primary Key. |
| `name` | `varchar(255)` | Quest title. |
| `description` | `text` | Quest text. |
| `trigger_event` | `varchar(50)` | Event that triggers progress checks. |
| `requirements` | `jsonb` | Completion criteria. |
| `rewards` | `jsonb` | Rewards for completion. |
### User Quests (`user_quests`)
User progress on quests.
| Column | Type | Description |
|---|---|---|
| `user_id` | `bigint` | PK/FK -> `users.id`. |
| `quest_id` | `integer` | PK/FK -> `quests.id`. |
| `progress` | `integer` | Current progress value. |
| `completed_at` | `timestamp` | Completion time (null if active). |
### User Timers (`user_timers`)
Generic timers for cooldowns, temporary effects, etc.
| Column | Type | Description |
|---|---|---|
| `user_id` | `bigint` | PK/FK -> `users.id`. |
| `type` | `varchar(50)` | PK. Timer type (COOLDOWN, EFFECT, ACCESS). |
| `key` | `varchar(100)` | PK. specific ID (e.g. 'daily'). |
| `expires_at` | `timestamp` | When the timer expires. |
| `metadata` | `jsonb` | Extra data. |
### Lootdrops (`lootdrops`)
Active chat loot drop events.
| Column | Type | Description |
|---|---|---|
| `message_id` | `varchar(255)` | Primary Key. Discord Message ID. |
| `channel_id` | `varchar(255)` | Discord Channel ID. |
| `reward_amount` | `integer` | Currency amount. |
| `currency` | `varchar(50)` | Currency type constant. |
| `claimed_by` | `bigint` | FK -> `users.id`. Null if unclaimed. |
| `created_at` | `timestamp` | Spawn time. |
| `expires_at` | `timestamp` | Despawn time. |
### Moderation Cases (`moderation_cases`)
History of moderation actions.
| Column | Type | Description |
|---|---|---|
| `id` | `bigserial` | Primary Key. |
| `case_id` | `varchar(50)` | Unique friendly ID. |
| `type` | `varchar(20)` | warn, timeout, kick, ban, etc. |
| `user_id` | `bigint` | Target user ID. |
| `username` | `varchar(255)` | Target username snapshot. |
| `moderator_id` | `bigint` | Acting moderator ID. |
| `moderator_name` | `varchar(255)` | Moderator username snapshot. |
| `reason` | `text` | Reason for action. |
| `metadata` | `jsonb` | Extra data. |
| `active` | `boolean` | Is this case active? |
| `created_at` | `timestamp` | Creation time. |
| `resolved_at` | `timestamp` | Resolution/Expiration time. |
| `resolved_by` | `bigint` | User ID who resolved it. |
| `resolved_reason` | `text` | Reason for resolution. |

127
docs/LOOTBOX_GUIDE.md Normal file
View File

@@ -0,0 +1,127 @@
# Lootbox Creation Guide
Currently, the Item Wizard does not support creating **Lootbox** items directly. Instead, they must be inserted manually into the database. This guide details the required JSON structure for the `LOOTBOX` effect.
## Item Structure
To create a lootbox, you need to insert a row into the `items` table. The critical part is the `usageData` JSON column.
```json
{
"consume": true,
"effects": [
{
"type": "LOOTBOX",
"pool": [ ... ]
}
]
}
```
## Loot Table Structure
The `pool` property is an array of `LootTableItem` objects. A random item is selected based on the total `weight` of all items in the pool.
| Field | Type | Description |
|-------|------|-------------|
| `type` | `string` | One of: `CURRENCY`, `ITEM`, `XP`, `NOTHING`. |
| `weight` | `number` | The relative probability weight of this outcome. |
| `message` | `string` | (Optional) Custom message to display when this outcome is selected. |
### Outcome Types
#### 1. Currency
Gives the user coins.
```json
{
"type": "CURRENCY",
"weight": 50,
"amount": 100, // Fixed amount OR
"minAmount": 50, // Minimum random amount
"maxAmount": 150 // Maximum random amount
}
```
#### 2. XP
Gives the user Experience Points.
```json
{
"type": "XP",
"weight": 30,
"amount": 500 // Fixed amount OR range (minAmount/maxAmount)
}
```
#### 3. Item
Gives the user another item (by ID).
```json
{
"type": "ITEM",
"weight": 10,
"itemId": 42, // The ID of the item to give
"amount": 1 // (Optional) Quantity to give, default 1
}
```
#### 4. Nothing
An empty roll.
```json
{
"type": "NOTHING",
"weight": 10,
"message": "The box was empty! Better luck next time."
}
```
## Complete Example
Here is a full SQL insert example (using a hypothetical SQL client or Drizzle studio) for a "Basic Lootbox":
**Name**: Basic Lootbox
**Type**: CONSUMABLE
**Effect**:
- 50% chance for 100-200 Coins
- 30% chance for 500 XP
- 10% chance for Item ID 5 (e.g. Rare Gem)
- 10% chance for Nothing
**JSON for `usageData`**:
```json
{
"consume": true,
"effects": [
{
"type": "LOOTBOX",
"pool": [
{
"type": "CURRENCY",
"weight": 50,
"minAmount": 100,
"maxAmount": 200
},
{
"type": "XP",
"weight": 30,
"amount": 500
},
{
"type": "ITEM",
"weight": 10,
"itemId": 5,
"amount": 1,
"message": "Startstruck! You found a Rare Gem!"
},
{
"type": "NOTHING",
"weight": 10,
"message": "It's empty..."
}
]
}
]
}
```

72
docs/MODULE_STRUCTURE.md Normal file
View File

@@ -0,0 +1,72 @@
# Aurora Module Structure Guide
This guide documents the standard module organization patterns used in the Aurora codebase. Following these patterns ensures consistency, maintainability, and clear separation of concerns.
## Module Anatomy
A typical module in `@modules/` is organized into several files, each with a specific responsibility.
Example: `trade` module
- `trade.service.ts`: Business logic and data access.
- `trade.view.ts`: Discord UI components (embeds, modals, select menus).
- `trade.interaction.ts`: Handler for interaction events (buttons, modals, etc.).
- `trade.types.ts`: TypeScript interfaces and types.
- `trade.service.test.ts`: Unit tests for the service logic.
## File Responsibilities
### 1. Service (`*.service.ts`)
The core of the module. It contains the business logic, database interactions (using Drizzle), and state management.
- **Rules**:
- Export a singleton instance: `export const tradeService = new TradeService();`
- Should not contain Discord-specific rendering logic (return data, not embeds).
- Throw `UserError` for validation issues that should be shown to the user.
### 2. View (`*.view.ts`)
Handles the creation of Discord-specific UI elements like `EmbedBuilder`, `ActionRowBuilder`, and `ModalBuilder`.
- **Rules**:
- Focus on formatting and presentation.
- Takes raw data (from services) and returns Discord components.
### 3. Interaction Handler (`*.interaction.ts`)
The entry point for Discord component interactions (buttons, select menus, modals).
- **Rules**:
- Export a single handler function: `export async function handleTradeInteraction(interaction: Interaction) { ... }`
- Routes internal `customId` patterns to specific logic.
- Relies on `ComponentInteractionHandler` for centralized error handling.
- **No local try-catch** for standard validation errors; let them bubble up as `UserError`.
### 4. Types (`*.types.ts`)
Central location for module-specific TypeScript types and constants.
- **Rules**:
- Define interfaces for complex data structures.
- Use enums or literal types for states and custom IDs.
## Interaction Routing
All interaction handlers must be registered in `src/lib/interaction.routes.ts`.
```typescript
{
predicate: (i) => i.customId.startsWith("module_"),
handler: () => import("@/modules/module/module.interaction"),
method: 'handleModuleInteraction'
}
```
## Error Handling Standards
Aurora uses a centralized error handling pattern in `ComponentInteractionHandler`.
1. **UserError**: Use this for validation errors or issues the user can fix (e.g., "Insufficient funds").
- `throw new UserError("You need more coins!");`
2. **SystemError / Generic Error**: Use this for unexpected system failures.
- These are logged to the console/logger and show a generic "Unexpected error" message to the user.
## Naming Conventions
- **Directory Name**: Lowercase, singular (e.g., `trade`, `inventory`).
- **File Names**: `moduleName.type.ts` (e.g., `trade.service.ts`).
- **Class Names**: PascalCase (e.g., `TradeService`).
- **Service Instances**: camelCase (e.g., `tradeService`).
- **Interaction Method**: `handle[ModuleName]Interaction`.

View File

@@ -0,0 +1,8 @@
CREATE INDEX "moderation_cases_user_id_idx" ON "moderation_cases" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "moderation_cases_case_id_idx" ON "moderation_cases" USING btree ("case_id");--> statement-breakpoint
CREATE INDEX "transactions_created_at_idx" ON "transactions" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "user_timers_expires_at_idx" ON "user_timers" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "user_timers_lookup_idx" ON "user_timers" USING btree ("user_id","type","key");--> statement-breakpoint
CREATE INDEX "users_username_idx" ON "users" USING btree ("username");--> statement-breakpoint
CREATE INDEX "users_balance_idx" ON "users" USING btree ("balance");--> statement-breakpoint
CREATE INDEX "users_level_xp_idx" ON "users" USING btree ("level","xp");

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,13 @@
"when": 1766606046050, "when": 1766606046050,
"tag": "0001_heavy_thundra", "tag": "0001_heavy_thundra",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1767716705797,
"tag": "0002_fancy_forge",
"breakpoints": true
} }
] ]
} }

View File

@@ -5,8 +5,7 @@
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"drizzle-kit": "^0.31.7", "drizzle-kit": "^0.31.7"
"postgres": "^3.4.7"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5" "typescript": "^5"
@@ -26,6 +25,7 @@
"discord.js": "^14.25.1", "discord.js": "^14.25.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7", "drizzle-orm": "^0.44.7",
"postgres": "^3.4.7",
"zod": "^4.1.13" "zod": "^4.1.13"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -0,0 +1,84 @@
import { describe, test, expect, mock, beforeEach } from "bun:test";
import { health } from "./health";
import { ChatInputCommandInteraction, Colors } from "discord.js";
import { AuroraClient } from "@/lib/BotClient";
// Mock DrizzleClient
const executeMock = mock(() => Promise.resolve());
mock.module("@/lib/DrizzleClient", () => ({
DrizzleClient: {
execute: executeMock
}
}));
// Mock BotClient (already has lastCommandTimestamp if imported, but we might want to control it)
AuroraClient.lastCommandTimestamp = 1641481200000; // Fixed timestamp for testing
describe("Health Command", () => {
beforeEach(() => {
executeMock.mockClear();
});
test("should execute successfully and return health embed", async () => {
const interaction = {
deferReply: mock(() => Promise.resolve()),
editReply: mock(() => Promise.resolve()),
client: {
ws: {
ping: 42
}
},
user: { id: "123", username: "testuser" },
commandName: "health"
} as unknown as ChatInputCommandInteraction;
await health.execute(interaction);
expect(interaction.deferReply).toHaveBeenCalled();
expect(executeMock).toHaveBeenCalled();
expect(interaction.editReply).toHaveBeenCalled();
const editReplyCall = (interaction.editReply as any).mock.calls[0][0];
const embed = editReplyCall.embeds[0];
expect(embed.data.title).toBe("System Health Status");
expect(embed.data.color).toBe(Colors.Aqua);
// Check fields
const fields = embed.data.fields;
expect(fields).toBeDefined();
// Connectivity field
const connectivityField = fields.find((f: any) => f.name === "📡 Connectivity");
expect(connectivityField.value).toContain("42ms");
expect(connectivityField.value).toContain("Connected");
// Activity field
const activityField = fields.find((f: any) => f.name === "⌨️ Activity");
expect(activityField.value).toContain("R>"); // Relative Discord timestamp
});
test("should handle database disconnection", async () => {
executeMock.mockImplementationOnce(() => Promise.reject(new Error("DB Down")));
const interaction = {
deferReply: mock(() => Promise.resolve()),
editReply: mock(() => Promise.resolve()),
client: {
ws: {
ping: 42
}
},
user: { id: "123", username: "testuser" },
commandName: "health"
} as unknown as ChatInputCommandInteraction;
await health.execute(interaction);
const editReplyCall = (interaction.editReply as any).mock.calls[0][0];
const embed = editReplyCall.embeds[0];
const connectivityField = embed.data.fields.find((f: any) => f.name === "📡 Connectivity");
expect(connectivityField.value).toContain("Disconnected");
});
});

View File

@@ -0,0 +1,60 @@
import { createCommand } from "@lib/utils";
import { AuroraClient } from "@/lib/BotClient";
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, Colors } from "discord.js";
import { DrizzleClient } from "@/lib/DrizzleClient";
import { sql } from "drizzle-orm";
import { createBaseEmbed } from "@lib/embeds";
export const health = createCommand({
data: new SlashCommandBuilder()
.setName("health")
.setDescription("Check the bot's health status")
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply();
// 1. Check Discord API latency
const wsPing = interaction.client.ws.ping;
// 2. Verify database connection
let dbStatus = "Connected";
let dbPing = -1;
try {
const start = Date.now();
await DrizzleClient.execute(sql`SELECT 1`);
dbPing = Date.now() - start;
} catch (error) {
dbStatus = "Disconnected";
console.error("Health check DB error:", error);
}
// 3. Uptime
const uptime = process.uptime();
const days = Math.floor(uptime / 86400);
const hours = Math.floor((uptime % 86400) / 3600);
const minutes = Math.floor((uptime % 3600) / 60);
const seconds = Math.floor(uptime % 60);
const uptimeString = `${days}d ${hours}h ${minutes}m ${seconds}s`;
// 4. Memory usage
const memory = process.memoryUsage();
const heapUsed = (memory.heapUsed / 1024 / 1024).toFixed(2);
const heapTotal = (memory.heapTotal / 1024 / 1024).toFixed(2);
const rss = (memory.rss / 1024 / 1024).toFixed(2);
// 5. Last successful command
const lastCommand = AuroraClient.lastCommandTimestamp
? `<t:${Math.floor(AuroraClient.lastCommandTimestamp / 1000)}:R>`
: "None since startup";
const embed = createBaseEmbed("System Health Status", undefined, Colors.Aqua)
.addFields(
{ name: "📡 Connectivity", value: `**Discord WS:** ${wsPing}ms\n**Database:** ${dbStatus} ${dbPing >= 0 ? `(${dbPing}ms)` : ""}`, inline: true },
{ name: "⏱️ Uptime", value: uptimeString, inline: true },
{ name: "🧠 Memory Usage", value: `**RSS:** ${rss} MB\n**Heap:** ${heapUsed} / ${heapTotal} MB`, inline: false },
{ name: "⌨️ Activity", value: `**Last Command:** ${lastCommand}`, inline: true }
);
await interaction.editReply({ embeds: [embed] });
}
});

View File

@@ -1,6 +1,7 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@/modules/moderation/moderation.service"; import { ModerationService } from "@/modules/moderation/moderation.service";
import { CaseType } from "@/lib/constants";
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
export const note = createCommand({ export const note = createCommand({
@@ -31,7 +32,7 @@ export const note = createCommand({
// Create the note case // Create the note case
const moderationCase = await ModerationService.createCase({ const moderationCase = await ModerationService.createCase({
type: 'note', type: CaseType.NOTE,
userId: targetUser.id, userId: targetUser.id,
username: targetUser.username, username: targetUser.username,
moderatorId: interaction.user.id, moderatorId: interaction.user.id,

View File

@@ -50,75 +50,31 @@ export const warn = createCommand({
return; return;
} }
// Create the warning case // Issue the warning via service
const moderationCase = await ModerationService.createCase({ const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({
type: 'warn',
userId: targetUser.id, userId: targetUser.id,
username: targetUser.username, username: targetUser.username,
moderatorId: interaction.user.id, moderatorId: interaction.user.id,
moderatorName: interaction.user.username, moderatorName: interaction.user.username,
reason, reason,
guildName: interaction.guild?.name || undefined,
dmTarget: targetUser,
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id)
}); });
if (!moderationCase) {
await interaction.editReply({
embeds: [getModerationErrorEmbed("Failed to create warning case.")]
});
return;
}
// Get total warning count for the user
const warningCount = await ModerationService.getActiveWarningCount(targetUser.id);
// Send success message to moderator // Send success message to moderator
await interaction.editReply({ await interaction.editReply({
embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)] embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
}); });
// Try to DM the user if configured // Follow up if auto-timeout was issued
if (config.moderation.cases.dmOnWarn) { if (autoTimeoutIssued) {
try { await interaction.followUp({
const serverName = interaction.guild?.name || 'this server'; embeds: [getModerationErrorEmbed(
await targetUser.send({ `⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.`
embeds: [getUserWarningEmbed(serverName, reason, moderationCase.caseId, warningCount)] )],
}); flags: MessageFlags.Ephemeral
} catch (error) { });
// Silently fail if user has DMs disabled
console.log(`Could not DM warning to ${targetUser.username}: ${error}`);
}
}
// Optional: Check for auto-timeout threshold
if (config.moderation.cases.autoTimeoutThreshold &&
warningCount >= config.moderation.cases.autoTimeoutThreshold) {
try {
const member = await interaction.guild?.members.fetch(targetUser.id);
if (member) {
// Auto-timeout for 24 hours (86400000 ms)
await member.timeout(86400000, `Automatic timeout: ${warningCount} warnings`);
// Create a timeout case
await ModerationService.createCase({
type: 'timeout',
userId: targetUser.id,
username: targetUser.username,
moderatorId: interaction.client.user!.id,
moderatorName: interaction.client.user!.username,
reason: `Automatic timeout: reached ${warningCount} warnings`,
metadata: { duration: '24h', automatic: true }
});
await interaction.followUp({
embeds: [getModerationErrorEmbed(
`⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.`
)],
flags: MessageFlags.Ephemeral
});
}
} catch (error) {
console.error('Failed to auto-timeout user:', error);
}
} }
} catch (error) { } catch (error) {

View File

@@ -23,6 +23,8 @@ export const balance = createCommand({
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username); const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
if (!user) throw new Error("Failed to retrieve user data.");
const embed = createBaseEmbed(undefined, `**Balance**: ${user.balance || 0n} AU`, "Yellow") const embed = createBaseEmbed(undefined, `**Balance**: ${user.balance || 0n} AU`, "Yellow")
.setAuthor({ name: targetUser.username, iconURL: targetUser.displayAvatarURL() }); .setAuthor({ name: targetUser.username, iconURL: targetUser.displayAvatarURL() });

View File

@@ -7,8 +7,9 @@ import { userTimers, users } from "@/db/schema";
import { eq, and, sql } from "drizzle-orm"; import { eq, and, sql } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { DrizzleClient } from "@/lib/DrizzleClient";
import { config } from "@lib/config"; import { config } from "@lib/config";
import { TimerType } from "@/lib/constants";
const EXAM_TIMER_TYPE = 'EXAM_SYSTEM'; const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
const EXAM_TIMER_KEY = 'default'; const EXAM_TIMER_KEY = 'default';
interface ExamMetadata { interface ExamMetadata {
@@ -25,6 +26,10 @@ export const exam = createCommand({
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply(); await interaction.deferReply();
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username); const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
if (!user) {
await interaction.editReply({ embeds: [createErrorEmbed("Failed to retrieve user data.")] });
return;
}
const now = new Date(); const now = new Date();
const currentDay = now.getDay(); const currentDay = now.getDay();
@@ -43,11 +48,12 @@ export const exam = createCommand({
// Set exam day to today // Set exam day to today
const nextExamDate = new Date(now); const nextExamDate = new Date(now);
nextExamDate.setDate(now.getDate() + 7); nextExamDate.setDate(now.getDate() + 7);
nextExamDate.setHours(0, 0, 0, 0);
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000); const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
const metadata: ExamMetadata = { const metadata: ExamMetadata = {
examDay: currentDay, examDay: currentDay,
lastXp: user.xp.toString() lastXp: (user.xp ?? 0n).toString()
}; };
await DrizzleClient.insert(userTimers).values({ await DrizzleClient.insert(userTimers).values({
@@ -61,7 +67,7 @@ export const exam = createCommand({
await interaction.editReply({ await interaction.editReply({
embeds: [createSuccessEmbed( embeds: [createSuccessEmbed(
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}** (Server Time).\n` + `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!`, `Come back on <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>) to take your first exam!`,
"Exam Registration Successful" "Exam Registration Successful"
)] )]
}); });
@@ -72,15 +78,17 @@ export const exam = createCommand({
const examDay = metadata.examDay; const examDay = metadata.examDay;
// 3. Cooldown Check // 3. Cooldown Check
if (now < new Date(timer.expiresAt)) { const expiresAt = new Date(timer.expiresAt);
expiresAt.setHours(0, 0, 0, 0);
if (now < expiresAt) {
// Calculate time remaining // Calculate time remaining
const expiresAt = new Date(timer.expiresAt);
const timestamp = Math.floor(expiresAt.getTime() / 1000); const timestamp = Math.floor(expiresAt.getTime() / 1000);
await interaction.editReply({ await interaction.editReply({
embeds: [createErrorEmbed( embeds: [createErrorEmbed(
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` + `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>)` `Next exam available: <t:${timestamp}:D> (<t:${timestamp}:R>)`
)] )]
}); });
return; return;
@@ -94,11 +102,12 @@ export const exam = createCommand({
const nextExamDate = new Date(now); const nextExamDate = new Date(now);
nextExamDate.setDate(now.getDate() + daysUntil); nextExamDate.setDate(now.getDate() + daysUntil);
nextExamDate.setHours(0, 0, 0, 0);
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000); const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
const newMetadata: ExamMetadata = { const newMetadata: ExamMetadata = {
examDay: examDay, examDay: examDay,
lastXp: user.xp.toString() // Reset tracking lastXp: (user.xp ?? 0n).toString()
}; };
await DrizzleClient.update(userTimers) await DrizzleClient.update(userTimers)
@@ -116,7 +125,7 @@ export const exam = createCommand({
embeds: [createErrorEmbed( embeds: [createErrorEmbed(
`You missed your exam day! Your exam day is **${DAYS[examDay]}** (Server Time).\n` + `You missed your exam day! Your exam day is **${DAYS[examDay]}** (Server Time).\n` +
`You verify your attendance but score a **0**.\n` + `You verify your attendance but score a **0**.\n` +
`Your next exam opportunity is: <t:${nextExamTimestamp}:F> (<t:${nextExamTimestamp}:R>)`, `Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
"Exam Failed" "Exam Failed"
)] )]
}); });
@@ -125,7 +134,7 @@ export const exam = createCommand({
// 5. Reward Calculation // 5. Reward Calculation
const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case
const currentXp = user.xp; const currentXp = user.xp ?? 0n;
const diff = currentXp - lastXp; const diff = currentXp - lastXp;
// Calculate Reward // Calculate Reward
@@ -143,6 +152,7 @@ export const exam = createCommand({
// 6. Update State // 6. Update State
const nextExamDate = new Date(now); const nextExamDate = new Date(now);
nextExamDate.setDate(now.getDate() + 7); nextExamDate.setDate(now.getDate() + 7);
nextExamDate.setHours(0, 0, 0, 0);
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000); const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
const newMetadata: ExamMetadata = { const newMetadata: ExamMetadata = {
@@ -178,7 +188,7 @@ export const exam = createCommand({
`**XP Gained:** ${diff.toString()}\n` + `**XP Gained:** ${diff.toString()}\n` +
`**Multiplier:** x${multiplier.toFixed(2)}\n` + `**Multiplier:** x${multiplier.toFixed(2)}\n` +
`**Reward:** ${reward.toString()} Currency\n\n` + `**Reward:** ${reward.toString()} Currency\n\n` +
`See you next week: <t:${nextExamTimestamp}:F>`, `See you next week: <t:${nextExamTimestamp}:D>`,
"Exam Passed!" "Exam Passed!"
)] )]
}); });

View File

@@ -33,6 +33,11 @@ export const pay = createCommand({
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;
if (!targetUser) {
await interaction.reply({ embeds: [createErrorEmbed("User not found.")], flags: MessageFlags.Ephemeral });
return;
}
const receiverId = targetUser.id; const receiverId = targetUser.id;
if (amount < config.economy.transfers.minAmount) { if (amount < config.economy.transfers.minAmount) {
@@ -40,14 +45,14 @@ export const pay = createCommand({
return; return;
} }
if (senderId === receiverId) { if (senderId === receiverId.toString()) {
await interaction.reply({ embeds: [createErrorEmbed("You cannot pay yourself.")], flags: MessageFlags.Ephemeral }); await interaction.reply({ embeds: [createErrorEmbed("You cannot pay yourself.")], flags: MessageFlags.Ephemeral });
return; return;
} }
try { try {
await interaction.deferReply(); await interaction.deferReply();
await economyService.transfer(senderId, receiverId, amount); await economyService.transfer(senderId, receiverId.toString(), amount);
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful"); const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` }); await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags } from "discord.js"; import { SlashCommandBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags } from "discord.js";
import { TradeService } from "@/modules/trade/trade.service"; import { tradeService } from "@/modules/trade/trade.service";
import { getTradeDashboard } from "@/modules/trade/trade.view"; import { getTradeDashboard } from "@/modules/trade/trade.view";
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds"; import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
@@ -59,7 +59,7 @@ export const trade = createCommand({
} }
// Setup Session // Setup Session
const 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 }
); );

View File

@@ -2,7 +2,8 @@ import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder } 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, createBaseEmbed } from "@lib/embeds"; import { createWarningEmbed } from "@lib/embeds";
import { getInventoryEmbed } from "@/modules/inventory/inventory.view";
export const inventory = createCommand({ export const inventory = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -24,18 +25,19 @@ export const inventory = createCommand({
} }
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); if (!user) {
await interaction.editReply({ embeds: [createWarningEmbed("Failed to load user data.", "Error")] });
return;
}
const items = await inventoryService.getInventory(user.id.toString());
if (!items || items.length === 0) { if (!items || items.length === 0) {
await interaction.editReply({ embeds: [createWarningEmbed("Inventory is empty.", `${user.username}'s Inventory`)] }); await interaction.editReply({ embeds: [createWarningEmbed("Inventory is empty.", `${user.username}'s Inventory`)] });
return; return;
} }
const description = items.map(entry => { const embed = getInventoryEmbed(items, user.username);
return `**${entry.item.name}** x${entry.quantity}`;
}).join("\n");
const embed = createBaseEmbed(`${user.username}'s Inventory`, description, "Blue");
await interaction.editReply({ embeds: [embed] }); await interaction.editReply({ embeds: [embed] });
} }

View File

@@ -2,10 +2,8 @@ import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder } 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 { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; import { createErrorEmbed } from "@lib/embeds";
import { inventory, items } from "@/db/schema"; import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
import { eq, and, like } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient";
import type { ItemUsageData } from "@/lib/types"; import type { ItemUsageData } from "@/lib/types";
import { UserError } from "@/lib/errors"; import { UserError } from "@/lib/errors";
import { config } from "@/lib/config"; import { config } from "@/lib/config";
@@ -25,9 +23,13 @@ export const use = createCommand({
const itemId = interaction.options.getNumber("item", true); const itemId = interaction.options.getNumber("item", true);
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username); const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
if (!user) {
await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] });
return;
}
try { try {
const result = await inventoryService.useItem(user.id, itemId); const result = await inventoryService.useItem(user.id.toString(), itemId);
const usageData = result.usageData; const usageData = result.usageData;
if (usageData) { if (usageData) {
@@ -53,11 +55,7 @@ export const use = createCommand({
} }
} }
const embed = createSuccessEmbed( const embed = getItemUseResultEmbed(result.results, result.item);
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] }); await interaction.editReply({ embeds: [embed] });
@@ -74,28 +72,8 @@ export const use = createCommand({
const focusedValue = interaction.options.getFocused(); const focusedValue = interaction.options.getFocused();
const userId = interaction.user.id; const userId = interaction.user.id;
// Fetch owned items that match the search query const results = await inventoryService.getAutocompleteItems(userId, focusedValue);
// 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 => { await interaction.respond(results);
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,10 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder } 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, items, inventory } from "@/db/schema";
import { desc } from "drizzle-orm"; import { desc, sql, eq } from "drizzle-orm";
import { createWarningEmbed, createBaseEmbed } from "@lib/embeds"; import { createWarningEmbed } from "@lib/embeds";
import { getLeaderboardEmbed } from "@/modules/leveling/leveling.view";
export const leaderboard = createCommand({ export const leaderboard = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -11,36 +12,49 @@ export const leaderboard = createCommand({
.setDescription("View the top players") .setDescription("View the top players")
.addStringOption(option => .addStringOption(option =>
option.setName("type") option.setName("type")
.setDescription("Sort by XP or Balance") .setDescription("Sort by XP, Balance, or Net Worth")
.setRequired(true) .setRequired(true)
.addChoices( .addChoices(
{ name: "Level / XP", value: "xp" }, { name: "Level / XP", value: "xp" },
{ name: "Balance", value: "balance" } { name: "Balance", value: "balance" },
{ name: "Net Worth", value: "networth" }
) )
), ),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply(); await interaction.deferReply();
const type = interaction.options.getString("type", true); const type = interaction.options.getString("type", true);
const isXp = type === "xp";
const leaders = await DrizzleClient.query.users.findMany({ let leaders;
orderBy: isXp ? desc(users.xp) : desc(users.balance),
limit: 10 if (type === 'networth') {
}); leaders = await DrizzleClient.select({
username: users.username,
level: users.level,
xp: users.xp,
balance: users.balance,
netWorth: sql<bigint>`${users.balance} + COALESCE(SUM(${items.price} * ${inventory.quantity}), 0)`.as('net_worth')
})
.from(users)
.leftJoin(inventory, eq(users.id, inventory.userId))
.leftJoin(items, eq(inventory.itemId, items.id))
.groupBy(users.id)
.orderBy(desc(sql`net_worth`))
.limit(10);
} else {
const isXp = type === "xp";
leaders = await DrizzleClient.query.users.findMany({
orderBy: isXp ? desc(users.xp) : desc(users.balance),
limit: 10
});
}
if (leaders.length === 0) { if (leaders.length === 0) {
await interaction.editReply({ embeds: [createWarningEmbed("No users found.", "Leaderboard")] }); await interaction.editReply({ embeds: [createWarningEmbed("No users found.", "Leaderboard")] });
return; return;
} }
const description = leaders.map((user, index) => { const embed = getLeaderboardEmbed(leaders, type as 'xp' | 'balance' | 'networth');
const medal = index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : `${index + 1}.`;
const value = isXp ? `Lvl ${user.level} (${user.xp} XP)` : `${user.balance} 🪙`;
return `${medal} **${user.username}** — ${value}`;
}).join("\n");
const embed = createBaseEmbed(isXp ? "🏆 XP Leaderboard" : "💰 Richest Players", description, "Gold");
await interaction.editReply({ embeds: [embed] }); await interaction.editReply({ embeds: [embed] });
} }

View File

@@ -1,7 +1,8 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, MessageFlags } 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, createBaseEmbed } from "@lib/embeds"; import { createWarningEmbed } from "@lib/embeds";
import { getQuestListEmbed } from "@/modules/quest/quest.view";
export const quests = createCommand({ export const quests = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -17,21 +18,7 @@ export const quests = createCommand({
return; return;
} }
const embed = createBaseEmbed("📜 Quest Log", undefined, "Blue"); const embed = getQuestListEmbed(userQuests);
userQuests.forEach(entry => {
const status = entry.completedAt ? "✅ Completed" : "In Progress";
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
const rewardStr = [];
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`);
embed.addFields({
name: `${entry.quest.name} (${status})`,
value: `${entry.quest.description}\n**Rewards:** ${rewardStr.join(", ")}\n**Progress:** ${entry.progress}%`,
inline: false
});
});
await interaction.editReply({ embeds: [embed] }); await interaction.editReply({ embeds: [embed] });
} }

43
src/db/indexes.test.ts Normal file
View File

@@ -0,0 +1,43 @@
import { expect, test, describe } from "bun:test";
import { postgres } from "../lib/DrizzleClient";
describe("Database Indexes", () => {
test("should have indexes on users table", async () => {
const result = await postgres`
SELECT indexname FROM pg_indexes
WHERE tablename = 'users'
`;
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
expect(indexNames).toContain("users_balance_idx");
expect(indexNames).toContain("users_level_xp_idx");
});
test("should have index on transactions table", async () => {
const result = await postgres`
SELECT indexname FROM pg_indexes
WHERE tablename = 'transactions'
`;
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
expect(indexNames).toContain("transactions_created_at_idx");
});
test("should have indexes on moderation_cases table", async () => {
const result = await postgres`
SELECT indexname FROM pg_indexes
WHERE tablename = 'moderation_cases'
`;
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
expect(indexNames).toContain("moderation_cases_user_id_idx");
expect(indexNames).toContain("moderation_cases_case_id_idx");
});
test("should have indexes on user_timers table", async () => {
const result = await postgres`
SELECT indexname FROM pg_indexes
WHERE tablename = 'user_timers'
`;
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
expect(indexNames).toContain("user_timers_expires_at_idx");
expect(indexNames).toContain("user_timers_lookup_idx");
});
});

View File

@@ -9,6 +9,7 @@ import {
text, text,
integer, integer,
primaryKey, primaryKey,
index,
bigserial, bigserial,
check check
} from 'drizzle-orm/pg-core'; } from 'drizzle-orm/pg-core';
@@ -41,7 +42,11 @@ export const users = pgTable('users', {
settings: jsonb('settings').default({}), settings: jsonb('settings').default({}),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
}); }, (table) => [
index('users_username_idx').on(table.username),
index('users_balance_idx').on(table.balance),
index('users_level_xp_idx').on(table.level, table.xp),
]);
// 3. Items // 3. Items
export const items = pgTable('items', { export const items = pgTable('items', {
@@ -82,7 +87,9 @@ export const transactions = pgTable('transactions', {
type: varchar('type', { length: 50 }).notNull(), type: varchar('type', { length: 50 }).notNull(),
description: text('description'), description: text('description'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
}); }, (table) => [
index('transactions_created_at_idx').on(table.createdAt),
]);
export const itemTransactions = pgTable('item_transactions', { export const itemTransactions = pgTable('item_transactions', {
id: bigserial('id', { mode: 'bigint' }).primaryKey(), id: bigserial('id', { mode: 'bigint' }).primaryKey(),
@@ -129,7 +136,9 @@ export const userTimers = pgTable('user_timers', {
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
metadata: jsonb('metadata').default({}), // Store channelId, specific buff amounts, etc. metadata: jsonb('metadata').default({}), // Store channelId, specific buff amounts, etc.
}, (table) => [ }, (table) => [
primaryKey({ columns: [table.userId, table.type, table.key] }) primaryKey({ columns: [table.userId, table.type, table.key] }),
index('user_timers_expires_at_idx').on(table.expiresAt),
index('user_timers_lookup_idx').on(table.userId, table.type, table.key),
]); ]);
// 9. Lootdrops // 9. Lootdrops
export const lootdrops = pgTable('lootdrops', { export const lootdrops = pgTable('lootdrops', {
@@ -158,7 +167,10 @@ export const moderationCases = pgTable('moderation_cases', {
resolvedAt: timestamp('resolved_at', { withTimezone: true }), resolvedAt: timestamp('resolved_at', { withTimezone: true }),
resolvedBy: bigint('resolved_by', { mode: 'bigint' }), resolvedBy: bigint('resolved_by', { mode: 'bigint' }),
resolvedReason: text('resolved_reason'), resolvedReason: text('resolved_reason'),
}); }, (table) => [
index('moderation_cases_user_id_idx').on(table.userId),
index('moderation_cases_case_id_idx').on(table.caseId),
]);

View File

@@ -1,72 +1,20 @@
import { Events, MessageFlags } from "discord.js"; import { Events } from "discord.js";
import { AuroraClient } from "@/lib/BotClient"; import { ComponentInteractionHandler, AutocompleteHandler, CommandHandler } from "@/lib/handlers";
import { userService } from "@/modules/user/user.service";
import { createErrorEmbed } from "@lib/embeds";
import type { Event } from "@lib/types"; import type { Event } from "@lib/types";
const event: Event<Events.InteractionCreate> = { const event: Event<Events.InteractionCreate> = {
name: Events.InteractionCreate, name: Events.InteractionCreate,
execute: async (interaction) => { execute: async (interaction) => {
// Handle Trade Interactions
if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) { if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
const { interactionRoutes } = await import("@lib/interaction.routes"); return ComponentInteractionHandler.handle(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;
} else {
console.error(`Handler method ${route.method} not found in module`);
}
}
}
} }
if (interaction.isAutocomplete()) { if (interaction.isAutocomplete()) {
const command = AuroraClient.commands.get(interaction.commandName); return AutocompleteHandler.handle(interaction);
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 CommandHandler.handle(interaction);
const command = AuroraClient.commands.get(interaction.commandName);
if (!command) {
console.error(`No command matching ${interaction.commandName} was found.`);
return;
}
// Ensure user exists in database
try {
const user = await userService.getUserById(interaction.user.id);
if (!user) {
console.log(`🆕 Creating new user entry for ${interaction.user.tag}`);
await userService.createUser(interaction.user.id, interaction.user.username);
}
} catch (error) {
console.error("Failed to check/create user:", error);
}
try {
await command.execute(interaction);
} catch (error) {
console.error(error);
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
} else {
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
}
} }
}, },
}; };

135
src/graphics/lootdrop.ts Normal file
View File

@@ -0,0 +1,135 @@
import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas';
import path from 'path';
// Register Fonts (same as studentID.ts)
const fontDir = path.join(process.cwd(), 'src', 'assets', 'fonts');
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold');
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
export async function generateLootdropCard(amount: number, currency: string): Promise<Buffer> {
const templatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'lootdrop', 'template.png');
const template = await loadImage(templatePath);
const canvas = createCanvas(template.width, template.height);
const ctx = canvas.getContext('2d');
// Draw Template
ctx.drawImage(template, 0, 0);
// Draw Lootdrop Text (Title-ish)
ctx.save();
ctx.font = '48px IBMPlexSansCondensed-SemiBold';
ctx.fillStyle = '#FFFFFF';
ctx.textAlign = 'center';
ctx.shadowBlur = 10;
ctx.shadowColor = 'rgba(255, 255, 255, 0.5)';
// Center of lower half (512-1024) is roughly 768
ctx.fillText('A STAR IS FALLING', canvas.width / 2, 660);
ctx.restore();
// Draw Reward Amount
ctx.save();
ctx.font = '72px IBMPlexMono-Bold';
ctx.fillStyle = '#DAC7A1';
ctx.textAlign = 'center';
//ctx.shadowBlur = 15;
//ctx.shadowColor = 'rgba(255, 215, 0, 0.8)';
ctx.fillText(`${amount} ${currency}`, canvas.width / 2, 760); // Below title
ctx.restore();
// Crop the image by 64px on all sides
const croppedWidth = template.width - 128;
const croppedHeight = template.height - 128;
const croppedCanvas = createCanvas(croppedWidth, croppedHeight);
const croppedCtx = croppedCanvas.getContext('2d');
// Draw the original canvas onto the cropped canvas, shifted by -64
croppedCtx.drawImage(canvas, -64, -64);
return croppedCanvas.toBuffer('image/png');
}
export async function generateClaimedLootdropCard(amount: number, currency: string, username: string, avatarUrl: string): Promise<Buffer> {
const templatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'lootdrop', 'template.png');
const template = await loadImage(templatePath);
const canvas = createCanvas(template.width, template.height);
const ctx = canvas.getContext('2d');
// Draw Template
ctx.drawImage(template, 0, 0);
// Add a colored overlay to signify "claimed"
ctx.fillStyle = 'rgba(10, 10, 20, 0.85)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw Claimed Text (Title-ish)
ctx.save();
ctx.font = '48px IBMPlexSansCondensed-SemiBold';
ctx.fillStyle = '#FFFFFF';
ctx.textAlign = 'center';
ctx.shadowBlur = 10;
ctx.shadowColor = 'rgba(255, 255, 255, 0.5)';
ctx.fillText('STAR CLAIMED', canvas.width / 2, 660);
ctx.restore();
// Draw "by username" with Avatar
ctx.save();
ctx.font = '36px IBMPlexSansCondensed-SemiBold';
ctx.fillStyle = '#AAAAAA';
// Calculate layout for centering Group (Avatar + Text)
const text = `by ${username}`;
const textMetrics = ctx.measureText(text);
const textWidth = textMetrics.width;
const avatarSize = 50;
const gap = 15;
const totalWidth = avatarSize + gap + textWidth;
const startX = (canvas.width - totalWidth) / 2;
const baselineY = 830;
// Draw Avatar
try {
const avatar = await loadImage(avatarUrl);
ctx.save();
ctx.beginPath();
// Center avatar vertically relative to text roughly (baseline - ~half cap height)
// 36px text ~ 27px cap height. Center roughly at baselineY - 14
const avatarCenterY = baselineY - 14;
ctx.arc(startX + avatarSize / 2, avatarCenterY, avatarSize / 2, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
ctx.drawImage(avatar, startX, avatarCenterY - avatarSize / 2, avatarSize, avatarSize);
ctx.restore();
} catch (e) {
// Fallback if avatar fails to load, just don't draw it (or maybe shift text?)
// For now, let's just proceed, the text will be off-center if avatar is missing but that's acceptable edge case
console.error("Failed to load avatar", e);
}
// Draw Text
ctx.textAlign = 'left';
ctx.fillText(text, startX + avatarSize + gap, baselineY);
ctx.restore();
ctx.save();
ctx.font = '72px IBMPlexMono-Bold'; // Match Amount size
ctx.fillStyle = '#E6D2B5'; // Lighter gold/beige for better contrast
ctx.textAlign = 'center';
ctx.shadowBlur = 10;
ctx.shadowColor = 'rgba(0, 0, 0, 0.8)'; // Dark shadow for contrast
ctx.fillText(`${amount} ${currency}`, canvas.width / 2, 760); // Same position as Unclaimed Amount
ctx.restore();
// Crop the image by 64px on all sides
const croppedWidth = template.width - 128;
const croppedHeight = template.height - 128;
const croppedCanvas = createCanvas(croppedWidth, croppedHeight);
const croppedCtx = croppedCanvas.getContext('2d');
// Draw the original canvas onto the cropped canvas, shifted by -64
croppedCtx.drawImage(canvas, -64, -64);
return croppedCanvas.toBuffer('image/png');
}

View File

@@ -1,14 +1,26 @@
import { AuroraClient } from "@/lib/BotClient"; import { AuroraClient } from "@/lib/BotClient";
import { env } from "@lib/env"; import { env } from "@lib/env";
import { WebServer } from "@/web/server";
// Load commands & events // Load commands & events
await AuroraClient.loadCommands(); await AuroraClient.loadCommands();
await AuroraClient.loadEvents(); await AuroraClient.loadEvents();
await AuroraClient.deployCommands(); await AuroraClient.deployCommands();
WebServer.start();
// 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.");
} }
AuroraClient.login(env.DISCORD_BOT_TOKEN); AuroraClient.login(env.DISCORD_BOT_TOKEN);
// Handle graceful shutdown
const shutdownHandler = () => {
WebServer.stop();
AuroraClient.shutdown();
};
process.on("SIGINT", shutdownHandler);
process.on("SIGTERM", shutdownHandler);

View File

@@ -1,145 +1,56 @@
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js"; import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js";
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 } from "@lib/types";
import { env } from "@lib/env"; import { env } from "@lib/env";
import { config } from "@lib/config"; import { CommandLoader } from "@lib/loaders/CommandLoader";
import { EventLoader } from "@lib/loaders/EventLoader";
import { logger } from "@lib/logger";
class Client extends DiscordClient { export class Client extends DiscordClient {
commands: Collection<string, Command>; commands: Collection<string, Command>;
lastCommandTimestamp: number | null = null;
private commandLoader: CommandLoader;
private eventLoader: EventLoader;
constructor({ intents }: { intents: number[] }) { constructor({ intents }: { intents: number[] }) {
super({ intents }); super({ intents });
this.commands = new Collection<string, Command>(); this.commands = new Collection<string, Command>();
this.commandLoader = new CommandLoader(this);
this.eventLoader = new EventLoader(this);
} }
async loadCommands(reload: boolean = false) { async loadCommands(reload: boolean = false) {
if (reload) { if (reload) {
this.commands.clear(); this.commands.clear();
console.log("♻️ Reloading commands..."); logger.info("♻️ Reloading commands...");
} }
const commandsPath = join(import.meta.dir, '../commands'); const commandsPath = join(import.meta.dir, '../commands');
await this.readCommandsRecursively(commandsPath, reload); const result = await this.commandLoader.loadFromDirectory(commandsPath, reload);
logger.info(`📦 Command loading complete: ${result.loaded} loaded, ${result.skipped} skipped, ${result.errors.length} errors`);
} }
async loadEvents(reload: boolean = false) { async loadEvents(reload: boolean = false) {
if (reload) { if (reload) {
this.removeAllListeners(); this.removeAllListeners();
console.log("♻️ Reloading events..."); logger.info("♻️ Reloading events...");
} }
const eventsPath = join(import.meta.dir, '../events'); const eventsPath = join(import.meta.dir, '../events');
await this.readEventsRecursively(eventsPath, reload); const result = await this.eventLoader.loadFromDirectory(eventsPath, reload);
logger.info(`📦 Event loading complete: ${result.loaded} loaded, ${result.skipped} skipped, ${result.errors.length} errors`);
} }
private async readCommandsRecursively(dir: string, reload: boolean = false) {
try {
const files = await readdir(dir, { withFileTypes: true });
for (const file of files) {
const filePath = join(dir, file.name);
if (file.isDirectory()) {
await this.readCommandsRecursively(filePath, reload);
continue;
}
if (!file.name.endsWith('.ts') && !file.name.endsWith('.js')) continue;
try {
const importPath = reload ? `${filePath}?t=${Date.now()}` : filePath;
const commandModule = await import(importPath);
const commands = Object.values(commandModule);
if (commands.length === 0) {
console.warn(`⚠️ No commands found in ${file.name}`);
continue;
}
// 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) {
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);
console.log(`✅ Loaded command: ${command.data.name}`);
} else {
console.warn(`⚠️ Skipping invalid command in ${file.name}`);
}
}
} catch (error) {
console.error(`❌ Failed to load command from ${filePath}:`, error);
}
}
} catch (error) {
console.error(`Error reading directory ${dir}:`, error);
}
}
private async readEventsRecursively(dir: string, reload: boolean = false) {
try {
const files = await readdir(dir, { withFileTypes: true });
for (const file of files) {
const filePath = join(dir, file.name);
if (file.isDirectory()) {
await this.readEventsRecursively(filePath, reload);
continue;
}
if (!file.name.endsWith('.ts') && !file.name.endsWith('.js')) continue;
try {
const importPath = reload ? `${filePath}?t=${Date.now()}` : filePath;
const eventModule = await import(importPath);
const event = eventModule.default;
if (this.isValidEvent(event)) {
if (event.once) {
this.once(event.name, (...args) => event.execute(...args));
} else {
this.on(event.name, (...args) => event.execute(...args));
}
console.log(`✅ Loaded event: ${event.name}`);
} else {
console.warn(`⚠️ Skipping invalid event in ${file.name}`);
}
} catch (error) {
console.error(`❌ Failed to load event from ${filePath}:`, error);
}
}
} catch (error) {
console.error(`Error reading directory ${dir}:`, error);
}
}
private isValidCommand(command: any): command is Command {
return command && typeof command === 'object' && 'data' in command && 'execute' in command;
}
private isValidEvent(event: any): event is Event<any> {
return event && typeof event === 'object' && 'name' in event && 'execute' in event;
}
async deployCommands() { async deployCommands() {
// We use env.DISCORD_BOT_TOKEN directly so this can run without client.login() // We use env.DISCORD_BOT_TOKEN directly so this can run without client.login()
const token = env.DISCORD_BOT_TOKEN; const token = env.DISCORD_BOT_TOKEN;
if (!token) { if (!token) {
console.error("DISCORD_BOT_TOKEN is not set."); logger.error("DISCORD_BOT_TOKEN is not set.");
return; return;
} }
@@ -149,16 +60,16 @@ class Client extends DiscordClient {
const clientId = env.DISCORD_CLIENT_ID; const clientId = env.DISCORD_CLIENT_ID;
if (!clientId) { if (!clientId) {
console.error("DISCORD_CLIENT_ID is not set."); logger.error("DISCORD_CLIENT_ID is not set.");
return; return;
} }
try { try {
console.log(`Started refreshing ${commandsData.length} application (/) commands.`); logger.info(`Started refreshing ${commandsData.length} application (/) commands.`);
let data; let data;
if (guildId) { if (guildId) {
console.log(`Registering commands to guild: ${guildId}`); logger.info(`Registering commands to guild: ${guildId}`);
data = await rest.put( data = await rest.put(
Routes.applicationGuildCommands(clientId, guildId), Routes.applicationGuildCommands(clientId, guildId),
{ body: commandsData }, { body: commandsData },
@@ -166,23 +77,46 @@ class Client extends DiscordClient {
// Clear global commands to avoid duplicates // Clear global commands to avoid duplicates
await rest.put(Routes.applicationCommands(clientId), { body: [] }); await rest.put(Routes.applicationCommands(clientId), { body: [] });
} else { } else {
console.log('Registering commands globally'); logger.info('Registering commands globally');
data = await rest.put( data = await rest.put(
Routes.applicationCommands(clientId), Routes.applicationCommands(clientId),
{ body: commandsData }, { body: commandsData },
); );
} }
console.log(`Successfully reloaded ${(data as any).length} application (/) commands.`); logger.success(`Successfully reloaded ${(data as any).length} application (/) commands.`);
} catch (error: any) { } catch (error: any) {
if (error.code === 50001) { if (error.code === 50001) {
console.warn("⚠️ Missing Access: The bot is not in the guild or lacks 'applications.commands' scope."); logger.warn("Missing Access: The bot is not in the guild or lacks 'applications.commands' scope.");
console.warn(" If you are testing locally, make sure you invited the bot with scope 'bot applications.commands'."); logger.warn(" If you are testing locally, make sure you invited the bot with scope 'bot applications.commands'.");
} else { } else {
console.error(error); logger.error(error);
} }
} }
} }
async shutdown() {
const { setShuttingDown, waitForTransactions } = await import("./shutdown");
const { closeDatabase } = await import("./DrizzleClient");
logger.info("🛑 Shutdown signal received. Starting graceful shutdown...");
setShuttingDown(true);
// Wait for transactions to complete
logger.info("⏳ Waiting for active transactions to complete...");
await waitForTransactions(10000);
// Destroy Discord client
logger.info("🔌 Disconnecting from Discord...");
this.destroy();
// Close database
logger.info("🗄️ Closing database connection...");
await closeDatabase();
logger.success("👋 Graceful shutdown complete. Exiting.");
process.exit(0);
}
} }
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers] }); export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers] });

View File

@@ -4,6 +4,10 @@ import * as schema from "@db/schema";
import { env } from "@lib/env"; import { env } from "@lib/env";
const connectionString = env.DATABASE_URL; const connectionString = env.DATABASE_URL;
const postgres = new SQL(connectionString); export const postgres = new SQL(connectionString);
export const DrizzleClient = drizzle(postgres, { schema }); export const DrizzleClient = drizzle(postgres, { schema });
export const closeDatabase = async () => {
await postgres.close();
};

View File

@@ -69,6 +69,7 @@ export interface GameConfigType {
autoTimeoutThreshold?: number; autoTimeoutThreshold?: number;
}; };
}; };
system: Record<string, any>;
} }
// Initial default config state // Initial default config state
@@ -160,7 +161,8 @@ const configSchema = z.object({
cases: { cases: {
dmOnWarn: true dmOnWarn: true
} }
}) }),
system: z.record(z.string(), z.any()).default({}),
}); });
export function reloadConfig() { export function reloadConfig() {

65
src/lib/constants.ts Normal file
View File

@@ -0,0 +1,65 @@
/**
* Global Constants and Enums
*/
export enum TimerType {
COOLDOWN = 'COOLDOWN',
EFFECT = 'EFFECT',
ACCESS = 'ACCESS',
EXAM_SYSTEM = 'EXAM_SYSTEM',
}
export enum EffectType {
ADD_XP = 'ADD_XP',
ADD_BALANCE = 'ADD_BALANCE',
REPLY_MESSAGE = 'REPLY_MESSAGE',
XP_BOOST = 'XP_BOOST',
TEMP_ROLE = 'TEMP_ROLE',
COLOR_ROLE = 'COLOR_ROLE',
LOOTBOX = 'LOOTBOX',
}
export enum TransactionType {
TRANSFER_IN = 'TRANSFER_IN',
TRANSFER_OUT = 'TRANSFER_OUT',
DAILY_REWARD = 'DAILY_REWARD',
ITEM_USE = 'ITEM_USE',
LOOTBOX = 'LOOTBOX',
EXAM_REWARD = 'EXAM_REWARD',
PURCHASE = 'PURCHASE',
TRADE_IN = 'TRADE_IN',
TRADE_OUT = 'TRADE_OUT',
QUEST_REWARD = 'QUEST_REWARD',
}
export enum ItemTransactionType {
TRADE_IN = 'TRADE_IN',
TRADE_OUT = 'TRADE_OUT',
SHOP_BUY = 'SHOP_BUY',
DROP = 'DROP',
GIVE = 'GIVE',
USE = 'USE',
}
export enum ItemType {
MATERIAL = 'MATERIAL',
CONSUMABLE = 'CONSUMABLE',
EQUIPMENT = 'EQUIPMENT',
QUEST = 'QUEST',
}
export enum CaseType {
WARN = 'warn',
TIMEOUT = 'timeout',
KICK = 'kick',
BAN = 'ban',
NOTE = 'note',
PRUNE = 'prune',
}
export enum LootType {
NOTHING = 'NOTHING',
CURRENCY = 'CURRENCY',
XP = 'XP',
ITEM = 'ITEM',
}

47
src/lib/db.test.ts Normal file
View File

@@ -0,0 +1,47 @@
import { describe, it, expect, mock, beforeEach } from "bun:test";
// Mock DrizzleClient
mock.module("./DrizzleClient", () => ({
DrizzleClient: {
transaction: async (cb: any) => cb("MOCK_TX")
}
}));
import { withTransaction } from "./db";
import { setShuttingDown, getActiveTransactions, decrementTransactions } from "./shutdown";
describe("db withTransaction", () => {
beforeEach(() => {
setShuttingDown(false);
// Reset transaction count
while (getActiveTransactions() > 0) {
decrementTransactions();
}
});
it("should allow transactions when not shutting down", async () => {
const result = await withTransaction(async (tx) => {
return "success";
});
expect(result).toBe("success");
expect(getActiveTransactions()).toBe(0);
});
it("should throw error when shutting down", async () => {
setShuttingDown(true);
expect(withTransaction(async (tx) => {
return "success";
})).rejects.toThrow("System is shutting down, no new transactions allowed.");
expect(getActiveTransactions()).toBe(0);
});
it("should increment and decrement transaction count", async () => {
let countDuring = 0;
await withTransaction(async (tx) => {
countDuring = getActiveTransactions();
return "ok";
});
expect(countDuring).toBe(1);
expect(getActiveTransactions()).toBe(0);
});
});

View File

@@ -1,5 +1,6 @@
import { DrizzleClient } from "./DrizzleClient"; import { DrizzleClient } from "./DrizzleClient";
import type { Transaction } from "./types"; import type { Transaction } from "./types";
import { isShuttingDown, incrementTransactions, decrementTransactions } from "./shutdown";
export const withTransaction = async <T>( export const withTransaction = async <T>(
callback: (tx: Transaction) => Promise<T>, callback: (tx: Transaction) => Promise<T>,
@@ -8,8 +9,17 @@ export const withTransaction = async <T>(
if (tx) { if (tx) {
return await callback(tx); return await callback(tx);
} else { } else {
return await DrizzleClient.transaction(async (newTx) => { if (isShuttingDown()) {
return await callback(newTx); throw new Error("System is shutting down, no new transactions allowed.");
}); }
incrementTransactions();
try {
return await DrizzleClient.transaction(async (newTx) => {
return await callback(newTx);
});
} finally {
decrementTransactions();
}
} }
}; };

View File

@@ -5,6 +5,7 @@ const envSchema = z.object({
DISCORD_CLIENT_ID: z.string().optional(), DISCORD_CLIENT_ID: z.string().optional(),
DISCORD_GUILD_ID: z.string().optional(), DISCORD_GUILD_ID: z.string().optional(),
DATABASE_URL: z.string().min(1, "Database URL is required"), DATABASE_URL: z.string().min(1, "Database URL is required"),
PORT: z.coerce.number().default(3000),
}); });
const parsedEnv = envSchema.safeParse(process.env); const parsedEnv = envSchema.safeParse(process.env);

View File

@@ -0,0 +1,22 @@
import { AutocompleteInteraction } from "discord.js";
import { AuroraClient } from "@/lib/BotClient";
import { logger } from "@lib/logger";
/**
* Handles autocomplete interactions for slash commands
*/
export class AutocompleteHandler {
static async handle(interaction: AutocompleteInteraction): Promise<void> {
const command = AuroraClient.commands.get(interaction.commandName);
if (!command || !command.autocomplete) {
return;
}
try {
await command.autocomplete(interaction);
} catch (error) {
logger.error(`Error handling autocomplete for ${interaction.commandName}:`, error);
}
}
}

View File

@@ -0,0 +1,59 @@
import { describe, test, expect, mock, beforeEach } from "bun:test";
import { CommandHandler } from "./CommandHandler";
import { AuroraClient } from "@/lib/BotClient";
import { ChatInputCommandInteraction } from "discord.js";
// Mock UserService
mock.module("@/modules/user/user.service", () => ({
userService: {
getOrCreateUser: mock(() => Promise.resolve())
}
}));
describe("CommandHandler", () => {
beforeEach(() => {
AuroraClient.commands.clear();
AuroraClient.lastCommandTimestamp = null;
});
test("should update lastCommandTimestamp on successful execution", async () => {
const executeSuccess = mock(() => Promise.resolve());
AuroraClient.commands.set("test", {
data: { name: "test" } as any,
execute: executeSuccess
} as any);
const interaction = {
commandName: "test",
user: { id: "123", username: "testuser" }
} as unknown as ChatInputCommandInteraction;
await CommandHandler.handle(interaction);
expect(executeSuccess).toHaveBeenCalled();
expect(AuroraClient.lastCommandTimestamp).not.toBeNull();
expect(AuroraClient.lastCommandTimestamp).toBeGreaterThan(0);
});
test("should not update lastCommandTimestamp on failed execution", async () => {
const executeError = mock(() => Promise.reject(new Error("Command Failed")));
AuroraClient.commands.set("fail", {
data: { name: "fail" } as any,
execute: executeError
} as any);
const interaction = {
commandName: "fail",
user: { id: "123", username: "testuser" },
replied: false,
deferred: false,
reply: mock(() => Promise.resolve()),
followUp: mock(() => Promise.resolve())
} as unknown as ChatInputCommandInteraction;
await CommandHandler.handle(interaction);
expect(executeError).toHaveBeenCalled();
expect(AuroraClient.lastCommandTimestamp).toBeNull();
});
});

View File

@@ -0,0 +1,41 @@
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
import { AuroraClient } from "@/lib/BotClient";
import { userService } from "@/modules/user/user.service";
import { createErrorEmbed } from "@lib/embeds";
import { logger } from "@lib/logger";
/**
* Handles slash command execution
* Includes user validation and comprehensive error handling
*/
export class CommandHandler {
static async handle(interaction: ChatInputCommandInteraction): Promise<void> {
const command = AuroraClient.commands.get(interaction.commandName);
if (!command) {
logger.error(`No command matching ${interaction.commandName} was found.`);
return;
}
// Ensure user exists in database
try {
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
} catch (error) {
logger.error("Failed to ensure user exists:", error);
}
try {
await command.execute(interaction);
AuroraClient.lastCommandTimestamp = Date.now();
} catch (error) {
logger.error(String(error));
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
} else {
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
}
}
}
}

View File

@@ -0,0 +1,78 @@
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js";
import { logger } from "@lib/logger";
import { UserError } from "@lib/errors";
import { createErrorEmbed } from "@lib/embeds";
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
/**
* Handles component interactions (buttons, select menus, modals)
* Routes to appropriate handlers based on customId patterns
* Provides centralized error handling with UserError differentiation
*/
export class ComponentInteractionHandler {
static async handle(interaction: ComponentInteraction): Promise<void> {
const { interactionRoutes } = await import("@lib/interaction.routes");
for (const route of interactionRoutes) {
if (route.predicate(interaction)) {
const module = await route.handler();
const handlerMethod = module[route.method];
if (typeof handlerMethod === 'function') {
try {
await handlerMethod(interaction);
return;
} catch (error) {
await this.handleError(interaction, error, route.method);
return;
}
} else {
logger.error(`Handler method ${route.method} not found in module`);
}
}
}
}
/**
* Handles errors from interaction handlers
* Differentiates between UserError (user-facing) and system errors
*/
private static async handleError(
interaction: ComponentInteraction,
error: unknown,
handlerName: string
): Promise<void> {
const isUserError = error instanceof UserError;
// Determine error message
const errorMessage = isUserError
? (error as Error).message
: 'An unexpected error occurred. Please try again later.';
// Log system errors (non-user errors) for debugging
if (!isUserError) {
logger.error(`Error in ${handlerName}:`, error);
}
const errorEmbed = createErrorEmbed(errorMessage);
try {
// Handle different interaction states
if (interaction.replied || interaction.deferred) {
await interaction.followUp({
embeds: [errorEmbed],
flags: MessageFlags.Ephemeral
});
} else {
await interaction.reply({
embeds: [errorEmbed],
flags: MessageFlags.Ephemeral
});
}
} catch (replyError) {
// If we can't send a reply, log it
logger.error(`Failed to send error response in ${handlerName}:`, replyError);
}
}
}

View File

@@ -0,0 +1,3 @@
export { ComponentInteractionHandler } from "./ComponentInteractionHandler";
export { AutocompleteHandler } from "./AutocompleteHandler";
export { CommandHandler } from "./CommandHandler";

View File

@@ -1,19 +1,32 @@
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js"; import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
type InteractionHandler = (interaction: any) => Promise<void>; // Union type for all component interactions
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
// Type for the handler function that modules export
type InteractionHandler = (interaction: ComponentInteraction) => Promise<void>;
// Type for the dynamically imported module containing the handler
interface InteractionModule {
[key: string]: (...args: any[]) => Promise<void> | any;
}
// Route definition
interface InteractionRoute { interface InteractionRoute {
predicate: (interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction) => boolean; predicate: (interaction: ComponentInteraction) => boolean;
handler: () => Promise<any>; handler: () => Promise<InteractionModule>;
method: string; method: string;
} }
export const interactionRoutes: InteractionRoute[] = [ export const interactionRoutes: InteractionRoute[] = [
// --- TRADE MODULE ---
{ {
predicate: (i) => i.customId.startsWith("trade_") || i.customId === "amount", predicate: (i) => i.customId.startsWith("trade_") || i.customId === "amount",
handler: () => import("@/modules/trade/trade.interaction"), handler: () => import("@/modules/trade/trade.interaction"),
method: 'handleTradeInteraction' method: 'handleTradeInteraction'
}, },
// --- ECONOMY MODULE ---
{ {
predicate: (i) => i.isButton() && i.customId.startsWith("shop_buy_"), predicate: (i) => i.isButton() && i.customId.startsWith("shop_buy_"),
handler: () => import("@/modules/economy/shop.interaction"), handler: () => import("@/modules/economy/shop.interaction"),
@@ -24,16 +37,22 @@ export const interactionRoutes: InteractionRoute[] = [
handler: () => import("@/modules/economy/lootdrop.interaction"), handler: () => import("@/modules/economy/lootdrop.interaction"),
method: 'handleLootdropInteraction' method: 'handleLootdropInteraction'
}, },
// --- ADMIN MODULE ---
{ {
predicate: (i) => i.customId.startsWith("createitem_"), predicate: (i) => i.customId.startsWith("createitem_"),
handler: () => import("@/modules/admin/item_wizard"), handler: () => import("@/modules/admin/item_wizard"),
method: 'handleItemWizardInteraction' method: 'handleItemWizardInteraction'
}, },
// --- USER MODULE ---
{ {
predicate: (i) => i.isButton() && i.customId === "enrollment", predicate: (i) => i.isButton() && i.customId === "enrollment",
handler: () => import("@/modules/user/enrollment.interaction"), handler: () => import("@/modules/user/enrollment.interaction"),
method: 'handleEnrollmentInteraction' method: 'handleEnrollmentInteraction'
}, },
// --- FEEDBACK MODULE ---
{ {
predicate: (i) => i.customId.startsWith("feedback_"), predicate: (i) => i.customId.startsWith("feedback_"),
handler: () => import("@/modules/feedback/feedback.interaction"), handler: () => import("@/modules/feedback/feedback.interaction"),

View File

@@ -0,0 +1,111 @@
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import type { Command } from "@lib/types";
import { config } from "@lib/config";
import type { LoadResult, LoadError } from "./types";
import type { Client } from "../BotClient";
import { logger } from "@lib/logger";
/**
* Handles loading commands from the file system
*/
export class CommandLoader {
private client: Client;
constructor(client: Client) {
this.client = client;
}
/**
* Load commands from a directory recursively
*/
async loadFromDirectory(dir: string, reload: boolean = false): Promise<LoadResult> {
const result: LoadResult = { loaded: 0, skipped: 0, errors: [] };
await this.scanDirectory(dir, reload, result);
return result;
}
/**
* Recursively scan directory for command files
*/
private async scanDirectory(dir: string, reload: boolean, result: LoadResult): Promise<void> {
try {
const files = await readdir(dir, { withFileTypes: true });
for (const file of files) {
const filePath = join(dir, file.name);
if (file.isDirectory()) {
await this.scanDirectory(filePath, reload, result);
continue;
}
if ((!file.name.endsWith('.ts') && !file.name.endsWith('.js')) || file.name.endsWith('.test.ts') || file.name.endsWith('.spec.ts')) continue;
await this.loadCommandFile(filePath, reload, result);
}
} catch (error) {
logger.error(`Error reading directory ${dir}:`, error);
result.errors.push({ file: dir, error });
}
}
/**
* Load a single command file
*/
private async loadCommandFile(filePath: string, reload: boolean, result: LoadResult): Promise<void> {
try {
const importPath = reload ? `${filePath}?t=${Date.now()}` : filePath;
const commandModule = await import(importPath);
const commands = Object.values(commandModule);
if (commands.length === 0) {
logger.warn(`No commands found in ${filePath}`);
result.skipped++;
return;
}
const category = this.extractCategory(filePath);
for (const command of commands) {
if (this.isValidCommand(command)) {
command.category = category;
const isEnabled = config.commands[command.data.name] !== false;
if (!isEnabled) {
logger.info(`🚫 Skipping disabled command: ${command.data.name}`);
result.skipped++;
continue;
}
this.client.commands.set(command.data.name, command);
logger.success(`Loaded command: ${command.data.name}`);
result.loaded++;
} else {
logger.warn(`Skipping invalid command in ${filePath}`);
result.skipped++;
}
}
} catch (error) {
logger.error(`Failed to load command from ${filePath}:`, error);
result.errors.push({ file: filePath, error });
}
}
/**
* Extract category from file path
* e.g., /path/to/commands/admin/features.ts -> "admin"
*/
private extractCategory(filePath: string): string {
const pathParts = filePath.split('/');
return pathParts[pathParts.length - 2] ?? "uncategorized";
}
/**
* Type guard to validate command structure
*/
private isValidCommand(command: any): command is Command {
return command && typeof command === 'object' && 'data' in command && 'execute' in command;
}
}

View File

@@ -0,0 +1,85 @@
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import type { Event } from "@lib/types";
import type { LoadResult } from "./types";
import type { Client } from "../BotClient";
import { logger } from "@lib/logger";
/**
* Handles loading events from the file system
*/
export class EventLoader {
private client: Client;
constructor(client: Client) {
this.client = client;
}
/**
* Load events from a directory recursively
*/
async loadFromDirectory(dir: string, reload: boolean = false): Promise<LoadResult> {
const result: LoadResult = { loaded: 0, skipped: 0, errors: [] };
await this.scanDirectory(dir, reload, result);
return result;
}
/**
* Recursively scan directory for event files
*/
private async scanDirectory(dir: string, reload: boolean, result: LoadResult): Promise<void> {
try {
const files = await readdir(dir, { withFileTypes: true });
for (const file of files) {
const filePath = join(dir, file.name);
if (file.isDirectory()) {
await this.scanDirectory(filePath, reload, result);
continue;
}
if (!file.name.endsWith('.ts') && !file.name.endsWith('.js')) continue;
await this.loadEventFile(filePath, reload, result);
}
} catch (error) {
logger.error(`Error reading directory ${dir}:`, error);
result.errors.push({ file: dir, error });
}
}
/**
* Load a single event file
*/
private async loadEventFile(filePath: string, reload: boolean, result: LoadResult): Promise<void> {
try {
const importPath = reload ? `${filePath}?t=${Date.now()}` : filePath;
const eventModule = await import(importPath);
const event = eventModule.default;
if (this.isValidEvent(event)) {
if (event.once) {
this.client.once(event.name, (...args) => event.execute(...args));
} else {
this.client.on(event.name, (...args) => event.execute(...args));
}
logger.success(`Loaded event: ${event.name}`);
result.loaded++;
} else {
logger.warn(`Skipping invalid event in ${filePath}`);
result.skipped++;
}
} catch (error) {
logger.error(`Failed to load event from ${filePath}:`, error);
result.errors.push({ file: filePath, error });
}
}
/**
* Type guard to validate event structure
*/
private isValidEvent(event: any): event is Event<any> {
return event && typeof event === 'object' && 'name' in event && 'execute' in event;
}
}

16
src/lib/loaders/types.ts Normal file
View File

@@ -0,0 +1,16 @@
/**
* Result of loading commands or events
*/
export interface LoadResult {
loaded: number;
skipped: number;
errors: LoadError[];
}
/**
* Error that occurred during loading
*/
export interface LoadError {
file: string;
error: unknown;
}

39
src/lib/logger.ts Normal file
View File

@@ -0,0 +1,39 @@
/**
* Centralized logging utility with consistent formatting
*/
export const logger = {
/**
* General information message
*/
info: (message: string, ...args: any[]) => {
console.log(` ${message}`, ...args);
},
/**
* Success message
*/
success: (message: string, ...args: any[]) => {
console.log(`${message}`, ...args);
},
/**
* Warning message
*/
warn: (message: string, ...args: any[]) => {
console.warn(`⚠️ ${message}`, ...args);
},
/**
* Error message
*/
error: (message: string, ...args: any[]) => {
console.error(`${message}`, ...args);
},
/**
* Debug message
*/
debug: (message: string, ...args: any[]) => {
console.log(`🔍 ${message}`, ...args);
},
};

56
src/lib/shutdown.test.ts Normal file
View File

@@ -0,0 +1,56 @@
import { describe, it, expect, beforeEach } from "bun:test";
import { isShuttingDown, setShuttingDown, incrementTransactions, decrementTransactions, getActiveTransactions, waitForTransactions } from "./shutdown";
describe("shutdown logic", () => {
beforeEach(() => {
setShuttingDown(false);
while (getActiveTransactions() > 0) {
decrementTransactions();
}
});
it("should initialize with shuttingDown as false", () => {
expect(isShuttingDown()).toBe(false);
});
it("should update shuttingDown state", () => {
setShuttingDown(true);
expect(isShuttingDown()).toBe(true);
});
it("should track active transactions", () => {
expect(getActiveTransactions()).toBe(0);
incrementTransactions();
expect(getActiveTransactions()).toBe(1);
decrementTransactions();
expect(getActiveTransactions()).toBe(0);
});
it("should wait for transactions to complete", async () => {
incrementTransactions();
const start = Date.now();
const waitPromise = waitForTransactions(1000);
// Simulate completion after 200ms
setTimeout(() => {
decrementTransactions();
}, 200);
await waitPromise;
const duration = Date.now() - start;
expect(duration).toBeGreaterThanOrEqual(200);
expect(getActiveTransactions()).toBe(0);
});
it("should timeout if transactions never complete", async () => {
incrementTransactions();
const start = Date.now();
await waitForTransactions(500);
const duration = Date.now() - start;
expect(duration).toBeGreaterThanOrEqual(500);
expect(getActiveTransactions()).toBe(1); // Still 1 because we didn't decrement
});
});

30
src/lib/shutdown.ts Normal file
View File

@@ -0,0 +1,30 @@
import { logger } from "@lib/logger";
let shuttingDown = false;
let activeTransactions = 0;
export const isShuttingDown = () => shuttingDown;
export const setShuttingDown = (value: boolean) => {
shuttingDown = value;
};
export const incrementTransactions = () => {
activeTransactions++;
};
export const decrementTransactions = () => {
activeTransactions--;
};
export const getActiveTransactions = () => activeTransactions;
export const waitForTransactions = async (timeoutMs: number = 10000) => {
const start = Date.now();
while (activeTransactions > 0) {
if (Date.now() - start > timeoutMs) {
logger.warn(`Shutdown timed out waiting for ${activeTransactions} transactions after ${timeoutMs}ms`);
break;
}
await new Promise(resolve => setTimeout(resolve, 100));
}
};

View File

@@ -1,4 +1,6 @@
import type { AutocompleteInteraction, ChatInputCommandInteraction, ClientEvents, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder } from "discord.js"; import type { AutocompleteInteraction, ChatInputCommandInteraction, ClientEvents, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder } from "discord.js";
import { LootType, EffectType } from "./constants";
import { DrizzleClient } from "./DrizzleClient";
export interface Command { export interface Command {
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder; data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder;
@@ -14,19 +16,28 @@ export interface Event<K extends keyof ClientEvents> {
} }
export type ItemEffect = export type ItemEffect =
| { type: 'ADD_XP'; amount: number } | { type: EffectType.ADD_XP; amount: number }
| { type: 'ADD_BALANCE'; amount: number } | { type: EffectType.ADD_BALANCE; amount: number }
| { type: 'XP_BOOST'; multiplier: number; durationSeconds?: number; durationMinutes?: number; durationHours?: number } | { type: EffectType.XP_BOOST; multiplier: number; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
| { type: 'TEMP_ROLE'; roleId: string; durationSeconds?: number; durationMinutes?: number; durationHours?: number } | { type: EffectType.TEMP_ROLE; roleId: string; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
| { type: 'REPLY_MESSAGE'; message: string } | { type: EffectType.REPLY_MESSAGE; message: string }
| { type: 'COLOR_ROLE'; roleId: string }; | { type: EffectType.COLOR_ROLE; roleId: string }
| { type: EffectType.LOOTBOX; pool: LootTableItem[] };
export interface LootTableItem {
type: LootType;
weight: number;
amount?: number; // For CURRENCY, XP
itemId?: number; // For ITEM
minAmount?: number; // Optional range for CURRENCY/XP
maxAmount?: number; // Optional range for CURRENCY/XP
message?: string; // Optional custom message for this outcome
}
export interface ItemUsageData { export interface ItemUsageData {
consume: boolean; consume: boolean;
effects: ItemEffect[]; effects: ItemEffect[];
} }
import { DrizzleClient } from "./DrizzleClient";
export type DbClient = typeof DrizzleClient; export type DbClient = typeof DrizzleClient;
export type Transaction = Parameters<Parameters<DbClient['transaction']>[0]>[0]; export type Transaction = Parameters<Parameters<DbClient['transaction']>[0]>[0];

View File

@@ -4,6 +4,7 @@ import { DrizzleClient } from "@/lib/DrizzleClient";
import type { ItemUsageData, ItemEffect } from "@/lib/types"; import type { ItemUsageData, ItemEffect } from "@/lib/types";
import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view"; import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
import type { DraftItem } from "./item_wizard.types"; import type { DraftItem } from "./item_wizard.types";
import { ItemType, EffectType } from "@/lib/constants";
// --- Types --- // --- Types ---
@@ -23,7 +24,7 @@ export const renderWizard = (userId: string, isDraft = true) => {
name: "New Item", name: "New Item",
description: "No description", description: "No description",
rarity: "Common", rarity: "Common",
type: "MATERIAL", type: ItemType.MATERIAL,
price: null, price: null,
iconUrl: "", iconUrl: "",
imageUrl: "", imageUrl: "",
@@ -176,26 +177,26 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
if (type) { if (type) {
let effect: ItemEffect | null = null; let effect: ItemEffect | null = null;
if (type === "ADD_XP" || type === "ADD_BALANCE") { if (type === EffectType.ADD_XP || type === EffectType.ADD_BALANCE) {
const amount = parseInt(interaction.fields.getTextInputValue("amount")); const amount = parseInt(interaction.fields.getTextInputValue("amount"));
if (!isNaN(amount)) effect = { type: type as any, amount }; if (!isNaN(amount)) effect = { type: type as any, amount };
} }
else if (type === "REPLY_MESSAGE") { else if (type === EffectType.REPLY_MESSAGE) {
effect = { type: "REPLY_MESSAGE", message: interaction.fields.getTextInputValue("message") }; effect = { type: EffectType.REPLY_MESSAGE, message: interaction.fields.getTextInputValue("message") };
} }
else if (type === "XP_BOOST") { else if (type === EffectType.XP_BOOST) {
const multiplier = parseFloat(interaction.fields.getTextInputValue("multiplier")); const multiplier = parseFloat(interaction.fields.getTextInputValue("multiplier"));
const duration = parseInt(interaction.fields.getTextInputValue("duration")); const duration = parseInt(interaction.fields.getTextInputValue("duration"));
if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: "XP_BOOST", multiplier, durationSeconds: duration }; if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: EffectType.XP_BOOST, multiplier, durationSeconds: duration };
} }
else if (type === "TEMP_ROLE") { else if (type === EffectType.TEMP_ROLE) {
const roleId = interaction.fields.getTextInputValue("role_id"); const roleId = interaction.fields.getTextInputValue("role_id");
const duration = parseInt(interaction.fields.getTextInputValue("duration")); const duration = parseInt(interaction.fields.getTextInputValue("duration"));
if (roleId && !isNaN(duration)) effect = { type: "TEMP_ROLE", roleId: roleId, durationSeconds: duration }; if (roleId && !isNaN(duration)) effect = { type: EffectType.TEMP_ROLE, roleId: roleId, durationSeconds: duration };
} }
else if (type === "COLOR_ROLE") { else if (type === EffectType.COLOR_ROLE) {
const roleId = interaction.fields.getTextInputValue("role_id"); const roleId = interaction.fields.getTextInputValue("role_id");
if (roleId) effect = { type: "COLOR_ROLE", roleId: roleId }; if (roleId) effect = { type: EffectType.COLOR_ROLE, roleId: roleId };
} }
if (effect) { if (effect) {

View File

@@ -10,12 +10,13 @@ import {
} from "discord.js"; } from "discord.js";
import { createBaseEmbed } from "@lib/embeds"; import { createBaseEmbed } from "@lib/embeds";
import type { DraftItem } from "./item_wizard.types"; import type { DraftItem } from "./item_wizard.types";
import { ItemType } from "@/lib/constants";
const getItemTypeOptions = () => [ const getItemTypeOptions = () => [
{ label: "Material", value: "MATERIAL", description: "Used for crafting or trading" }, { label: "Material", value: ItemType.MATERIAL, description: "Used for crafting or trading" },
{ label: "Consumable", value: "CONSUMABLE", description: "Can be used to gain effects" }, { label: "Consumable", value: ItemType.CONSUMABLE, description: "Can be used to gain effects" },
{ label: "Equipment", value: "EQUIPMENT", description: "Can be equipped (Not yet implemented)" }, { label: "Equipment", value: ItemType.EQUIPMENT, description: "Can be equipped (Not yet implemented)" },
{ label: "Quest Item", value: "QUEST", description: "Required for quests" }, { label: "Quest Item", value: ItemType.QUEST, description: "Required for quests" },
]; ];
const getEffectTypeOptions = () => [ const getEffectTypeOptions = () => [

View File

@@ -2,14 +2,16 @@ 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"; import { UserError } from "@/lib/errors";
import { withTransaction } from "@/lib/db";
import type { Transaction } from "@/lib/types";
export const classService = { export const classService = {
getAllClasses: async () => { getAllClasses: async () => {
return await DrizzleClient.query.classes.findMany(); return await DrizzleClient.query.classes.findMany();
}, },
assignClass: async (userId: string, classId: bigint, tx?: any) => { assignClass: async (userId: string, classId: bigint, tx?: Transaction) => {
const execute = async (txFn: any) => { return await withTransaction(async (txFn) => {
const cls = await txFn.query.classes.findFirst({ const cls = await txFn.query.classes.findFirst({
where: eq(classes.id, classId), where: eq(classes.id, classId),
}); });
@@ -22,8 +24,7 @@ export const classService = {
.returning(); .returning();
return user; return user;
}; }, tx);
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
}, },
getClassBalance: async (classId: bigint) => { getClassBalance: async (classId: bigint) => {
const cls = await DrizzleClient.query.classes.findFirst({ const cls = await DrizzleClient.query.classes.findFirst({
@@ -31,15 +32,15 @@ export const classService = {
}); });
return cls?.balance || 0n; return cls?.balance || 0n;
}, },
modifyClassBalance: async (classId: bigint, amount: bigint, tx?: any) => { modifyClassBalance: async (classId: bigint, amount: bigint, tx?: Transaction) => {
const execute = async (txFn: any) => { return await withTransaction(async (txFn) => {
const cls = await txFn.query.classes.findFirst({ const cls = await txFn.query.classes.findFirst({
where: eq(classes.id, classId), where: eq(classes.id, classId),
}); });
if (!cls) throw new UserError("Class not found"); if (!cls) throw new UserError("Class not found");
if ((cls.balance ?? 0n) < amount) { if ((cls.balance ?? 0n) + amount < 0n) {
throw new UserError("Insufficient class funds"); throw new UserError("Insufficient class funds");
} }
@@ -51,35 +52,31 @@ export const classService = {
.returning(); .returning();
return updatedClass; return updatedClass;
}; }, tx);
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
}, },
updateClass: async (id: bigint, data: Partial<typeof classes.$inferInsert>, tx?: any) => { updateClass: async (id: bigint, data: Partial<typeof classes.$inferInsert>, tx?: Transaction) => {
const execute = async (txFn: any) => { return await withTransaction(async (txFn) => {
const [updatedClass] = await txFn.update(classes) const [updatedClass] = await txFn.update(classes)
.set(data) .set(data)
.where(eq(classes.id, id)) .where(eq(classes.id, id))
.returning(); .returning();
return updatedClass; return updatedClass;
}; }, tx);
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
}, },
createClass: async (data: typeof classes.$inferInsert, tx?: any) => { createClass: async (data: typeof classes.$inferInsert, tx?: Transaction) => {
const execute = async (txFn: any) => { return await withTransaction(async (txFn) => {
const [newClass] = await txFn.insert(classes) const [newClass] = await txFn.insert(classes)
.values(data) .values(data)
.returning(); .returning();
return newClass; return newClass;
}; }, tx);
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
}, },
deleteClass: async (id: bigint, tx?: any) => { deleteClass: async (id: bigint, tx?: Transaction) => {
const execute = async (txFn: any) => { return await withTransaction(async (txFn) => {
await txFn.delete(classes).where(eq(classes.id, id)); await txFn.delete(classes).where(eq(classes.id, id));
}; }, tx);
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
} }
}; };

View File

@@ -173,10 +173,22 @@ describe("economyService", () => {
it("should throw if cooldown is active", async () => { it("should throw if cooldown is active", async () => {
const future = new Date("2023-01-02T12:00:00Z"); // +24h const future = new Date("2023-01-02T12:00:00Z"); // +24h
mockFindFirst.mockResolvedValue({ expiresAt: future }); mockFindFirst.mockResolvedValue({ expiresAt: future });
expect(economyService.claimDaily("1")).rejects.toThrow("Daily already claimed"); expect(economyService.claimDaily("1")).rejects.toThrow("Daily already claimed");
}); });
it("should set cooldown to next UTC midnight", async () => {
// 2023-01-01T12:00:00Z -> Should be 2023-01-02T00:00:00Z
mockFindFirst
.mockResolvedValueOnce(undefined) // No cooldown
.mockResolvedValueOnce({ id: 1n, dailyStreak: 5, balance: 1000n });
const result = await economyService.claimDaily("1");
const expectedReset = new Date("2023-01-02T00:00:00Z");
expect(result.nextReadyAt.toISOString()).toBe(expectedReset.toISOString());
});
it("should reset streak if missed a day (long time gap)", async () => { it("should reset streak if missed a day (long time gap)", async () => {
// Expired 3 days ago // Expired 3 days ago
const past = new Date("2023-01-01T00:00:00Z"); // now is 12:00 const past = new Date("2023-01-01T00:00:00Z"); // now is 12:00
@@ -193,8 +205,40 @@ describe("economyService", () => {
const result = await economyService.claimDaily("1"); const result = await economyService.claimDaily("1");
// timeSinceReady = 48h. // timeSinceReady = 48h.
// streak = (5+1) - floor(48h / 24h) = 6 - 2 = 4. // streak should reset to 1
expect(result.streak).toBe(4); expect(result.streak).toBe(1);
});
it("should preserve streak if cooldown is missing but user has a streak", async () => {
mockFindFirst
.mockResolvedValueOnce(undefined) // No cooldown
.mockResolvedValueOnce({ id: 1n, dailyStreak: 10 });
const result = await economyService.claimDaily("1");
expect(result.streak).toBe(11);
});
it("should prevent weekly bonus exploit by resetting streak", async () => {
// Mock user at streak 7.
// Mock time as 24h + 1m after expiry.
const expiredAt = new Date("2023-01-01T11:59:00Z"); // now is 12:00 next day, plus 1 min gap?
// no, 'now' is 2023-01-01T12:00:00Z set in beforeEach
// We want gap > 24h.
// If expiry was yesterday 11:59:59. Gap is 24h + 1s.
const expiredAtExploit = new Date("2022-12-31T11:59:00Z"); // Over 24h ago
mockFindFirst
.mockResolvedValueOnce({ expiresAt: expiredAtExploit })
.mockResolvedValueOnce({ id: 1n, dailyStreak: 7 });
const result = await economyService.claimDaily("1");
// Should reset to 1
expect(result.streak).toBe(1);
expect(result.isWeekly).toBe(false);
}); });
}); });

View File

@@ -4,6 +4,7 @@ import { config } from "@/lib/config";
import { withTransaction } from "@/lib/db"; import { withTransaction } from "@/lib/db";
import type { Transaction } from "@/lib/types"; import type { Transaction } from "@/lib/types";
import { UserError } from "@/lib/errors"; import { UserError } from "@/lib/errors";
import { TimerType, TransactionType } from "@/lib/constants";
export const economyService = { export const economyService = {
transfer: async (fromUserId: string, toUserId: string, amount: bigint, tx?: Transaction) => { transfer: async (fromUserId: string, toUserId: string, amount: bigint, tx?: Transaction) => {
@@ -48,7 +49,7 @@ export const economyService = {
await txFn.insert(transactions).values({ await txFn.insert(transactions).values({
userId: BigInt(fromUserId), userId: BigInt(fromUserId),
amount: -amount, amount: -amount,
type: 'TRANSFER_OUT', type: TransactionType.TRANSFER_OUT,
description: `Transfer to ${toUserId}`, description: `Transfer to ${toUserId}`,
}); });
@@ -56,7 +57,7 @@ export const economyService = {
await txFn.insert(transactions).values({ await txFn.insert(transactions).values({
userId: BigInt(toUserId), userId: BigInt(toUserId),
amount: amount, amount: amount,
type: 'TRANSFER_IN', type: TransactionType.TRANSFER_IN,
description: `Transfer from ${fromUserId}`, description: `Transfer from ${fromUserId}`,
}); });
@@ -67,20 +68,18 @@ export const economyService = {
claimDaily: async (userId: string, tx?: Transaction) => { claimDaily: async (userId: string, tx?: Transaction) => {
return await withTransaction(async (txFn) => { return await withTransaction(async (txFn) => {
const now = new Date(); const now = new Date();
const startOfDay = new Date(now);
startOfDay.setHours(0, 0, 0, 0);
// Check cooldown // Check cooldown
const cooldown = await txFn.query.userTimers.findFirst({ const cooldown = await txFn.query.userTimers.findFirst({
where: and( where: and(
eq(userTimers.userId, BigInt(userId)), eq(userTimers.userId, BigInt(userId)),
eq(userTimers.type, 'COOLDOWN'), eq(userTimers.type, TimerType.COOLDOWN),
eq(userTimers.key, 'daily') eq(userTimers.key, 'daily')
), ),
}); });
if (cooldown && cooldown.expiresAt > now) { if (cooldown && cooldown.expiresAt > now) {
throw new UserError(`Daily already claimed. Ready <t:${Math.floor(cooldown.expiresAt.getTime() / 1000)}:R>`); throw new UserError(`Daily already claimed today. Next claim <t:${Math.floor(cooldown.expiresAt.getTime() / 1000)}:F>`);
} }
// Get user for streak logic // Get user for streak logic
@@ -89,17 +88,23 @@ export const economyService = {
}); });
if (!user) { if (!user) {
throw new Error("User not found"); // This might be system error because user should exist if authenticated, but keeping simple for now throw new Error("User not found");
} }
let streak = (user.dailyStreak || 0) + 1; let streak = (user.dailyStreak || 0) + 1;
// If previous cooldown exists and expired more than 24h ago (meaning >48h since last claim), reduce streak by one for each day passed minimum 1 // Check if streak should be reset due to missing a day
if (cooldown) { if (cooldown) {
const timeSinceReady = now.getTime() - cooldown.expiresAt.getTime(); const timeSinceReady = now.getTime() - cooldown.expiresAt.getTime();
// If more than 24h passed since it became ready, they missed a full calendar day
if (timeSinceReady > 24 * 60 * 60 * 1000) { if (timeSinceReady > 24 * 60 * 60 * 1000) {
streak = Math.max(1, streak - Math.floor(timeSinceReady / (24 * 60 * 60 * 1000))); streak = 1;
} }
} else if ((user.dailyStreak || 0) > 0) {
// If no cooldown record exists but user has a streak,
// we'll allow one "free" increment to restore the timer state.
// This prevents unfair resets if timers were cleared/lost.
streak = (user.dailyStreak || 0) + 1;
} else { } else {
streak = 1; streak = 1;
} }
@@ -119,13 +124,15 @@ export const economyService = {
}) })
.where(eq(users.id, BigInt(userId))); .where(eq(users.id, BigInt(userId)));
// Set new cooldown (now + 24h) // Set new cooldown (Next UTC Midnight)
const nextReadyAt = new Date(now.getTime() + config.economy.daily.cooldownMs); const nextReadyAt = new Date(now);
nextReadyAt.setUTCDate(nextReadyAt.getUTCDate() + 1);
nextReadyAt.setUTCHours(0, 0, 0, 0);
await txFn.insert(userTimers) await txFn.insert(userTimers)
.values({ .values({
userId: BigInt(userId), userId: BigInt(userId),
type: 'COOLDOWN', type: TimerType.COOLDOWN,
key: 'daily', key: 'daily',
expiresAt: nextReadyAt, expiresAt: nextReadyAt,
}) })
@@ -138,7 +145,7 @@ export const economyService = {
await txFn.insert(transactions).values({ await txFn.insert(transactions).values({
userId: BigInt(userId), userId: BigInt(userId),
amount: totalReward, amount: totalReward,
type: 'DAILY_REWARD', type: TransactionType.DAILY_REWARD,
description: `Daily reward (Streak: ${streak})`, description: `Daily reward (Streak: ${streak})`,
}); });

View File

@@ -1,6 +1,6 @@
import { ButtonInteraction } from "discord.js"; import { ButtonInteraction } from "discord.js";
import { lootdropService } from "./lootdrop.service"; import { lootdropService } from "./lootdrop.service";
import { createErrorEmbed } from "@/lib/embeds"; import { UserError } from "@/lib/errors";
import { getLootdropClaimedMessage } from "./lootdrop.view"; import { getLootdropClaimedMessage } from "./lootdrop.view";
export async function handleLootdropInteraction(interaction: ButtonInteraction) { export async function handleLootdropInteraction(interaction: ButtonInteraction) {
@@ -9,28 +9,27 @@ export async function handleLootdropInteraction(interaction: ButtonInteraction)
const result = await lootdropService.tryClaim(interaction.message.id, interaction.user.id, interaction.user.username); const result = await lootdropService.tryClaim(interaction.message.id, interaction.user.id, interaction.user.username);
if (result.success) { if (!result.success) {
await interaction.editReply({ throw new UserError(result.error || "Failed to claim.");
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.")]
});
} }
await interaction.editReply({
content: `🎉 You successfully claimed **${result.amount} ${result.currency}**!`
});
const { content, files, components } = await getLootdropClaimedMessage(
interaction.user.id,
interaction.user.username,
interaction.user.displayAvatarURL({ extension: "png" }),
result.amount || 0,
result.currency || "Coins"
);
await interaction.message.edit({
content,
embeds: [],
files,
components
});
} }
} }

View File

@@ -27,7 +27,7 @@ class LootdropService {
// Cleanup interval for activity tracking and expired lootdrops // Cleanup interval for activity tracking and expired lootdrops
setInterval(() => { setInterval(() => {
this.cleanupActivity(); this.cleanupActivity();
this.cleanupExpiredLootdrops(); this.cleanupExpiredLootdrops(true);
}, 60000); }, 60000);
} }
@@ -45,16 +45,24 @@ class LootdropService {
} }
} }
private async cleanupExpiredLootdrops() { public async cleanupExpiredLootdrops(includeClaimed: boolean = false): Promise<number> {
try { try {
const now = new Date(); const now = new Date();
await DrizzleClient.delete(lootdrops) const whereClause = includeClaimed
.where(and( ? lt(lootdrops.expiresAt, now)
isNull(lootdrops.claimedBy), : and(isNull(lootdrops.claimedBy), lt(lootdrops.expiresAt, now));
lt(lootdrops.expiresAt, now)
)); const result = await DrizzleClient.delete(lootdrops)
.where(whereClause)
.returning();
if (result.length > 0) {
console.log(`[LootdropService] Cleaned up ${result.length} expired lootdrops.`);
}
return result.length;
} catch (error) { } catch (error) {
console.error("Failed to cleanup lootdrops:", error); console.error("Failed to cleanup lootdrops:", error);
return 0;
} }
} }
@@ -94,10 +102,10 @@ class LootdropService {
const reward = Math.floor(Math.random() * (max - min + 1)) + min; const reward = Math.floor(Math.random() * (max - min + 1)) + min;
const currency = config.lootdrop.reward.currency; const currency = config.lootdrop.reward.currency;
const { embeds, components } = getLootdropMessage(reward, currency); const { content, files, components } = await getLootdropMessage(reward, currency);
try { try {
const message = await channel.send({ embeds, components }); const message = await channel.send({ content, files, components });
// Persist to DB // Persist to DB
await DrizzleClient.insert(lootdrops).values({ await DrizzleClient.insert(lootdrops).values({

View File

@@ -1,31 +1,29 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
import { createBaseEmbed } from "@lib/embeds"; import { generateLootdropCard, generateClaimedLootdropCard } from "@/graphics/lootdrop";
export function getLootdropMessage(reward: number, currency: string) { export async function getLootdropMessage(reward: number, currency: string) {
const embed = createBaseEmbed( const cardBuffer = await generateLootdropCard(reward, currency);
"💰 LOOTDROP!", const attachment = new AttachmentBuilder(cardBuffer, { name: "lootdrop.png" });
`A lootdrop has appeared! Click the button below to claim **${reward} ${currency}**!`,
"#FFD700"
);
const claimButton = new ButtonBuilder() const claimButton = new ButtonBuilder()
.setCustomId("lootdrop_claim") .setCustomId("lootdrop_claim")
.setLabel("CLAIM REWARD") .setLabel("CLAIM REWARD")
.setStyle(ButtonStyle.Success) .setStyle(ButtonStyle.Secondary) // Changed to Secondary to fit the darker theme better? Or keep Success? Let's try Secondary with custom emoji
.setEmoji("💸"); .setEmoji("🌠");
const row = new ActionRowBuilder<ButtonBuilder>() const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(claimButton); .addComponents(claimButton);
return { embeds: [embed], components: [row] }; return {
content: "",
files: [attachment],
components: [row]
};
} }
export function getLootdropClaimedMessage(originalTitle: string, userId: string, amount: number, currency: string) { export async function getLootdropClaimedMessage(userId: string, username: string, avatarUrl: string, amount: number, currency: string) {
const newEmbed = createBaseEmbed( const cardBuffer = await generateClaimedLootdropCard(amount, currency, username, avatarUrl);
originalTitle || "💰 LOOTDROP!", const attachment = new AttachmentBuilder(cardBuffer, { name: "lootdrop_claimed.png" });
`✅ Claimed by <@${userId}> for **${amount} ${currency}**!`,
"#00FF00"
);
const newRow = new ActionRowBuilder<ButtonBuilder>() const newRow = new ActionRowBuilder<ButtonBuilder>()
.addComponents( .addComponents(
@@ -37,5 +35,9 @@ export function getLootdropClaimedMessage(originalTitle: string, userId: string,
.setDisabled(true) .setDisabled(true)
); );
return { embeds: [newEmbed], components: [newRow] }; return {
content: ``, // Remove content as the image says it all
files: [attachment],
components: [newRow]
};
} }

View File

@@ -1,40 +1,34 @@
import { ButtonInteraction, MessageFlags } from "discord.js"; import { ButtonInteraction, MessageFlags } 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 { createErrorEmbed, createWarningEmbed } from "@/lib/embeds"; import { UserError } from "@/lib/errors";
export async function handleShopInteraction(interaction: ButtonInteraction) { export async function handleShopInteraction(interaction: ButtonInteraction) {
if (!interaction.customId.startsWith("shop_buy_")) return; if (!interaction.customId.startsWith("shop_buy_")) return;
try { await interaction.deferReply({ flags: MessageFlags.Ephemeral });
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const itemId = parseInt(interaction.customId.replace("shop_buy_", "")); const itemId = parseInt(interaction.customId.replace("shop_buy_", ""));
if (isNaN(itemId)) { if (isNaN(itemId)) {
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Item ID.")] }); throw new UserError("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.")] });
} }
const item = await inventoryService.getItem(itemId);
if (!item || !item.price) {
throw new UserError("Item not found or not for sale.");
}
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
if (!user) {
throw new UserError("User profiles could not be loaded. Please try again later.");
}
// Double check balance here too, although service handles it, we want a nice message
if ((user.balance ?? 0n) < item.price) {
throw new UserError(`You need ${item.price} 🪙 to buy this item. You have ${user.balance?.toString() ?? "0"} 🪙.`);
}
await inventoryService.buyItem(user.id.toString(), item.id, 1n);
await interaction.editReply({ content: `✅ **Success!** You bought **${item.name}** for ${item.price} 🪙.` });
} }

View File

@@ -4,7 +4,7 @@ import { config } from "@/lib/config";
import { AuroraClient } from "@/lib/BotClient"; import { AuroraClient } from "@/lib/BotClient";
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view"; import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types"; import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
import { createErrorEmbed, createSuccessEmbed } from "@/lib/embeds"; import { UserError } from "@/lib/errors";
export const handleFeedbackInteraction = async (interaction: Interaction) => { export const handleFeedbackInteraction = async (interaction: Interaction) => {
// Handle select menu for choosing feedback type // Handle select menu for choosing feedback type
@@ -12,11 +12,7 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
const feedbackType = interaction.values[0] as FeedbackType; const feedbackType = interaction.values[0] as FeedbackType;
if (!feedbackType) { if (!feedbackType) {
await interaction.reply({ throw new UserError("Invalid feedback type selected.");
embeds: [createErrorEmbed("Invalid feedback type selected.")],
ephemeral: true
});
return;
} }
const modal = getFeedbackModal(feedbackType); const modal = getFeedbackModal(feedbackType);
@@ -34,79 +30,50 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
if (!feedbackType || !["FEATURE_REQUEST", "BUG_REPORT", "GENERAL"].includes(feedbackType)) { if (!feedbackType || !["FEATURE_REQUEST", "BUG_REPORT", "GENERAL"].includes(feedbackType)) {
console.error(`Invalid feedback type extracted: ${feedbackType} from customId: ${interaction.customId}`); console.error(`Invalid feedback type extracted: ${feedbackType} from customId: ${interaction.customId}`);
await interaction.reply({ throw new UserError("An error occurred processing your feedback. Please try again.");
embeds: [createErrorEmbed("An error occurred processing your feedback. Please try again.")],
ephemeral: true
});
return;
} }
if (!config.feedbackChannelId) { if (!config.feedbackChannelId) {
await interaction.reply({ throw new UserError("Feedback channel is not configured. Please contact an administrator.");
embeds: [createErrorEmbed("Feedback channel is not configured. Please contact an administrator.")],
ephemeral: true
});
return;
} }
try { // Parse modal inputs
// Parse modal inputs const title = interaction.fields.getTextInputValue(FEEDBACK_CUSTOM_IDS.TITLE_FIELD);
const title = interaction.fields.getTextInputValue(FEEDBACK_CUSTOM_IDS.TITLE_FIELD); const description = interaction.fields.getTextInputValue(FEEDBACK_CUSTOM_IDS.DESCRIPTION_FIELD);
const description = interaction.fields.getTextInputValue(FEEDBACK_CUSTOM_IDS.DESCRIPTION_FIELD);
// Build feedback data // Build feedback data
const feedbackData: FeedbackData = { const feedbackData: FeedbackData = {
type: feedbackType, type: feedbackType,
title, title,
description, description,
userId: interaction.user.id, userId: interaction.user.id,
username: interaction.user.username, username: interaction.user.username,
timestamp: new Date() timestamp: new Date()
}; };
// Get feedback channel // Get feedback channel
const channel = await AuroraClient.channels.fetch(config.feedbackChannelId).catch(() => null) as TextChannel | null; const channel = await AuroraClient.channels.fetch(config.feedbackChannelId).catch(() => null) as TextChannel | null;
if (!channel) { if (!channel) {
await interaction.reply({ throw new UserError("Feedback channel not found. Please contact an administrator.");
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
});
}
} }
// 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({
content: "✨ **Feedback Submitted**\nYour feedback has been submitted successfully! Thank you for helping improve Aurora.",
flags: MessageFlags.Ephemeral
});
} }
}; };

View File

@@ -2,6 +2,11 @@ import { levelingService } from "@/modules/leveling/leveling.service";
import { economyService } from "@/modules/economy/economy.service"; import { economyService } from "@/modules/economy/economy.service";
import { userTimers } from "@/db/schema"; import { userTimers } from "@/db/schema";
import type { EffectHandler } from "./types"; import type { EffectHandler } from "./types";
import type { LootTableItem } from "@/lib/types";
import { inventoryService } from "@/modules/inventory/inventory.service";
import { inventory, items } from "@/db/schema";
import { TimerType, TransactionType, LootType } from "@/lib/constants";
// Helper to extract duration in seconds // Helper to extract duration in seconds
const getDuration = (effect: any): number => { const getDuration = (effect: any): number => {
@@ -16,7 +21,7 @@ export const handleAddXp: EffectHandler = async (userId, effect, txFn) => {
}; };
export const handleAddBalance: EffectHandler = async (userId, effect, txFn) => { export const handleAddBalance: EffectHandler = async (userId, effect, txFn) => {
await economyService.modifyUserBalance(userId, BigInt(effect.amount), 'ITEM_USE', `Used Item`, null, txFn); await economyService.modifyUserBalance(userId, BigInt(effect.amount), TransactionType.ITEM_USE, `Used Item`, null, txFn);
return `Gained ${effect.amount} 🪙`; return `Gained ${effect.amount} 🪙`;
}; };
@@ -29,7 +34,7 @@ export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
const expiresAt = new Date(Date.now() + boostDuration * 1000); const expiresAt = new Date(Date.now() + boostDuration * 1000);
await txFn.insert(userTimers).values({ await txFn.insert(userTimers).values({
userId: BigInt(userId), userId: BigInt(userId),
type: 'EFFECT', type: TimerType.EFFECT,
key: 'xp_boost', key: 'xp_boost',
expiresAt: expiresAt, expiresAt: expiresAt,
metadata: { multiplier: effect.multiplier } metadata: { multiplier: effect.multiplier }
@@ -45,7 +50,7 @@ export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000); const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
await txFn.insert(userTimers).values({ await txFn.insert(userTimers).values({
userId: BigInt(userId), userId: BigInt(userId),
type: 'ACCESS', type: TimerType.ACCESS,
key: `role_${effect.roleId}`, key: `role_${effect.roleId}`,
expiresAt: roleExpiresAt, expiresAt: roleExpiresAt,
metadata: { roleId: effect.roleId } metadata: { roleId: effect.roleId }
@@ -60,3 +65,73 @@ export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
export const handleColorRole: EffectHandler = async (_userId, _effect, _txFn) => { export const handleColorRole: EffectHandler = async (_userId, _effect, _txFn) => {
return "Color Role Equipped"; return "Color Role Equipped";
}; };
export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
const pool = effect.pool as LootTableItem[];
if (!pool || pool.length === 0) return "The box is empty...";
const totalWeight = pool.reduce((sum, item) => sum + item.weight, 0);
let random = Math.random() * totalWeight;
let winner: LootTableItem | null = null;
for (const item of pool) {
if (random < item.weight) {
winner = item;
break;
}
random -= item.weight;
}
if (!winner) return "The box is empty..."; // Should not happen
// Process Winner
if (winner.type === LootType.NOTHING) {
return winner.message || "You found nothing inside.";
}
if (winner.type === LootType.CURRENCY) {
let amount = winner.amount || 0;
if (winner.minAmount && winner.maxAmount) {
amount = Math.floor(Math.random() * (winner.maxAmount - winner.minAmount + 1)) + winner.minAmount;
}
if (amount > 0) {
await economyService.modifyUserBalance(userId, BigInt(amount), TransactionType.LOOTBOX, 'Lootbox Reward', null, txFn);
return winner.message || `You found ${amount} 🪙!`;
}
}
if (winner.type === LootType.XP) {
let amount = winner.amount || 0;
if (winner.minAmount && winner.maxAmount) {
amount = Math.floor(Math.random() * (winner.maxAmount - winner.minAmount + 1)) + winner.minAmount;
}
if (amount > 0) {
await levelingService.addXp(userId, BigInt(amount), txFn);
return winner.message || `You gained ${amount} XP!`;
}
}
if (winner.type === LootType.ITEM) {
if (winner.itemId) {
const quantity = BigInt(winner.amount || 1);
await inventoryService.addItem(userId, winner.itemId, quantity, txFn);
// Try to fetch item name for the message
try {
const item = await txFn.query.items.findFirst({
where: (items, { eq }) => eq(items.id, winner.itemId!)
});
if (item) {
return winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`;
}
} catch (e) {
console.error("Failed to fetch item name for lootbox message", e);
}
return winner.message || `You found an item! (ID: ${winner.itemId})`;
}
}
return "You found nothing suitable inside.";
};

View File

@@ -4,7 +4,8 @@ import {
handleReplyMessage, handleReplyMessage,
handleXpBoost, handleXpBoost,
handleTempRole, handleTempRole,
handleColorRole handleColorRole,
handleLootbox
} from "./handlers"; } from "./handlers";
import type { EffectHandler } from "./types"; import type { EffectHandler } from "./types";
@@ -14,5 +15,6 @@ export const effectHandlers: Record<string, EffectHandler> = {
'REPLY_MESSAGE': handleReplyMessage, 'REPLY_MESSAGE': handleReplyMessage,
'XP_BOOST': handleXpBoost, 'XP_BOOST': handleXpBoost,
'TEMP_ROLE': handleTempRole, 'TEMP_ROLE': handleTempRole,
'COLOR_ROLE': handleColorRole 'COLOR_ROLE': handleColorRole,
'LOOTBOX': handleLootbox
}; };

View File

@@ -18,6 +18,8 @@ const mockWhere = mock();
const mockSelect = mock(); const mockSelect = mock();
const mockFrom = mock(); const mockFrom = mock();
const mockOnConflictDoUpdate = mock(); const mockOnConflictDoUpdate = mock();
const mockInnerJoin = mock();
const mockLimit = mock();
// Chain setup // Chain setup
mockInsert.mockReturnValue({ values: mockValues }); mockInsert.mockReturnValue({ values: mockValues });
@@ -34,7 +36,10 @@ mockWhere.mockReturnValue({ returning: mockReturning });
mockDelete.mockReturnValue({ where: mockWhere }); mockDelete.mockReturnValue({ where: mockWhere });
mockSelect.mockReturnValue({ from: mockFrom }); mockSelect.mockReturnValue({ from: mockFrom });
mockFrom.mockReturnValue({ where: mockWhere }); mockFrom.mockReturnValue({ where: mockWhere, innerJoin: mockInnerJoin });
mockInnerJoin.mockReturnValue({ where: mockWhere });
mockWhere.mockReturnValue({ returning: mockReturning, limit: mockLimit });
mockLimit.mockResolvedValue([]);
// Mock DrizzleClient // Mock DrizzleClient
mock.module("@/lib/DrizzleClient", () => { mock.module("@/lib/DrizzleClient", () => {
@@ -239,4 +244,39 @@ describe("inventoryService", () => {
expect(mockDelete).toHaveBeenCalledWith(inventory); // Consume expect(mockDelete).toHaveBeenCalledWith(inventory); // Consume
}); });
}); });
describe("getAutocompleteItems", () => {
it("should return formatted autocomplete results with rarity", async () => {
const mockItems = [
{
item: { id: 1, name: "Common Sword", rarity: "Common", usageData: { effects: [{}] } },
quantity: 5n
},
{
item: { id: 2, name: "Epic Shield", rarity: "Epic", usageData: { effects: [{}] } },
quantity: 1n
}
];
mockLimit.mockResolvedValue(mockItems);
// Restore mocks that might have been polluted by other tests
mockFrom.mockReturnValue({ where: mockWhere, innerJoin: mockInnerJoin });
mockWhere.mockReturnValue({ returning: mockReturning, limit: mockLimit });
const result = await inventoryService.getAutocompleteItems("1", "Sw");
expect(mockSelect).toHaveBeenCalled();
expect(mockFrom).toHaveBeenCalledWith(inventory);
expect(mockInnerJoin).toHaveBeenCalled(); // checks join
expect(mockWhere).toHaveBeenCalled(); // checks filters
expect(mockLimit).toHaveBeenCalledWith(20);
expect(result).toHaveLength(2);
expect(result[0]?.name).toBe("Common Sword (5) [Common]");
expect(result[0]?.value).toBe(1);
expect(result[1]?.name).toBe("Epic Shield (1) [Epic]");
expect(result[1]?.value).toBe(2);
});
});
}); });

View File

@@ -1,5 +1,5 @@
import { inventory, items, users, userTimers } from "@/db/schema"; import { inventory, items, users, userTimers } from "@/db/schema";
import { eq, and, sql, count } from "drizzle-orm"; import { eq, and, sql, count, ilike } 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 { levelingService } from "@/modules/leveling/leveling.service"; import { levelingService } from "@/modules/leveling/leveling.service";
@@ -7,6 +7,7 @@ import { config } from "@/lib/config";
import { UserError } from "@/lib/errors"; import { UserError } from "@/lib/errors";
import { withTransaction } from "@/lib/db"; import { withTransaction } from "@/lib/db";
import type { Transaction, ItemUsageData } from "@/lib/types"; import type { Transaction, ItemUsageData } from "@/lib/types";
import { TransactionType } from "@/lib/constants";
@@ -121,7 +122,7 @@ export const inventoryService = {
const totalPrice = item.price * quantity; const totalPrice = item.price * quantity;
// Deduct Balance using economy service (passing tx ensures atomicity) // Deduct Balance using economy service (passing tx ensures atomicity)
await economyService.modifyUserBalance(userId, -totalPrice, 'PURCHASE', `Bought ${quantity}x ${item.name}`, null, txFn); await economyService.modifyUserBalance(userId, -totalPrice, TransactionType.PURCHASE, `Bought ${quantity}x ${item.name}`, null, txFn);
await inventoryService.addItem(userId, itemId, quantity, txFn); await inventoryService.addItem(userId, itemId, quantity, txFn);
@@ -159,7 +160,6 @@ export const inventoryService = {
const results: string[] = []; const results: string[] = [];
// 2. Apply Effects
// 2. Apply Effects // 2. Apply Effects
const { effectHandlers } = await import("./effects/registry"); const { effectHandlers } = await import("./effects/registry");
@@ -179,7 +179,31 @@ export const inventoryService = {
await inventoryService.removeItem(userId, itemId, 1n, txFn); await inventoryService.removeItem(userId, itemId, 1n, txFn);
} }
return { success: true, results, usageData }; return { success: true, results, usageData, item };
}, tx); }, tx);
},
getAutocompleteItems: async (userId: string, query: string) => {
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)),
ilike(items.name, `%${query}%`)
))
.limit(20);
const filtered = entries.filter(entry => {
const usageData = entry.item.usageData as ItemUsageData | null;
return usageData && usageData.effects && usageData.effects.length > 0;
});
return filtered.map(entry => ({
name: `${entry.item.name} (${entry.quantity}) [${entry.item.rarity || 'Common'}]`,
value: entry.item.id
}));
} }
}; };

View File

@@ -0,0 +1,54 @@
import { EmbedBuilder } from "discord.js";
import type { ItemUsageData } from "@/lib/types";
import { EffectType } from "@/lib/constants";
/**
* Inventory entry with item details
*/
interface InventoryEntry {
quantity: bigint | null;
item: {
id: number;
name: string;
[key: string]: any;
};
}
/**
* Creates an embed displaying a user's inventory
*/
export function getInventoryEmbed(items: InventoryEntry[], username: string): EmbedBuilder {
const description = items.map(entry => {
return `**${entry.item.name}** x${entry.quantity}`;
}).join("\n");
return new EmbedBuilder()
.setTitle(`📦 ${username}'s Inventory`)
.setDescription(description)
.setColor(0x3498db); // Blue
}
/**
* Creates an embed showing the results of using an item
*/
export function getItemUseResultEmbed(results: string[], item?: { name: string, iconUrl: string | null, usageData: any }): EmbedBuilder {
const description = results.map(r => `${r}`).join("\n");
// Check if it was a lootbox
const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === EffectType.LOOTBOX);
const embed = new EmbedBuilder()
.setDescription(description)
.setColor(isLootbox ? 0xFFD700 : 0x2ecc71); // Gold for lootbox, Green otherwise
if (isLootbox && item) {
embed.setTitle(`🎁 ${item.name} Opened!`);
if (item.iconUrl) {
embed.setThumbnail(item.iconUrl);
}
} else {
embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!");
}
return embed;
}

View File

@@ -77,8 +77,8 @@ describe("levelingService", () => {
// base 100, exp 1.5 // base 100, exp 1.5
// lvl 1: 100 * 1^1.5 = 100 // lvl 1: 100 * 1^1.5 = 100
// lvl 2: 100 * 2^1.5 = 100 * 2.828 = 282 // lvl 2: 100 * 2^1.5 = 100 * 2.828 = 282
expect(levelingService.getXpForLevel(1)).toBe(100); expect(levelingService.getXpForNextLevel(1)).toBe(100);
expect(levelingService.getXpForLevel(2)).toBe(282); expect(levelingService.getXpForNextLevel(2)).toBe(282);
}); });
}); });
@@ -123,7 +123,7 @@ describe("levelingService", () => {
expect(result.levelUp).toBe(true); expect(result.levelUp).toBe(true);
expect(result.currentLevel).toBe(2); expect(result.currentLevel).toBe(2);
expect(mockSet).toHaveBeenCalledWith({ xp: 20n, level: 2 }); expect(mockSet).toHaveBeenCalledWith({ xp: 120n, level: 2 });
}); });
it("should handle multiple level ups", async () => { it("should handle multiple level ups", async () => {

View File

@@ -3,6 +3,7 @@ import { eq, sql, and } from "drizzle-orm";
import { withTransaction } from "@/lib/db"; import { withTransaction } from "@/lib/db";
import { config } from "@/lib/config"; import { config } from "@/lib/config";
import type { Transaction } from "@/lib/types"; import type { Transaction } from "@/lib/types";
import { TimerType } from "@/lib/constants";
export const levelingService = { export const levelingService = {
// Calculate total XP required to REACH a specific level (Cumulative) // Calculate total XP required to REACH a specific level (Cumulative)
@@ -78,7 +79,7 @@ export const levelingService = {
const cooldown = await txFn.query.userTimers.findFirst({ const cooldown = await txFn.query.userTimers.findFirst({
where: and( where: and(
eq(userTimers.userId, BigInt(id)), eq(userTimers.userId, BigInt(id)),
eq(userTimers.type, 'COOLDOWN'), eq(userTimers.type, TimerType.COOLDOWN),
eq(userTimers.key, 'chat_xp') eq(userTimers.key, 'chat_xp')
), ),
}); });
@@ -95,7 +96,7 @@ export const levelingService = {
const xpBoost = await txFn.query.userTimers.findFirst({ const xpBoost = await txFn.query.userTimers.findFirst({
where: and( where: and(
eq(userTimers.userId, BigInt(id)), eq(userTimers.userId, BigInt(id)),
eq(userTimers.type, 'EFFECT'), eq(userTimers.type, TimerType.EFFECT),
eq(userTimers.key, 'xp_boost') eq(userTimers.key, 'xp_boost')
) )
}); });
@@ -114,7 +115,7 @@ export const levelingService = {
await txFn.insert(userTimers) await txFn.insert(userTimers)
.values({ .values({
userId: BigInt(id), userId: BigInt(id),
type: 'COOLDOWN', type: TimerType.COOLDOWN,
key: 'chat_xp', key: 'chat_xp',
expiresAt: nextReadyAt, expiresAt: nextReadyAt,
}) })

View File

@@ -0,0 +1,71 @@
import { EmbedBuilder } from "discord.js";
/**
* User data for leaderboard display
*/
interface LeaderboardUser {
username: string;
level: number | null;
xp: bigint | null;
balance: bigint | null;
netWorth?: bigint | null;
}
/**
* Returns the appropriate medal emoji for a ranking position
*/
function getMedalEmoji(index: number): string {
if (index === 0) return "🥇";
if (index === 1) return "🥈";
if (index === 2) return "🥉";
return `${index + 1}.`;
}
/**
* Formats a single leaderboard entry based on type
*/
function formatLeaderEntry(user: LeaderboardUser, index: number, type: 'xp' | 'balance' | 'networth'): string {
const medal = getMedalEmoji(index);
let value = '';
switch (type) {
case 'xp':
value = `Lvl ${user.level ?? 1} (${user.xp ?? 0n} XP)`;
break;
case 'balance':
value = `${user.balance ?? 0n} 🪙`;
break;
case 'networth':
value = `${user.netWorth ?? 0n} 🪙 (Net Worth)`;
break;
}
return `${medal} **${user.username}** — ${value}`;
}
/**
* Creates a leaderboard embed for either XP, Balance or Net Worth rankings
*/
export function getLeaderboardEmbed(leaders: LeaderboardUser[], type: 'xp' | 'balance' | 'networth'): EmbedBuilder {
const description = leaders.map((user, index) =>
formatLeaderEntry(user, index, type)
).join("\n");
let title = '';
switch (type) {
case 'xp':
title = "🏆 XP Leaderboard";
break;
case 'balance':
title = "💰 Richest Players";
break;
case 'networth':
title = "💎 Net Worth Leaderboard";
break;
}
return new EmbedBuilder()
.setTitle(title)
.setDescription(description)
.setColor(0xFFD700); // Gold
}

View File

@@ -0,0 +1,291 @@
import { describe, it, expect, mock, beforeEach } from "bun:test";
import { ModerationService } from "./moderation.service";
import { moderationCases } from "@/db/schema";
import { CaseType } from "@/lib/constants";
// Mock Drizzle Functions
const mockFindFirst = mock();
const mockFindMany = mock();
const mockInsert = mock();
const mockUpdate = mock();
const mockValues = mock();
const mockReturning = mock();
const mockSet = mock();
const mockWhere = mock();
// Mock Config
const mockConfig = {
moderation: {
cases: {
dmOnWarn: true,
autoTimeoutThreshold: 3
}
}
};
mock.module("@/lib/config", () => ({
config: mockConfig
}));
// Mock View
const mockGetUserWarningEmbed = mock(() => ({}));
mock.module("./moderation.view", () => ({
getUserWarningEmbed: mockGetUserWarningEmbed
}));
// Mock DrizzleClient
mock.module("@/lib/DrizzleClient", () => ({
DrizzleClient: {
query: {
moderationCases: {
findFirst: mockFindFirst,
findMany: mockFindMany,
},
},
insert: mockInsert,
update: mockUpdate,
}
}));
// Setup chains
mockInsert.mockReturnValue({ values: mockValues });
mockValues.mockReturnValue({ returning: mockReturning });
mockUpdate.mockReturnValue({ set: mockSet });
mockSet.mockReturnValue({ where: mockWhere });
mockWhere.mockReturnValue({ returning: mockReturning });
describe("ModerationService", () => {
beforeEach(() => {
mockFindFirst.mockReset();
mockFindMany.mockReset();
mockInsert.mockClear();
mockUpdate.mockClear();
mockValues.mockClear();
mockReturning.mockClear();
mockSet.mockClear();
mockWhere.mockClear();
mockGetUserWarningEmbed.mockClear();
// Reset config to defaults
mockConfig.moderation.cases.dmOnWarn = true;
mockConfig.moderation.cases.autoTimeoutThreshold = 3;
});
describe("issueWarning", () => {
const defaultOptions = {
userId: "123456789",
username: "testuser",
moderatorId: "987654321",
moderatorName: "mod",
reason: "test reason",
guildName: "Test Guild"
};
it("should issue a warning and attempt to DM the user", async () => {
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
mockReturning.mockResolvedValue([{ caseId: "CASE-0002" }]);
mockFindMany.mockResolvedValue([{ type: CaseType.WARN, active: true }]); // 1 warning total
const mockDmTarget = { send: mock() };
const result = await ModerationService.issueWarning({
...defaultOptions,
dmTarget: mockDmTarget
});
expect(result.moderationCase).toBeDefined();
expect(result.warningCount).toBe(1);
expect(mockDmTarget.send).toHaveBeenCalled();
expect(mockGetUserWarningEmbed).toHaveBeenCalled();
});
it("should not DM if dmOnWarn is false", async () => {
mockConfig.moderation.cases.dmOnWarn = false;
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
mockReturning.mockResolvedValue([{ caseId: "CASE-0002" }]);
mockFindMany.mockResolvedValue([]);
const mockDmTarget = { send: mock() };
await ModerationService.issueWarning({
...defaultOptions,
dmTarget: mockDmTarget
});
expect(mockDmTarget.send).not.toHaveBeenCalled();
});
it("should trigger auto-timeout when threshold is reached", async () => {
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
mockReturning.mockResolvedValue([{ caseId: "CASE-0002" }]);
// Simulate 3 warnings (threshold is 3)
mockFindMany.mockResolvedValue([{}, {}, {}]);
const mockTimeoutTarget = { timeout: mock() };
const result = await ModerationService.issueWarning({
...defaultOptions,
timeoutTarget: mockTimeoutTarget
});
expect(result.autoTimeoutIssued).toBe(true);
expect(mockTimeoutTarget.timeout).toHaveBeenCalledWith(86400000, expect.stringContaining("3 warnings"));
// Should create two cases: one for warn, one for timeout
expect(mockInsert).toHaveBeenCalledTimes(2);
});
it("should not timeout if threshold is not reached", async () => {
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
mockReturning.mockResolvedValue([{ caseId: "CASE-0002" }]);
// Simulate 2 warnings (threshold is 3)
mockFindMany.mockResolvedValue([{}, {}]);
const mockTimeoutTarget = { timeout: mock() };
const result = await ModerationService.issueWarning({
...defaultOptions,
timeoutTarget: mockTimeoutTarget
});
expect(result.autoTimeoutIssued).toBe(false);
expect(mockTimeoutTarget.timeout).not.toHaveBeenCalled();
expect(mockInsert).toHaveBeenCalledTimes(1);
});
});
describe("getNextCaseId", () => {
it("should return CASE-0001 if no cases exist", async () => {
mockFindFirst.mockResolvedValue(undefined);
// Accessing private method via bracket notation for testing
const nextId = await (ModerationService as any).getNextCaseId();
expect(nextId).toBe("CASE-0001");
});
it("should increment the latest case ID", async () => {
mockFindFirst.mockResolvedValue({ caseId: "CASE-0042" });
const nextId = await (ModerationService as any).getNextCaseId();
expect(nextId).toBe("CASE-0043");
});
it("should handle padding correctly (e.g., 9 -> 0010)", async () => {
mockFindFirst.mockResolvedValue({ caseId: "CASE-0009" });
const nextId = await (ModerationService as any).getNextCaseId();
expect(nextId).toBe("CASE-0010");
});
});
describe("createCase", () => {
it("should create a new moderation case with correct values", async () => {
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
const mockNewCase = {
caseId: "CASE-0002",
type: CaseType.WARN,
userId: 123456789n,
username: "testuser",
moderatorId: 987654321n,
moderatorName: "mod",
reason: "test reason",
metadata: {},
active: true
};
mockReturning.mockResolvedValue([mockNewCase]);
const result = await ModerationService.createCase({
type: CaseType.WARN,
userId: "123456789",
username: "testuser",
moderatorId: "987654321",
moderatorName: "mod",
reason: "test reason"
});
expect(result?.caseId).toBe("CASE-0002");
expect(mockInsert).toHaveBeenCalled();
expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({
caseId: "CASE-0002",
type: CaseType.WARN,
userId: 123456789n,
reason: "test reason"
}));
});
it("should set active to false for non-warn types", async () => {
mockFindFirst.mockResolvedValue(undefined);
mockReturning.mockImplementation((values) => [values]); // Simplified mock
const result = await ModerationService.createCase({
type: CaseType.BAN,
userId: "123456789",
username: "testuser",
moderatorId: "987654321",
moderatorName: "mod",
reason: "test reason"
});
expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({
active: false
}));
});
});
describe("getCaseById", () => {
it("should return a case by its ID", async () => {
const mockCase = { caseId: "CASE-0001", reason: "test" };
mockFindFirst.mockResolvedValue(mockCase);
const result = await ModerationService.getCaseById("CASE-0001");
expect(result).toEqual(mockCase as any);
});
});
describe("getUserCases", () => {
it("should return all cases for a user", async () => {
const mockCases = [{ caseId: "CASE-0001" }, { caseId: "CASE-0002" }];
mockFindMany.mockResolvedValue(mockCases);
const result = await ModerationService.getUserCases("123456789");
expect(result).toHaveLength(2);
expect(mockFindMany).toHaveBeenCalled();
});
});
describe("clearCase", () => {
it("should update a case to be inactive and resolved", async () => {
const mockUpdatedCase = { caseId: "CASE-0001", active: false };
mockReturning.mockResolvedValue([mockUpdatedCase]);
const result = await ModerationService.clearCase({
caseId: "CASE-0001",
clearedBy: "987654321",
clearedByName: "mod",
reason: "resolved"
});
expect(result?.active).toBe(false);
expect(mockUpdate).toHaveBeenCalled();
expect(mockSet).toHaveBeenCalledWith(expect.objectContaining({
active: false,
resolvedBy: 987654321n,
resolvedReason: "resolved"
}));
});
});
describe("getActiveWarningCount", () => {
it("should return the number of active warnings", async () => {
mockFindMany.mockResolvedValue([
{ id: 1n, type: CaseType.WARN, active: true },
{ id: 2n, type: CaseType.WARN, active: true }
]);
const count = await ModerationService.getActiveWarningCount("123456789");
expect(count).toBe(2);
});
it("should return 0 if no active warnings", async () => {
mockFindMany.mockResolvedValue([]);
const count = await ModerationService.getActiveWarningCount("123456789");
expect(count).toBe(0);
});
});
});

View File

@@ -1,7 +1,10 @@
import { moderationCases } from "@/db/schema"; import { moderationCases } from "@/db/schema";
import { eq, and, desc } from "drizzle-orm"; import { eq, and, desc } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { DrizzleClient } from "@/lib/DrizzleClient";
import type { CreateCaseOptions, ClearCaseOptions, SearchCasesFilter, CaseType } from "./moderation.types"; import type { CreateCaseOptions, ClearCaseOptions, SearchCasesFilter } from "./moderation.types";
import { config } from "@/lib/config";
import { getUserWarningEmbed } from "./moderation.view";
import { CaseType } from "@/lib/constants";
export class ModerationService { export class ModerationService {
/** /**
@@ -41,12 +44,85 @@ export class ModerationService {
moderatorName: options.moderatorName, moderatorName: options.moderatorName,
reason: options.reason, reason: options.reason,
metadata: options.metadata || {}, metadata: options.metadata || {},
active: options.type === 'warn' ? true : false, // Only warnings are "active" by default active: options.type === CaseType.WARN ? true : false, // Only warnings are "active" by default
}).returning(); }).returning();
return newCase; return newCase;
} }
/**
* Issue a warning with DM and threshold logic
*/
static async issueWarning(options: {
userId: string;
username: string;
moderatorId: string;
moderatorName: string;
reason: string;
guildName?: string;
dmTarget?: { send: (options: any) => Promise<any> };
timeoutTarget?: { timeout: (duration: number, reason: string) => Promise<any> };
}) {
const moderationCase = await this.createCase({
type: CaseType.WARN,
userId: options.userId,
username: options.username,
moderatorId: options.moderatorId,
moderatorName: options.moderatorName,
reason: options.reason,
});
if (!moderationCase) {
throw new Error("Failed to create moderation case");
}
const warningCount = await this.getActiveWarningCount(options.userId);
// Try to DM the user if configured
if (config.moderation.cases.dmOnWarn && options.dmTarget) {
try {
await options.dmTarget.send({
embeds: [getUserWarningEmbed(
options.guildName || 'this server',
options.reason,
moderationCase.caseId,
warningCount
)]
});
} catch (error) {
console.log(`Could not DM warning to ${options.username}: ${error}`);
}
}
// Check for auto-timeout threshold
let autoTimeoutIssued = false;
if (config.moderation.cases.autoTimeoutThreshold &&
warningCount >= config.moderation.cases.autoTimeoutThreshold &&
options.timeoutTarget) {
try {
// Auto-timeout for 24 hours (86400000 ms)
await options.timeoutTarget.timeout(86400000, `Automatic timeout: ${warningCount} warnings`);
// Create a timeout case
await this.createCase({
type: CaseType.TIMEOUT,
userId: options.userId,
username: options.username,
moderatorId: "0", // System/Bot
moderatorName: "System",
reason: `Automatic timeout: reached ${warningCount} warnings`,
metadata: { duration: '24h', automatic: true }
});
autoTimeoutIssued = true;
} catch (error) {
console.error('Failed to auto-timeout user:', error);
}
}
return { moderationCase, warningCount, autoTimeoutIssued };
}
/** /**
* Get a case by its case ID * Get a case by its case ID
*/ */
@@ -79,7 +155,7 @@ export class ModerationService {
return await DrizzleClient.query.moderationCases.findMany({ return await DrizzleClient.query.moderationCases.findMany({
where: and( where: and(
eq(moderationCases.userId, BigInt(userId)), eq(moderationCases.userId, BigInt(userId)),
eq(moderationCases.type, 'warn'), eq(moderationCases.type, CaseType.WARN),
eq(moderationCases.active, true) eq(moderationCases.active, true)
), ),
orderBy: [desc(moderationCases.createdAt)], orderBy: [desc(moderationCases.createdAt)],
@@ -93,7 +169,7 @@ export class ModerationService {
return await DrizzleClient.query.moderationCases.findMany({ return await DrizzleClient.query.moderationCases.findMany({
where: and( where: and(
eq(moderationCases.userId, BigInt(userId)), eq(moderationCases.userId, BigInt(userId)),
eq(moderationCases.type, 'note') eq(moderationCases.type, CaseType.NOTE)
), ),
orderBy: [desc(moderationCases.createdAt)], orderBy: [desc(moderationCases.createdAt)],
}); });

View File

@@ -1,4 +1,6 @@
export type CaseType = 'warn' | 'timeout' | 'kick' | 'ban' | 'note' | 'prune'; import { CaseType } from "@/lib/constants";
export { CaseType };
export interface CreateCaseOptions { export interface CreateCaseOptions {
type: CaseType; type: CaseType;

View File

@@ -6,6 +6,7 @@ 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 { withTransaction } from "@/lib/db";
import type { Transaction } from "@/lib/types"; import type { Transaction } from "@/lib/types";
import { TransactionType } from "@/lib/constants";
export const questService = { export const questService = {
assignQuest: async (userId: string, questId: number, tx?: Transaction) => { assignQuest: async (userId: string, questId: number, tx?: Transaction) => {
@@ -62,7 +63,7 @@ export const questService = {
if (rewards?.balance) { if (rewards?.balance) {
const bal = BigInt(rewards.balance); const bal = BigInt(rewards.balance);
await economyService.modifyUserBalance(userId, bal, 'QUEST_REWARD', `Reward for quest ${questId}`, null, txFn); await economyService.modifyUserBalance(userId, bal, TransactionType.QUEST_REWARD, `Reward for quest ${questId}`, null, txFn);
results.balance = bal; results.balance = bal;
} }

View File

@@ -0,0 +1,54 @@
import { EmbedBuilder } from "discord.js";
/**
* Quest entry with quest details and progress
*/
interface QuestEntry {
progress: number | null;
completedAt: Date | null;
quest: {
name: string;
description: string | null;
rewards: any;
};
}
/**
* Formats quest rewards object into a human-readable string
*/
function formatQuestRewards(rewards: { xp?: number, balance?: number }): string {
const rewardStr: string[] = [];
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`);
return rewardStr.join(", ");
}
/**
* Returns the quest status display string
*/
function getQuestStatus(completedAt: Date | null): string {
return completedAt ? "✅ Completed" : "📝 In Progress";
}
/**
* Creates an embed displaying a user's quest log
*/
export function getQuestListEmbed(userQuests: QuestEntry[]): EmbedBuilder {
const embed = new EmbedBuilder()
.setTitle("📜 Quest Log")
.setColor(0x3498db); // Blue
userQuests.forEach(entry => {
const status = getQuestStatus(entry.completedAt);
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
const rewardsText = formatQuestRewards(rewards);
embed.addFields({
name: `${entry.quest.name} (${status})`,
value: `${entry.quest.description}\n**Rewards:** ${rewardsText}\n**Progress:** ${entry.progress}%`,
inline: false
});
});
return embed;
}

View File

@@ -1,86 +1,21 @@
import { userTimers } from "@/db/schema"; import { temporaryRoleService } from "./temp-role.service";
import { eq, and, lt } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient";
import { AuroraClient } from "@/lib/BotClient";
import { env } from "@/lib/env";
/**
* The Janitor responsible for cleaning up expired ACCESS timers
* and revoking privileges.
*/
export const schedulerService = { export const schedulerService = {
start: () => { start: () => {
console.log("🕒 Scheduler started: Janitor loop running every 60s"); console.log("🕒 Scheduler started: Maintenance loops initialized.");
// Run immediately on start
schedulerService.runJanitor();
// Loop every 60 seconds // 1. Temporary Role Revocation (every 60s)
setInterval(() => { setInterval(() => {
schedulerService.runJanitor(); temporaryRoleService.processExpiredRoles();
}, 60 * 1000); }, 60 * 1000);
// Terminal Update Loop (every 60s) // 2. Terminal Update Loop (every 60s)
const { terminalService } = require("@/modules/terminal/terminal.service"); const { terminalService } = require("@/modules/terminal/terminal.service");
setInterval(() => { setInterval(() => {
terminalService.update(); terminalService.update();
}, 60 * 1000); }, 60 * 1000);
},
runJanitor: async () => { // Run an initial check on start
try { temporaryRoleService.processExpiredRoles();
// Find all expired ACCESS timers
// We do this in a transaction to ensure we read and delete atomically if possible,
// though for this specific case, fetching then deleting is fine as long as we handle race conditions gracefully.
const now = new Date();
const expiredAccess = await DrizzleClient.query.userTimers.findMany({
where: and(
eq(userTimers.type, 'ACCESS'),
lt(userTimers.expiresAt, now)
)
});
if (expiredAccess.length === 0) return;
console.log(`🧹 Janitor: Found ${expiredAccess.length} expired access timers.`);
for (const timer of expiredAccess) {
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'})`);
// TODO: Generic channel permission removal if needed
}
// Delete the timer row
await DrizzleClient.delete(userTimers)
.where(and(
eq(userTimers.userId, timer.userId),
eq(userTimers.type, timer.type),
eq(userTimers.key, timer.key)
));
}
} catch (error) {
console.error("Janitor Error:", error);
}
} }
}; };

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, mock, beforeEach } from "bun:test";
import { temporaryRoleService } from "./temp-role.service";
import { userTimers } from "@/db/schema";
import { TimerType } from "@/lib/constants";
const mockDelete = mock();
const mockWhere = mock();
const mockFindMany = mock();
mockDelete.mockReturnValue({ where: mockWhere });
// Mock DrizzleClient
mock.module("@/lib/DrizzleClient", () => ({
DrizzleClient: {
delete: mockDelete,
query: {
userTimers: {
findMany: mockFindMany
}
}
}
}));
// Mock AuroraClient
const mockRemoveRole = mock();
const mockFetchMember = mock();
const mockFetchGuild = mock();
mock.module("@/lib/BotClient", () => ({
AuroraClient: {
guilds: {
fetch: mockFetchGuild
}
}
}));
mock.module("@/lib/env", () => ({
env: {
DISCORD_GUILD_ID: "guild123"
}
}));
describe("temporaryRoleService", () => {
beforeEach(() => {
mockDelete.mockClear();
mockWhere.mockClear();
mockFindMany.mockClear();
mockRemoveRole.mockClear();
mockFetchMember.mockClear();
mockFetchGuild.mockClear();
mockFetchGuild.mockResolvedValue({
members: {
fetch: mockFetchMember
}
});
mockFetchMember.mockResolvedValue({
user: { tag: "TestUser#1234" },
roles: { remove: mockRemoveRole }
});
});
it("should revoke expired roles and delete timers", async () => {
// Mock findMany to return an expired role timer
mockFindMany.mockResolvedValue([
{
userId: 123n,
type: TimerType.ACCESS,
key: 'role_456',
expiresAt: new Date(Date.now() - 1000),
metadata: { roleId: '456' }
}
]);
const count = await temporaryRoleService.processExpiredRoles();
expect(count).toBe(1);
expect(mockFetchGuild).toHaveBeenCalledWith("guild123");
expect(mockFetchMember).toHaveBeenCalledWith("123");
expect(mockRemoveRole).toHaveBeenCalledWith("456");
expect(mockDelete).toHaveBeenCalledWith(userTimers);
});
it("should still delete the timer even if member is not found", async () => {
mockFindMany.mockResolvedValue([
{
userId: 999n,
type: TimerType.ACCESS,
key: 'role_789',
expiresAt: new Date(Date.now() - 1000),
metadata: {}
}
]);
// Mock member fetch failure
mockFetchMember.mockRejectedValue(new Error("Member not found"));
const count = await temporaryRoleService.processExpiredRoles();
expect(count).toBe(1);
expect(mockRemoveRole).not.toHaveBeenCalled();
expect(mockDelete).toHaveBeenCalledWith(userTimers);
});
it("should return 0 if no expired timers exist", async () => {
mockFindMany.mockResolvedValue([]);
const count = await temporaryRoleService.processExpiredRoles();
expect(count).toBe(0);
expect(mockDelete).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,67 @@
import { userTimers } from "@/db/schema";
import { eq, and, lt } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient";
import { AuroraClient } from "@/lib/BotClient";
import { env } from "@/lib/env";
import { TimerType } from "@/lib/constants";
export const temporaryRoleService = {
/**
* Checks for and revokes expired temporary roles.
* This is intended to run as a high-frequency maintenance task.
*/
processExpiredRoles: async (): Promise<number> => {
const now = new Date();
let revokedCount = 0;
// Find all expired ACCESS (temporary role) timers
const expiredTimers = await DrizzleClient.query.userTimers.findMany({
where: and(
eq(userTimers.type, TimerType.ACCESS),
lt(userTimers.expiresAt, now)
)
});
if (expiredTimers.length === 0) return 0;
for (const timer of expiredTimers) {
const userIdStr = timer.userId.toString();
const meta = timer.metadata as any;
// We only handle keys that indicate role management
if (timer.key.startsWith('role_')) {
try {
const roleId = meta?.roleId || timer.key.replace('role_', '');
const guildId = env.DISCORD_GUILD_ID;
if (guildId) {
const guild = await AuroraClient.guilds.fetch(guildId);
const member = await guild.members.fetch(userIdStr).catch(() => null);
if (member) {
await member.roles.remove(roleId);
console.log(`👋 Temporary role ${roleId} revoked from ${member.user.tag} (Expired)`);
} else {
console.log(`⚠️ Could not find member ${userIdStr} to revoke role ${roleId}.`);
}
}
} catch (err) {
console.error(`❌ Failed to revoke role for user ${userIdStr}:`, err);
}
}
// Always delete the timer record after trying to revoke (or if it's not a role key)
// to prevent repeated failed attempts.
await DrizzleClient.delete(userTimers)
.where(and(
eq(userTimers.userId, timer.userId),
eq(userTimers.type, timer.type),
eq(userTimers.key, timer.key)
));
revokedCount++;
}
return revokedCount;
}
};

View File

@@ -1,13 +1,30 @@
import { TextChannel, Message, ContainerBuilder, TextDisplayBuilder, SectionBuilder, MessageFlags } from "discord.js"; import {
TextChannel,
ContainerBuilder,
TextDisplayBuilder,
SectionBuilder,
SeparatorBuilder,
ThumbnailBuilder,
MessageFlags,
SeparatorSpacingSize
} from "discord.js";
import { AuroraClient } from "@/lib/BotClient"; import { AuroraClient } from "@/lib/BotClient";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { DrizzleClient } from "@/lib/DrizzleClient";
import { users, transactions, lootdrops } from "@/db/schema"; import { users, transactions, lootdrops, inventory } from "@/db/schema";
import { desc } from "drizzle-orm"; import { desc, sql } from "drizzle-orm";
import { config, saveConfig } from "@/lib/config"; import { config, saveConfig } from "@/lib/config";
// Color palette for containers (hex as decimal)
const COLORS = {
HEADER: 0x9B59B6, // Purple - mystical
LEADERS: 0xF1C40F, // Gold - achievement
ACTIVITY: 0x3498DB, // Blue - activity
ALERT: 0xE74C3C // Red - active events
};
export const terminalService = { export const terminalService = {
init: async (channel: TextChannel) => { init: async (channel: TextChannel) => {
// limit to one terminal for now // Limit to one terminal for now
if (config.terminal) { if (config.terminal) {
try { try {
const oldChannel = await AuroraClient.channels.fetch(config.terminal.channelId) as TextChannel; const oldChannel = await AuroraClient.channels.fetch(config.terminal.channelId) as TextChannel;
@@ -16,11 +33,11 @@ export const terminalService = {
if (oldMsg) await oldMsg.delete(); if (oldMsg) await oldMsg.delete();
} }
} catch (e) { } catch (e) {
// ignore if old message doesn't exist // Ignore if old message doesn't exist
} }
} }
const msg = await channel.send({ content: "🔄 Initializing Aurora Observatory..." }); const msg = await channel.send({ content: "🔄 Initializing Aurora Station..." });
config.terminal = { config.terminal = {
channelId: channel.id, channelId: channel.id,
@@ -48,8 +65,6 @@ export const terminalService = {
const containers = await terminalService.buildMessage(); 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({ await message.edit({
content: null, content: null,
embeds: null as any, embeds: null as any,
@@ -64,24 +79,51 @@ export const terminalService = {
}, },
buildMessage: async () => { buildMessage: async () => {
// 1. Data Fetching // ═══════════════════════════════════════════════════════════
// DATA FETCHING
// ═══════════════════════════════════════════════════════════
const allUsers = await DrizzleClient.select().from(users); const allUsers = await DrizzleClient.select().from(users);
const totalUsers = allUsers.length; const totalUsers = allUsers.length;
const totalWealth = allUsers.reduce((acc, u) => acc + (u.balance || 0n), 0n); const totalWealth = allUsers.reduce((acc, u) => acc + (u.balance || 0n), 0n);
// 2. Leaderboards Calculation // System stats
const topLevels = [...allUsers].sort((a, b) => (b.level || 0) - (a.level || 0)).slice(0, 3); const uptime = process.uptime();
const topWealth = [...allUsers].sort((a, b) => Number(b.balance || 0n) - Number(a.balance || 0n)).slice(0, 3); const uptimeHours = Math.floor(uptime / 3600);
const uptimeMinutes = Math.floor((uptime % 3600) / 60);
const ping = AuroraClient.ws.ping;
const now = Math.floor(Date.now() / 1000);
const formatUser = (u: typeof users.$inferSelect, i: number) => { // Guild member count (if available)
const star = i === 0 ? "🌟" : i === 1 ? "⭐" : "✨"; const guild = AuroraClient.guilds.cache.first();
return `${star} <@${u.id}>`; const memberCount = guild?.memberCount ?? totalUsers;
};
const levelText = topLevels.map((u, i) => `> ${formatUser(u, i)} • Lvl ${u.level}`).join("\n") || "> *The sky is empty...*"; // Additional metrics
const wealthText = topWealth.map((u, i) => `> ${formatUser(u, i)}${u.balance} AU`).join("\n") || "> *The sky is empty...*"; const avgLevel = totalUsers > 0
? Math.round(allUsers.reduce((acc, u) => acc + (u.level || 1), 0) / totalUsers)
: 1;
const topStreak = allUsers.reduce((max, u) => Math.max(max, u.dailyStreak || 0), 0);
// 3. Lootdrops Data // Items in circulation
const itemsResult = await DrizzleClient
.select({ total: sql<string>`COALESCE(SUM(${inventory.quantity}), 0)` })
.from(inventory);
const totalItems = Number(itemsResult[0]?.total || 0);
// Last command timestamp
const lastCmd = AuroraClient.lastCommandTimestamp
? `<t:${Math.floor(AuroraClient.lastCommandTimestamp / 1000)}:R>`
: "*Never*";
// Leaderboards
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);
// Lootdrops
const activeDrops = await DrizzleClient.query.lootdrops.findMany({ const activeDrops = await DrizzleClient.query.lootdrops.findMany({
where: (lootdrops, { isNull }) => isNull(lootdrops.claimedBy), where: (lootdrops, { isNull }) => isNull(lootdrops.claimedBy),
limit: 1, limit: 1,
@@ -94,65 +136,166 @@ export const terminalService = {
orderBy: desc(lootdrops.createdAt) orderBy: desc(lootdrops.createdAt)
}); });
// --- CONTAINER 1: Header --- // Recent transactions
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({ const recentTx = await DrizzleClient.query.transactions.findMany({
limit: 5, limit: 3,
orderBy: [desc(transactions.createdAt)] orderBy: [desc(transactions.createdAt)]
}); });
const activityLines = recentTx.map(tx => { // ═══════════════════════════════════════════════════════════
const time = Math.floor(tx.createdAt!.getTime() / 1000); // HELPER FORMATTERS
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 getMedal = (i: number) => i === 0 ? "🥇" : i === 1 ? "🥈" : "🥉";
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() const formatLeaderEntry = (u: typeof users.$inferSelect, i: number, type: 'level' | 'wealth') => {
const medal = getMedal(i);
const value = type === 'level'
? `Lvl ${u.level ?? 1}`
: `${Number(u.balance ?? 0).toLocaleString()} AU`;
return `${medal} **${u.username}** — ${value}`;
};
const getActivityIcon = (type: string) => {
if (type.includes("LOOT")) return "🌠";
if (type.includes("GIFT")) return "🎁";
if (type.includes("SHOP")) return "🛒";
if (type.includes("DAILY")) return "☀️";
if (type.includes("QUEST")) return "📜";
return "💫";
};
// ═══════════════════════════════════════════════════════════
// CONTAINER 1: HEADER - Station Overview
// ═══════════════════════════════════════════════════════════
const botAvatar = AuroraClient.user?.displayAvatarURL({ size: 64 }) ?? "";
const headerSection = new SectionBuilder()
.addTextDisplayComponents( .addTextDisplayComponents(
new TextDisplayBuilder().setContent("## 📡 COSMIC ECHOES"), new TextDisplayBuilder().setContent("# 🔮 AURORA STATION"),
new TextDisplayBuilder().setContent(activityLines.join("\n") || "Silence...") new TextDisplayBuilder().setContent("-# Real-time server observatory")
)
.setThumbnailAccessory(
new ThumbnailBuilder().setURL(botAvatar)
); );
const statsText = [
`📡 **Uptime** ${uptimeHours}h ${uptimeMinutes}m`,
`🏓 **Ping** ${ping}ms`,
`👥 **Students** ${totalUsers}`,
`🪙 **Economy** ${totalWealth.toLocaleString()} AU`
].join(" • ");
return [headerContainer, logContainer, leaderContainer, echoesContainer]; const secondaryStats = [
`📦 **Items** ${totalItems.toLocaleString()}`,
`📈 **Avg Lvl** ${avgLevel}`,
`🔥 **Top Streak** ${topStreak}d`,
`⚡ **Last Cmd** ${lastCmd}`
].join(" • ");
const headerContainer = new ContainerBuilder()
.setAccentColor(COLORS.HEADER)
.addSectionComponents(headerSection)
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small))
.addTextDisplayComponents(
new TextDisplayBuilder().setContent(statsText),
new TextDisplayBuilder().setContent(secondaryStats),
new TextDisplayBuilder().setContent(`-# Updated <t:${now}:R>`)
);
// ═══════════════════════════════════════════════════════════
// CONTAINER 2: LEADERBOARDS
// ═══════════════════════════════════════════════════════════
const levelLeaderText = topLevels.length > 0
? topLevels.map((u, i) => formatLeaderEntry(u, i, 'level')).join("\n")
: "*No data yet*";
const wealthLeaderText = topWealth.length > 0
? topWealth.map((u, i) => formatLeaderEntry(u, i, 'wealth')).join("\n")
: "*No data yet*";
const leadersContainer = new ContainerBuilder()
.setAccentColor(COLORS.LEADERS)
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("## ✨ CONSTELLATION LEADERS")
)
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small))
.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`**Brightest Stars**\n${levelLeaderText}`),
new TextDisplayBuilder().setContent(`**Gilded Nebulas**\n${wealthLeaderText}`)
);
// ═══════════════════════════════════════════════════════════
// CONTAINER 3: LIVE ACTIVITY
// ═══════════════════════════════════════════════════════════
// Determine if there's an active lootdrop
const hasActiveDrop = activeDrops.length > 0 && activeDrops[0];
const activityColor = hasActiveDrop ? COLORS.ALERT : COLORS.ACTIVITY;
const activityContainer = new ContainerBuilder()
.setAccentColor(activityColor)
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("## 🌠 LIVE ACTIVITY")
)
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
// Active lootdrop or recent event
if (hasActiveDrop) {
const drop = activeDrops[0]!;
const expiresTimestamp = Math.floor(drop.expiresAt!.getTime() / 1000);
activityContainer.addTextDisplayComponents(
new TextDisplayBuilder().setContent(
`🚨 **SHOOTING STAR ACTIVE**\n` +
`> **Reward:** \`${drop.rewardAmount} ${drop.currency}\`\n` +
`> **Location:** <#${drop.channelId}>\n` +
`> **Expires:** <t:${expiresTimestamp}:R>`
)
);
} else if (recentDrops.length > 0 && recentDrops[0]) {
const drop = recentDrops[0];
const claimer = allUsers.find(u => u.id === drop.claimedBy);
const claimedTimestamp = drop.createdAt ? Math.floor(drop.createdAt.getTime() / 1000) : now;
activityContainer.addTextDisplayComponents(
new TextDisplayBuilder().setContent(
`✅ **Last Star Claimed**\n` +
`> **${claimer?.username ?? 'Unknown'}** collected \`${drop.rewardAmount} ${drop.currency}\` <t:${claimedTimestamp}:R>`
)
);
} else {
activityContainer.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`-# The sky is quiet... waiting for the next star.`)
);
}
// Recent transactions
if (recentTx.length > 0) {
activityContainer.addSeparatorComponents(
new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)
);
const txLines = recentTx.map(tx => {
const time = Math.floor(tx.createdAt!.getTime() / 1000);
const icon = getActivityIcon(tx.type);
const user = allUsers.find(u => u.id === tx.userId);
// Clean description (remove trailing channel IDs)
let desc = tx.description || "Unknown";
desc = desc.replace(/\s*\d{17,19}\s*$/, "").trim();
return `${icon} **${user?.username ?? 'Unknown'}**: ${desc} · <t:${time}:R>`;
});
activityContainer.addTextDisplayComponents(
new TextDisplayBuilder().setContent("**Recent Echoes**"),
new TextDisplayBuilder().setContent(txLines.join("\n"))
);
}
return [headerContainer, leadersContainer, activityContainer];
} }
}; };

View File

@@ -7,9 +7,10 @@ import {
TextChannel, TextChannel,
EmbedBuilder 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 { UserError } from "@lib/errors";
import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view"; import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view";
@@ -22,43 +23,34 @@ export async function handleTradeInteraction(interaction: Interaction) {
if (!threadId) return; if (!threadId) return;
try { if (customId === 'trade_cancel') {
if (customId === 'trade_cancel') { await handleCancel(interaction, threadId);
await handleCancel(interaction, threadId); } else if (customId === 'trade_lock') {
} else if (customId === 'trade_lock') { await handleLock(interaction, threadId);
await handleLock(interaction, threadId); } else if (customId === 'trade_confirm') {
} else if (customId === 'trade_confirm') { // Confirm logic is handled implicitly by both locking or explicitly if needed.
// Confirm logic is handled implicitly by both locking or explicitly if needed. // For now, locking both triggers execution, so no separate confirm handler is actively used
// For now, locking both triggers execution, so no separate confirm handler is actively used // unless we re-introduce a specific button. keeping basic handler stub if needed.
// unless we re-introduce a specific button. keeping basic handler stub if needed. } else if (customId === 'trade_add_money') {
} else if (customId === 'trade_add_money') { await handleAddMoneyClick(interaction);
await handleAddMoneyClick(interaction); } else if (customId === 'trade_money_modal') {
} else if (customId === 'trade_money_modal') { await handleMoneySubmit(interaction as ModalSubmitInteraction, threadId);
await handleMoneySubmit(interaction as ModalSubmitInteraction, threadId); } else if (customId === 'trade_add_item') {
} else if (customId === 'trade_add_item') { await handleAddItemClick(interaction as ButtonInteraction, threadId);
await handleAddItemClick(interaction as ButtonInteraction, threadId); } else if (customId === 'trade_select_item') {
} else if (customId === 'trade_select_item') { await handleItemSelect(interaction as StringSelectMenuInteraction, threadId);
await handleItemSelect(interaction as StringSelectMenuInteraction, threadId); } else if (customId === 'trade_remove_item') {
} else if (customId === 'trade_remove_item') { await handleRemoveItemClick(interaction as ButtonInteraction, threadId);
await handleRemoveItemClick(interaction as ButtonInteraction, threadId); } else if (customId === 'trade_remove_item_select') {
} else if (customId === 'trade_remove_item_select') { await handleRemoveItemSelect(interaction as StringSelectMenuInteraction, threadId);
await handleRemoveItemSelect(interaction as StringSelectMenuInteraction, threadId);
}
} catch (error: any) {
const errorEmbed = createErrorEmbed(error.message);
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ embeds: [errorEmbed], ephemeral: true });
} else {
await interaction.reply({ embeds: [errorEmbed], ephemeral: true });
}
} }
} }
async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) { async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
const session = TradeService.getSession(threadId); const session = tradeService.getSession(threadId);
const user = interaction.user; const user = interaction.user;
TradeService.endSession(threadId); tradeService.endSession(threadId);
await interaction.deferUpdate(); await interaction.deferUpdate();
@@ -70,11 +62,11 @@ async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInt
async function handleLock(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) { async function handleLock(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
await interaction.deferUpdate(); await interaction.deferUpdate();
const isLocked = TradeService.toggleLock(threadId, interaction.user.id); const isLocked = tradeService.toggleLock(threadId, interaction.user.id);
await updateTradeDashboard(interaction, threadId); await updateTradeDashboard(interaction, threadId);
// Check if trade executed (both locked) // Check if trade executed (both locked)
const session = TradeService.getSession(threadId); const session = tradeService.getSession(threadId);
if (session && session.state === 'COMPLETED') { if (session && session.state === 'COMPLETED') {
// Trade executed during updateTradeDashboard // Trade executed during updateTradeDashboard
return; return;
@@ -93,9 +85,9 @@ async function handleMoneySubmit(interaction: ModalSubmitInteraction, threadId:
const amountStr = interaction.fields.getTextInputValue('amount'); const amountStr = interaction.fields.getTextInputValue('amount');
const amount = BigInt(amountStr); const amount = BigInt(amountStr);
if (amount < 0n) throw new Error("Amount must be positive"); if (amount < 0n) throw new UserError("Amount must be positive");
TradeService.updateMoney(threadId, interaction.user.id, amount); tradeService.updateMoney(threadId, interaction.user.id, amount);
await interaction.deferUpdate(); // Acknowledge modal await interaction.deferUpdate(); // Acknowledge modal
await updateTradeDashboard(interaction, threadId); await updateTradeDashboard(interaction, threadId);
} }
@@ -126,16 +118,16 @@ async function handleItemSelect(interaction: StringSelectMenuInteraction, thread
// Assuming implementation implies adding 1 item for now // Assuming implementation implies adding 1 item for now
const item = await inventoryService.getItem(itemId); const item = await inventoryService.getItem(itemId);
if (!item) throw new Error("Item not found"); if (!item) throw new UserError("Item not found");
TradeService.addItem(threadId, interaction.user.id, { id: item.id, name: item.name }, 1n); tradeService.addItem(threadId, interaction.user.id, { id: item.id, name: item.name }, 1n);
await interaction.update({ content: `Added ${item.name} x1`, components: [] }); await interaction.update({ content: `Added ${item.name} x1`, components: [] });
await updateTradeDashboard(interaction, threadId); await updateTradeDashboard(interaction, threadId);
} }
async function handleRemoveItemClick(interaction: ButtonInteraction, threadId: string) { async function handleRemoveItemClick(interaction: ButtonInteraction, threadId: string) {
const session = TradeService.getSession(threadId); const session = tradeService.getSession(threadId);
if (!session) return; if (!session) return;
const participant = session.userA.id === interaction.user.id ? session.userA : session.userB; const participant = session.userA.id === interaction.user.id ? session.userA : session.userB;
@@ -158,7 +150,7 @@ async function handleRemoveItemSelect(interaction: StringSelectMenuInteraction,
const value = interaction.values[0]; const value = interaction.values[0];
if (!value) return; if (!value) return;
const itemId = parseInt(value); const itemId = parseInt(value);
TradeService.removeItem(threadId, interaction.user.id, itemId); tradeService.removeItem(threadId, interaction.user.id, itemId);
await interaction.update({ content: `Removed item.`, components: [] }); await interaction.update({ content: `Removed item.`, components: [] });
await updateTradeDashboard(interaction, threadId); await updateTradeDashboard(interaction, threadId);
@@ -168,14 +160,14 @@ async function handleRemoveItemSelect(interaction: StringSelectMenuInteraction,
// --- DASHBOARD UPDATER --- // --- DASHBOARD UPDATER ---
export async function updateTradeDashboard(interaction: Interaction, threadId: string) { export async function updateTradeDashboard(interaction: Interaction, threadId: string) {
const session = TradeService.getSession(threadId); const session = tradeService.getSession(threadId);
if (!session) return; if (!session) return;
// Check Auto-Execute (If both locked) // Check Auto-Execute (If both locked)
if (session.userA.locked && session.userB.locked) { if (session.userA.locked && session.userB.locked) {
// Execute Trade // Execute Trade
try { try {
await TradeService.executeTrade(threadId); await tradeService.executeTrade(threadId);
const embed = getTradeCompletedEmbed(session); const embed = getTradeCompletedEmbed(session);
await updateDashboardMessage(interaction, { embeds: [embed], components: [] }); await updateDashboardMessage(interaction, { embeds: [embed], components: [] });

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test"; import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
import { TradeService } from "./trade.service"; import { tradeService } from "./trade.service";
import { itemTransactions } from "@/db/schema"; import { itemTransactions } from "@/db/schema";
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";
@@ -37,7 +37,7 @@ describe("TradeService", () => {
mockValues.mockClear(); mockValues.mockClear();
// Clear sessions // Clear sessions
(TradeService as any).sessions.clear(); (tradeService as any)._sessions.clear();
// Spies // Spies
mockModifyUserBalance = spyOn(economyService, 'modifyUserBalance').mockResolvedValue({} as any); mockModifyUserBalance = spyOn(economyService, 'modifyUserBalance').mockResolvedValue({} as any);
@@ -54,68 +54,68 @@ describe("TradeService", () => {
describe("createSession", () => { describe("createSession", () => {
it("should create a new session", () => { it("should create a new session", () => {
const session = TradeService.createSession("thread1", userA, userB); const session = tradeService.createSession("thread1", userA, userB);
expect(session.threadId).toBe("thread1"); expect(session.threadId).toBe("thread1");
expect(session.state).toBe("NEGOTIATING"); expect(session.state).toBe("NEGOTIATING");
expect(session.userA.id).toBe("1"); expect(session.userA.id).toBe("1");
expect(session.userB.id).toBe("2"); expect(session.userB.id).toBe("2");
expect(TradeService.getSession("thread1")).toBe(session); expect(tradeService.getSession("thread1")).toBe(session);
}); });
}); });
describe("updateMoney", () => { describe("updateMoney", () => {
it("should update money offer", () => { it("should update money offer", () => {
TradeService.createSession("thread1", userA, userB); tradeService.createSession("thread1", userA, userB);
TradeService.updateMoney("thread1", "1", 100n); tradeService.updateMoney("thread1", "1", 100n);
const session = TradeService.getSession("thread1"); const session = tradeService.getSession("thread1");
expect(session?.userA.offer.money).toBe(100n); expect(session?.userA.offer.money).toBe(100n);
}); });
it("should unlock participants when offer changes", () => { it("should unlock participants when offer changes", () => {
const session = TradeService.createSession("thread1", userA, userB); const session = tradeService.createSession("thread1", userA, userB);
session.userA.locked = true; session.userA.locked = true;
session.userB.locked = true; session.userB.locked = true;
TradeService.updateMoney("thread1", "1", 100n); tradeService.updateMoney("thread1", "1", 100n);
expect(session.userA.locked).toBe(false); expect(session.userA.locked).toBe(false);
expect(session.userB.locked).toBe(false); expect(session.userB.locked).toBe(false);
}); });
it("should throw if not in trade", () => { it("should throw if not in trade", () => {
TradeService.createSession("thread1", userA, userB); tradeService.createSession("thread1", userA, userB);
expect(() => TradeService.updateMoney("thread1", "3", 100n)).toThrow("User not in trade"); expect(() => tradeService.updateMoney("thread1", "3", 100n)).toThrow("User not in trade");
}); });
}); });
describe("addItem", () => { describe("addItem", () => {
it("should add item to offer", () => { it("should add item to offer", () => {
TradeService.createSession("thread1", userA, userB); tradeService.createSession("thread1", userA, userB);
TradeService.addItem("thread1", "1", { id: 10, name: "Sword" }, 1n); tradeService.addItem("thread1", "1", { id: 10, name: "Sword" }, 1n);
const session = TradeService.getSession("thread1"); const session = tradeService.getSession("thread1");
expect(session?.userA.offer.items).toHaveLength(1); expect(session?.userA.offer.items).toHaveLength(1);
expect(session?.userA.offer.items[0]).toEqual({ id: 10, name: "Sword", quantity: 1n }); expect(session?.userA.offer.items[0]).toEqual({ id: 10, name: "Sword", quantity: 1n });
}); });
it("should stack items if already offered", () => { it("should stack items if already offered", () => {
TradeService.createSession("thread1", userA, userB); tradeService.createSession("thread1", userA, userB);
TradeService.addItem("thread1", "1", { id: 10, name: "Sword" }, 1n); tradeService.addItem("thread1", "1", { id: 10, name: "Sword" }, 1n);
TradeService.addItem("thread1", "1", { id: 10, name: "Sword" }, 2n); tradeService.addItem("thread1", "1", { id: 10, name: "Sword" }, 2n);
const session = TradeService.getSession("thread1"); const session = tradeService.getSession("thread1");
expect(session?.userA.offer.items[0]!.quantity).toBe(3n); expect(session?.userA.offer.items[0]!.quantity).toBe(3n);
}); });
}); });
describe("removeItem", () => { describe("removeItem", () => {
it("should remove item from offer", () => { it("should remove item from offer", () => {
const session = TradeService.createSession("thread1", userA, userB); const session = tradeService.createSession("thread1", userA, userB);
session.userA.offer.items.push({ id: 10, name: "Sword", quantity: 1n }); session.userA.offer.items.push({ id: 10, name: "Sword", quantity: 1n });
TradeService.removeItem("thread1", "1", 10); tradeService.removeItem("thread1", "1", 10);
expect(session.userA.offer.items).toHaveLength(0); expect(session.userA.offer.items).toHaveLength(0);
}); });
@@ -123,19 +123,19 @@ describe("TradeService", () => {
describe("toggleLock", () => { describe("toggleLock", () => {
it("should toggle lock status", () => { it("should toggle lock status", () => {
TradeService.createSession("thread1", userA, userB); tradeService.createSession("thread1", userA, userB);
const locked1 = TradeService.toggleLock("thread1", "1"); const locked1 = tradeService.toggleLock("thread1", "1");
expect(locked1).toBe(true); expect(locked1).toBe(true);
const locked2 = TradeService.toggleLock("thread1", "1"); const locked2 = tradeService.toggleLock("thread1", "1");
expect(locked2).toBe(false); expect(locked2).toBe(false);
}); });
}); });
describe("executeTrade", () => { describe("executeTrade", () => {
it("should execute trade successfully", async () => { it("should execute trade successfully", async () => {
const session = TradeService.createSession("thread1", userA, userB); const session = tradeService.createSession("thread1", userA, userB);
// Setup offers // Setup offers
session.userA.offer.money = 100n; session.userA.offer.money = 100n;
@@ -148,7 +148,7 @@ describe("TradeService", () => {
session.userA.locked = true; session.userA.locked = true;
session.userB.locked = true; session.userB.locked = true;
await TradeService.executeTrade("thread1"); await tradeService.executeTrade("thread1");
expect(session.state).toBe("COMPLETED"); expect(session.state).toBe("COMPLETED");
@@ -171,11 +171,11 @@ describe("TradeService", () => {
}); });
it("should throw if not locked", async () => { it("should throw if not locked", async () => {
const session = TradeService.createSession("thread1", userA, userB); const session = tradeService.createSession("thread1", userA, userB);
session.userA.locked = true; session.userA.locked = true;
// B not locked // B not locked
expect(TradeService.executeTrade("thread1")).rejects.toThrow("Both players must accept"); expect(tradeService.executeTrade("thread1")).rejects.toThrow("Both players must accept");
}); });
}); });
}); });

View File

@@ -1,17 +1,83 @@
import type { TradeSession, TradeParticipant } from "./trade.types"; import type { TradeSession, TradeParticipant } from "./trade.types";
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 { withTransaction } from "@/lib/db";
import type { Transaction } from "@/lib/types"; import type { Transaction } from "@/lib/types";
import { TransactionType, ItemTransactionType } from "@/lib/constants";
export class TradeService { // Module-level session storage
private static sessions = new Map<string, TradeSession>(); const sessions = new Map<string, TradeSession>();
/**
* Unlocks both participants in a trade session
*/
const unlockAll = (session: TradeSession) => {
session.userA.locked = false;
session.userB.locked = false;
};
/**
* Processes a one-way transfer from one participant to another
*/
const processTransfer = async (tx: Transaction, from: TradeParticipant, to: TradeParticipant, threadId: string) => {
// 1. Money
if (from.offer.money > 0n) {
await economyService.modifyUserBalance(
from.id,
-from.offer.money,
TransactionType.TRADE_OUT,
`Trade with ${to.username} (Thread: ${threadId})`,
to.id,
tx
);
await economyService.modifyUserBalance(
to.id,
from.offer.money,
TransactionType.TRADE_IN,
`Trade with ${from.username} (Thread: ${threadId})`,
from.id,
tx
);
}
// 2. Items
for (const item of from.offer.items) {
// Remove from sender
await inventoryService.removeItem(from.id, item.id, item.quantity, tx);
// Add to receiver
await inventoryService.addItem(to.id, item.id, item.quantity, tx);
// Log Item Transaction (Sender)
await tx.insert(itemTransactions).values({
userId: BigInt(from.id),
relatedUserId: BigInt(to.id),
itemId: item.id,
quantity: -item.quantity,
type: ItemTransactionType.TRADE_OUT,
description: `Traded to ${to.username}`,
});
// Log Item Transaction (Receiver)
await tx.insert(itemTransactions).values({
userId: BigInt(to.id),
relatedUserId: BigInt(from.id),
itemId: item.id,
quantity: item.quantity,
type: ItemTransactionType.TRADE_IN,
description: `Received from ${from.username}`,
});
}
};
export const tradeService = {
// Expose for testing
_sessions: sessions,
/** /**
* Creates a new trade session * Creates a new trade session
*/ */
static createSession(threadId: string, userA: { id: string, username: string }, userB: { id: string, username: string }): TradeSession { createSession: (threadId: string, userA: { id: string, username: string }, userB: { id: string, username: string }): TradeSession => {
const session: TradeSession = { const session: TradeSession = {
threadId, threadId,
userA: { userA: {
@@ -30,24 +96,24 @@ export class TradeService {
lastInteraction: Date.now() lastInteraction: Date.now()
}; };
this.sessions.set(threadId, session); sessions.set(threadId, session);
return session; return session;
} },
static getSession(threadId: string): TradeSession | undefined { getSession: (threadId: string): TradeSession | undefined => {
return this.sessions.get(threadId); return sessions.get(threadId);
} },
static endSession(threadId: string) { endSession: (threadId: string) => {
this.sessions.delete(threadId); sessions.delete(threadId);
} },
/** /**
* Updates an offer. If allowed, validation checks should be done BEFORE calling this. * Updates an offer. If allowed, validation checks should be done BEFORE calling this.
* unlocking logic is handled here (if offer changes, unlock both). * unlocking logic is handled here (if offer changes, unlock both).
*/ */
static updateMoney(threadId: string, userId: string, amount: bigint) { updateMoney: (threadId: string, userId: string, amount: bigint) => {
const session = this.getSession(threadId); const session = tradeService.getSession(threadId);
if (!session) throw new Error("Session not found"); if (!session) throw new Error("Session not found");
if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active"); if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active");
@@ -55,12 +121,12 @@ export class TradeService {
if (!participant) throw new Error("User not in trade"); if (!participant) throw new Error("User not in trade");
participant.offer.money = amount; participant.offer.money = amount;
this.unlockAll(session); unlockAll(session);
session.lastInteraction = Date.now(); session.lastInteraction = Date.now();
} },
static addItem(threadId: string, userId: string, item: { id: number, name: string }, quantity: bigint) { addItem: (threadId: string, userId: string, item: { id: number, name: string }, quantity: bigint) => {
const session = this.getSession(threadId); const session = tradeService.getSession(threadId);
if (!session) throw new Error("Session not found"); if (!session) throw new Error("Session not found");
if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active"); if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active");
@@ -74,12 +140,12 @@ export class TradeService {
participant.offer.items.push({ id: item.id, name: item.name, quantity }); participant.offer.items.push({ id: item.id, name: item.name, quantity });
} }
this.unlockAll(session); unlockAll(session);
session.lastInteraction = Date.now(); session.lastInteraction = Date.now();
} },
static removeItem(threadId: string, userId: string, itemId: number) { removeItem: (threadId: string, userId: string, itemId: number) => {
const session = this.getSession(threadId); const session = tradeService.getSession(threadId);
if (!session) throw new Error("Session not found"); if (!session) throw new Error("Session not found");
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null; const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
@@ -87,12 +153,12 @@ export class TradeService {
participant.offer.items = participant.offer.items.filter(i => i.id !== itemId); participant.offer.items = participant.offer.items.filter(i => i.id !== itemId);
this.unlockAll(session); unlockAll(session);
session.lastInteraction = Date.now(); session.lastInteraction = Date.now();
} },
static toggleLock(threadId: string, userId: string): boolean { toggleLock: (threadId: string, userId: string): boolean => {
const session = this.getSession(threadId); const session = tradeService.getSession(threadId);
if (!session) throw new Error("Session not found"); if (!session) throw new Error("Session not found");
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null; const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
@@ -102,12 +168,7 @@ export class TradeService {
session.lastInteraction = Date.now(); session.lastInteraction = Date.now();
return participant.locked; return participant.locked;
} },
private static unlockAll(session: TradeSession) {
session.userA.locked = false;
session.userB.locked = false;
}
/** /**
* Executes the trade atomically. * Executes the trade atomically.
@@ -116,8 +177,8 @@ export class TradeService {
* 3. Swaps items. * 3. Swaps items.
* 4. Logs transactions. * 4. Logs transactions.
*/ */
static async executeTrade(threadId: string): Promise<void> { executeTrade: async (threadId: string): Promise<void> => {
const session = this.getSession(threadId); const session = tradeService.getSession(threadId);
if (!session) throw new Error("Session not found"); if (!session) throw new Error("Session not found");
if (!session.userA.locked || !session.userB.locked) { if (!session.userA.locked || !session.userB.locked) {
@@ -126,65 +187,14 @@ export class TradeService {
session.state = 'COMPLETED'; // Prevent double execution session.state = 'COMPLETED'; // Prevent double execution
await DrizzleClient.transaction(async (tx) => { await withTransaction(async (tx) => {
// -- Validate & Execute User A -> User B -- // -- Validate & Execute User A -> User B --
await this.processTransfer(tx, session.userA, session.userB, session.threadId); await processTransfer(tx, session.userA, session.userB, session.threadId);
// -- Validate & Execute User B -> User A -- // -- Validate & Execute User B -> User A --
await this.processTransfer(tx, session.userB, session.userA, session.threadId); await processTransfer(tx, session.userB, session.userA, session.threadId);
}); });
this.endSession(threadId); tradeService.endSession(threadId);
} }
};
private static async processTransfer(tx: Transaction, from: TradeParticipant, to: TradeParticipant, threadId: string) {
// 1. Money
if (from.offer.money > 0n) {
await economyService.modifyUserBalance(
from.id,
-from.offer.money,
'TRADE_OUT',
`Trade with ${to.username} (Thread: ${threadId})`,
to.id,
tx
);
await economyService.modifyUserBalance(
to.id,
from.offer.money,
'TRADE_IN',
`Trade with ${from.username} (Thread: ${threadId})`,
from.id,
tx
);
}
// 2. Items
for (const item of from.offer.items) {
// Remove from sender
await inventoryService.removeItem(from.id, item.id, item.quantity, tx);
// Add to receiver
await inventoryService.addItem(to.id, item.id, item.quantity, tx);
// Log Item Transaction (Sender)
await tx.insert(itemTransactions).values({
userId: BigInt(from.id),
relatedUserId: BigInt(to.id),
itemId: item.id,
quantity: -item.quantity,
type: 'TRADE_OUT',
description: `Traded to ${to.username}`,
});
// Log Item Transaction (Receiver)
await tx.insert(itemTransactions).values({
userId: BigInt(to.id),
relatedUserId: BigInt(from.id),
itemId: item.id,
quantity: item.quantity,
type: 'TRADE_IN',
description: `Received from ${from.username}`,
});
}
}
}

View File

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

View File

@@ -20,6 +20,7 @@ mockUpdate.mockReturnValue({ set: mockSet });
mockSet.mockReturnValue({ where: mockWhere }); mockSet.mockReturnValue({ where: mockWhere });
mockWhere.mockReturnValue({ returning: mockReturning }); mockWhere.mockReturnValue({ returning: mockReturning });
mockDelete.mockReturnValue({ where: mockWhere });
// Mock DrizzleClient // Mock DrizzleClient
mock.module("@/lib/DrizzleClient", () => { mock.module("@/lib/DrizzleClient", () => {
@@ -51,12 +52,39 @@ mock.module("@/lib/DrizzleClient", () => {
}; };
}); });
// Mock withTransaction helper to use the same pattern as DrizzleClient.transaction
mock.module("@/lib/db", () => {
return {
withTransaction: async (callback: any, tx?: any) => {
if (tx) {
return callback(tx);
}
// Simulate transaction by calling the callback with mock db
return callback({
query: {
users: {
findFirst: mockFindFirst,
},
},
insert: mockInsert,
update: mockUpdate,
delete: mockDelete,
});
}
};
});
describe("userService", () => { describe("userService", () => {
beforeEach(() => { beforeEach(() => {
mockFindFirst.mockReset(); mockFindFirst.mockReset();
mockInsert.mockClear(); mockInsert.mockClear();
mockValues.mockClear(); mockValues.mockClear();
mockReturning.mockClear(); mockReturning.mockClear();
mockUpdate.mockClear();
mockSet.mockClear();
mockWhere.mockClear();
mockDelete.mockClear();
}); });
describe("getUserById", () => { describe("getUserById", () => {
@@ -80,7 +108,91 @@ describe("userService", () => {
}); });
}); });
describe("createUser", () => { describe("getUserByUsername", () => {
it("should return user when username exists", async () => {
const mockUser = { id: 456n, username: "alice", balance: 100n };
mockFindFirst.mockResolvedValue(mockUser);
const result = await userService.getUserByUsername("alice");
expect(result).toEqual(mockUser as any);
expect(mockFindFirst).toHaveBeenCalledTimes(1);
});
it("should return undefined when username not found", async () => {
mockFindFirst.mockResolvedValue(undefined);
const result = await userService.getUserByUsername("nonexistent");
expect(result).toBeUndefined();
});
});
describe("getUserClass", () => {
it("should return user class when user has a class", async () => {
const mockClass = { id: 1n, name: "Warrior", emoji: "⚔️" };
const mockUser = { id: 123n, username: "testuser", class: mockClass };
mockFindFirst.mockResolvedValue(mockUser);
const result = await userService.getUserClass("123");
expect(result).toEqual(mockClass as any);
});
it("should return null when user has no class", async () => {
const mockUser = { id: 123n, username: "testuser", class: null };
mockFindFirst.mockResolvedValue(mockUser);
const result = await userService.getUserClass("123");
expect(result).toBeNull();
});
it("should return undefined when user not found", async () => {
mockFindFirst.mockResolvedValue(undefined);
const result = await userService.getUserClass("999");
expect(result).toBeUndefined();
});
});
describe("getOrCreateUser (withTransaction)", () => {
it("should return existing user if found", async () => {
const mockUser = { id: 123n, username: "existinguser", class: null };
mockFindFirst.mockResolvedValue(mockUser);
const result = await userService.getOrCreateUser("123", "existinguser");
expect(result).toEqual(mockUser as any);
expect(mockFindFirst).toHaveBeenCalledTimes(1);
expect(mockInsert).not.toHaveBeenCalled();
});
it("should create new user if not found", async () => {
const newUser = { id: 789n, username: "newuser", classId: null };
// First call returns undefined (user not found)
// Second call returns the newly created user (after insert + re-query)
mockFindFirst
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({ id: 789n, username: "newuser", class: null });
mockReturning.mockResolvedValue([newUser]);
const result = await userService.getOrCreateUser("789", "newuser");
expect(mockInsert).toHaveBeenCalledTimes(1);
expect(mockValues).toHaveBeenCalledWith({
id: 789n,
username: "newuser"
});
// Should query twice: once to check, once after insert
expect(mockFindFirst).toHaveBeenCalledTimes(2);
});
});
describe("createUser (withTransaction)", () => {
it("should create and return a new user", async () => { it("should create and return a new user", async () => {
const newUser = { id: 456n, username: "newuser", classId: null }; const newUser = { id: 456n, username: "newuser", classId: null };
mockReturning.mockResolvedValue([newUser]); mockReturning.mockResolvedValue([newUser]);
@@ -95,5 +207,53 @@ describe("userService", () => {
classId: undefined classId: undefined
}); });
}); });
it("should create user with classId when provided", async () => {
const newUser = { id: 999n, username: "warrior", classId: 5n };
mockReturning.mockResolvedValue([newUser]);
const result = await userService.createUser("999", "warrior", 5n);
expect(result).toEqual(newUser as any);
expect(mockValues).toHaveBeenCalledWith({
id: 999n,
username: "warrior",
classId: 5n
});
});
});
describe("updateUser (withTransaction)", () => {
it("should update user data", async () => {
const updatedUser = { id: 123n, username: "testuser", balance: 500n };
mockReturning.mockResolvedValue([updatedUser]);
const result = await userService.updateUser("123", { balance: 500n });
expect(result).toEqual(updatedUser as any);
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockSet).toHaveBeenCalledWith({ balance: 500n });
});
it("should update multiple fields", async () => {
const updatedUser = { id: 456n, username: "alice", xp: 100n, level: 5 };
mockReturning.mockResolvedValue([updatedUser]);
const result = await userService.updateUser("456", { xp: 100n, level: 5 });
expect(result).toEqual(updatedUser as any);
expect(mockSet).toHaveBeenCalledWith({ xp: 100n, level: 5 });
});
});
describe("deleteUser (withTransaction)", () => {
it("should delete user from database", async () => {
mockWhere.mockResolvedValue(undefined);
await userService.deleteUser("123");
expect(mockDelete).toHaveBeenCalledTimes(1);
expect(mockWhere).toHaveBeenCalledTimes(1);
});
}); });
}); });

View File

@@ -1,6 +1,8 @@
import { users } from "@/db/schema"; import { users } from "@/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { DrizzleClient } from "@/lib/DrizzleClient";
import { withTransaction } from "@/lib/db";
import type { Transaction } from "@/lib/types";
export const userService = { export const userService = {
getUserById: async (id: string) => { getUserById: async (id: string) => {
@@ -14,23 +16,27 @@ export const userService = {
const user = await DrizzleClient.query.users.findFirst({ where: eq(users.username, username) }); const user = await DrizzleClient.query.users.findFirst({ where: eq(users.username, username) });
return user; return user;
}, },
getOrCreateUser: async (id: string, username: string, tx?: any) => { getOrCreateUser: async (id: string, username: string, tx?: Transaction) => {
const execute = async (txFn: any) => { return await withTransaction(async (txFn) => {
let user = await txFn.query.users.findFirst({ let user = await txFn.query.users.findFirst({
where: eq(users.id, BigInt(id)), where: eq(users.id, BigInt(id)),
with: { class: true } with: { class: true }
}); });
if (!user) { if (!user) {
const [newUser] = await txFn.insert(users).values({ await txFn.insert(users).values({
id: BigInt(id), id: BigInt(id),
username, username,
}).returning(); }).returning();
user = { ...newUser, class: null };
// Re-query to get the user with class relation
user = await txFn.query.users.findFirst({
where: eq(users.id, BigInt(id)),
with: { class: true }
});
} }
return user; return user;
}; }, tx);
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
}, },
getUserClass: async (id: string) => { getUserClass: async (id: string) => {
const user = await DrizzleClient.query.users.findFirst({ const user = await DrizzleClient.query.users.findFirst({
@@ -39,31 +45,28 @@ export const userService = {
}); });
return user?.class; return user?.class;
}, },
createUser: async (id: string | bigint, username: string, classId?: bigint, tx?: any) => { createUser: async (id: string | bigint, username: string, classId?: bigint, tx?: Transaction) => {
const execute = async (txFn: any) => { return await withTransaction(async (txFn) => {
const [user] = await txFn.insert(users).values({ const [user] = await txFn.insert(users).values({
id: BigInt(id), id: BigInt(id),
username, username,
classId, classId,
}).returning(); }).returning();
return user; return user;
}; }, tx);
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
}, },
updateUser: async (id: string, data: Partial<typeof users.$inferInsert>, tx?: any) => { updateUser: async (id: string, data: Partial<typeof users.$inferInsert>, tx?: Transaction) => {
const execute = async (txFn: any) => { return await withTransaction(async (txFn) => {
const [user] = await txFn.update(users) const [user] = await txFn.update(users)
.set(data) .set(data)
.where(eq(users.id, BigInt(id))) .where(eq(users.id, BigInt(id)))
.returning(); .returning();
return user; return user;
}; }, tx);
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
}, },
deleteUser: async (id: string, tx?: any) => { deleteUser: async (id: string, tx?: Transaction) => {
const execute = async (txFn: any) => { return await withTransaction(async (txFn) => {
await txFn.delete(users).where(eq(users.id, BigInt(id))); await txFn.delete(users).where(eq(users.id, BigInt(id)));
}; }, tx);
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
}, },
}; };

View File

@@ -1,8 +1,9 @@
import { userTimers } from "@/db/schema"; import { userTimers } from "@/db/schema";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { DrizzleClient } from "@/lib/DrizzleClient";
import { TimerType } from "@/lib/constants";
export type TimerType = 'COOLDOWN' | 'EFFECT' | 'ACCESS'; export { TimerType };
export const userTimerService = { export const userTimerService = {
/** /**

68
src/web/public/style.css Normal file
View File

@@ -0,0 +1,68 @@
:root {
--bg-color: #0f172a;
--text-color: #f8fafc;
--accent-color: #38bdf8;
--card-bg: #1e293b;
--font-family: system-ui, -apple-system, sans-serif;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: var(--font-family);
margin: 0;
line-height: 1.5;
display: flex;
flex-direction: column;
min-height: 100vh;
}
header {
background-color: var(--card-bg);
padding: 1rem 2rem;
border-bottom: 1px solid #334155;
display: flex;
justify-content: space-between;
align-items: center;
}
header h1 {
margin: 0;
font-size: 1.5rem;
color: var(--accent-color);
}
main {
flex: 1;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
.card {
background-color: var(--card-bg);
border-radius: 0.5rem;
padding: 1.5rem;
margin-bottom: 1rem;
border: 1px solid #334155;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
footer {
text-align: center;
padding: 1rem;
color: #94a3b8;
font-size: 0.875rem;
border-top: 1px solid #334155;
}
a {
color: var(--accent-color);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}

52
src/web/router.test.ts Normal file
View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "bun:test";
import { router } from "./router";
describe("Web Router", () => {
it("should return home page on /", async () => {
const req = new Request("http://localhost/");
const res = await router(req);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("text/html");
expect(await res.text()).toContain("Aurora Web");
});
it("should return health check on /health", async () => {
const req = new Request("http://localhost/health");
const res = await router(req);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("application/json");
const data = await res.json();
expect(data).toHaveProperty("status", "ok");
});
it("should block path traversal", async () => {
// Attempts to go up two directories to reach the project root or src
const req = new Request("http://localhost/public/../../package.json");
const res = await router(req);
// Should be 403 Forbidden or 404 Not Found (our logical change makes it 403)
expect([403, 404]).toContain(res.status);
});
it("should serve existing static file", async () => {
// We know style.css exists in src/web/public
const req = new Request("http://localhost/public/style.css");
const res = await router(req);
expect(res.status).toBe(200);
if (res.status === 200) {
const text = await res.text();
expect(text).toContain("body");
}
});
it("should not serve static files on non-GET methods", async () => {
const req = new Request("http://localhost/public/style.css", { method: "POST" });
const res = await router(req);
expect(res.status).toBe(404);
});
it("should return 404 for unknown routes", async () => {
const req = new Request("http://localhost/unknown");
const res = await router(req);
expect(res.status).toBe(404);
});
});

53
src/web/router.ts Normal file
View File

@@ -0,0 +1,53 @@
import { homeRoute } from "./routes/home";
import { healthRoute } from "./routes/health";
import { file } from "bun";
import { join, resolve } from "path";
export async function router(request: Request): Promise<Response> {
const url = new URL(request.url);
const method = request.method;
// Resolve the absolute path to the public directory
const publicDir = resolve(import.meta.dir, "public");
if (method === "GET") {
// Handle Static Files
// We handle requests starting with /public/ OR containing an extension (like /style.css)
if (url.pathname.startsWith("/public/") || url.pathname.includes(".")) {
// Normalize path: remove /public prefix if present so that
// /public/style.css and /style.css both map to .../public/style.css
const relativePath = url.pathname.replace(/^\/public/, "");
// Resolve full path
// We use join with relativePath. If relativePath starts with /, join handles it correctly
// effectively treating it as a segment.
// However, to be extra safe with 'resolve', we ensure we are resolving from publicDir.
// simple join(publicDir, relativePath) is usually enough with 'bun'.
// But we use 'resolve' to handle .. segments correctly.
// We prepend '.' to relativePath to ensure it's treated as relative to publicDir logic
const normalizedRelative = relativePath.startsWith("/") ? "." + relativePath : relativePath;
const requestedPath = resolve(publicDir, normalizedRelative);
// Security Check: Block Path Traversal
if (requestedPath.startsWith(publicDir)) {
const staticFile = file(requestedPath);
if (await staticFile.exists()) {
return new Response(staticFile);
}
} else {
// If path traversal detected, return 403 or 404.
// 403 indicates we caught them.
return new Response("Forbidden", { status: 403 });
}
}
if (url.pathname === "/" || url.pathname === "/index.html") {
return homeRoute();
}
if (url.pathname === "/health") {
return healthRoute();
}
}
return new Response("Not Found", { status: 404 });
}

9
src/web/routes/health.ts Normal file
View File

@@ -0,0 +1,9 @@
export function healthRoute(): Response {
return new Response(JSON.stringify({
status: "ok",
uptime: process.uptime(),
timestamp: new Date().toISOString()
}), {
headers: { "Content-Type": "application/json" },
});
}

20
src/web/routes/home.ts Normal file
View File

@@ -0,0 +1,20 @@
import { BaseLayout } from "../views/layout";
export function homeRoute(): Response {
const content = `
<div class="card">
<h2>Welcome</h2>
<p>The Aurora web server is up and running!</p>
</div>
<div class="card">
<h3>Status</h3>
<p>System operational.</p>
</div>
`;
const html = BaseLayout({ title: "Home", content });
return new Response(html, {
headers: { "Content-Type": "text/html" },
});
}

24
src/web/server.ts Normal file
View File

@@ -0,0 +1,24 @@
import { env } from "@/lib/env";
import { router } from "./router";
import type { Server } from "bun";
export class WebServer {
private static server: Server<unknown> | null = null;
public static start() {
this.server = Bun.serve({
port: env.PORT || 3000,
fetch: router,
});
console.log(`🌐 Web server listening on http://localhost:${this.server.port}`);
}
public static stop() {
if (this.server) {
this.server.stop();
console.log("🛑 Web server stopped");
this.server = null;
}
}
}

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "bun:test";
import { escapeHtml } from "./html";
describe("HTML Utils", () => {
it("should escape special characters", () => {
const unsafe = '<script>alert("xss")</script>';
const safe = escapeHtml(unsafe);
expect(safe).toBe("&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;");
});
it("should handle mixed content", () => {
const unsafe = 'Hello & "World"';
const safe = escapeHtml(unsafe);
expect(safe).toBe("Hello &amp; &quot;World&quot;");
});
});

14
src/web/utils/html.ts Normal file
View File

@@ -0,0 +1,14 @@
/**
* Escapes unsafe characters in a string to prevent XSS.
* @param unsafe - The raw string to escape.
* @returns The escaped string safe for HTML insertion.
*/
export function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

34
src/web/views/layout.ts Normal file
View File

@@ -0,0 +1,34 @@
import { escapeHtml } from "../utils/html";
interface LayoutProps {
title: string;
content: string;
}
export function BaseLayout({ title, content }: LayoutProps): string {
const safeTitle = escapeHtml(title);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${safeTitle} | Aurora</title>
<link rel="stylesheet" href="/style.css">
<meta name="description" content="Aurora Bot Web Interface">
</head>
<body>
<header>
<h1>Aurora Web</h1>
<nav>
<a href="/">Home</a>
</nav>
</header>
<main>
${content}
</main>
<footer>
<p>&copy; ${new Date().getFullYear()} Aurora Bot</p>
</footer>
</body>
</html>`;
}

View File

@@ -0,0 +1,59 @@
# 2026-01-07-web-server-foundation: Web Server Infrastructure Foundation
**Status:** Done
**Created:** 2026-01-07
**Tags:** infrastructure, web, core
## 1. Context & User Story
* **As a:** Developer
* **I want to:** Establish a lightweight, integrated web server foundation within the existing codebase.
* **So that:** We can serve internal tools (Workbench) or public pages (Leaderboard) with minimal friction, avoiding complex separate build pipelines.
## 2. Technical Requirements
### Architecture
- **Native Bun Server:** Use `Bun.serve()` for high performance.
- **Exposure:** The server port must be exposed in `docker-compose.yml` to be accessible outside the container.
- **Rendering Strategy:** **Server-Side Rendering (SSR) via Template Literals**.
- *Why?* Zero dependencies. No build step (like Vite/Webpack) required. We can simply write functions that return HTML strings.
- *Client Side:* Minimal Vanilla JS or a lightweight drop-in library (like HTMX or Alpine from CDN) can be used if interactivity is needed later.
### File Organization (`src/web/`)
We will separate the web infrastructure from game modules to keep concerns clean.
- `src/web/server.ts`: Main server class/entry point.
- `src/web/router.ts`: Simple routing logic.
- `src/web/routes/`: Individual route handlers (e.g., `home.ts`, `health.ts`).
- `src/web/views/`: Reusable HTML template functions (Header, Footer, Layouts).
- `src/web/public/`: Static assets (CSS, Images) served directly.
### API / Interface
- **GET /health**: Returns `{ status: "ok", uptime: <seconds> }`.
- **GET /**: Renders a basic HTML landing page using the View system.
## 3. Constraints & Validations (CRITICAL)
- **Zero Frameworks:** No Express/NestJS.
- **Zero Build Tools:** No Webpack/Vite. The code must be runnable directly by `bun run`.
- **Docker Integration:** Port 3000 (or env `PORT`) must be mapped in Docker Compose.
- **Static Files:** Must implement a handler to check `src/web/public` for file requests.
## 4. Acceptance Criteria
1. [x] `docker-compose up` exposes port 3000.
2. [x] `http://localhost:3000` loads a styled HTML page (verifying static asset serving + SSR).
3. [x] `http://localhost:3000/health` returns JSON.
4. [x] Folder structure established as defined above.
## 5. Implementation Plan
- [x] **Infrastructure**: Create `src/web/` directory structure.
- [x] **Core Logic**: Implement `WebServer` class in `src/web/server.ts` with routing and static file serving logic.
- [x] **Integration**: Bind `WebServer.start()` to `src/index.ts`.
- [x] **Docker**: Update `docker-compose.yml` to map port `3000:3000`.
- [x] **Views**: Create a basic `BaseLayout` function in `src/web/views/layout.ts`.
- [x] **Env**: Add `PORT` to `config.ts` / `env.ts`.
## Implementation Notes
- Created `src/web` directory with `router.ts`, `server.ts` and subdirectories `routes`, `views`, `public`.
- Implemented `WebServer` class using `Bun.serve`.
- Added basic CSS and layout system.
- Added `PORT` to `src/lib/env.ts` (default 3000).
- Integrated into `src/index.ts` to start on boot and graceful shutdown.
- Fixed unrelated typing issues in `src/commands/admin/note.ts` and `src/db/indexes.test.ts` to pass strict CI checks.
- Verified with `bun test` and `bun x tsc`.