44 Commits

Author SHA1 Message Date
syntaxbullet
66af870aa9 fix: make dashboard locally accessible only 2026-01-07 14:33:19 +01:00
syntaxbullet
8047bce755 feat: add bot action controls and real-time vital statistics to the web dashboard 2026-01-07 14:26:37 +01:00
syntaxbullet
9804456257 docs: Remove completed and draft feature tickets from the tickets directory. 2026-01-07 13:49:04 +01:00
syntaxbullet
259b8d6875 feat: replace mock dashboard data with live telemetry 2026-01-07 13:47:02 +01:00
syntaxbullet
a2cb684b71 Merge branch 'feat/web-interface-expansion-mockup' into main 2026-01-07 13:39:41 +01:00
syntaxbullet
9c2098bc46 fix(test): use dynamic port for websocket tests 2026-01-07 13:37:21 +01:00
syntaxbullet
618d973863 feat: expansion of web dashboard with live activity feed and metrics 2026-01-07 13:34:29 +01:00
syntaxbullet
63f55b6dfd feat: implement dashboard mockup and route 2026-01-07 13:29:06 +01:00
syntaxbullet
ac4025e179 feat: implement websocket realtime data streaming 2026-01-07 13:25:41 +01:00
syntaxbullet
ff23f22337 feat: move status to footer and clean up home page 2026-01-07 13:21:36 +01:00
syntaxbullet
292991c605 feat: responsive mobile layout and touch optimizations 2026-01-07 13:08:02 +01:00
syntaxbullet
4640cd11a7 feat: ux enhancements (animations, dynamic backgrounds, micro-interactions) 2026-01-07 13:05:42 +01:00
syntaxbullet
43a003f641 feat: visual design system overhaul (HSL palette, fonts, components) 2026-01-07 13:04:40 +01:00
syntaxbullet
6f4426e49d feat: save progress on web server foundation and add new tickets 2026-01-07 13:02:36 +01:00
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
81 changed files with 4776 additions and 375 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/log
scratchpad/

View File

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

View File

@@ -7,7 +7,7 @@ services:
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME}
ports:
- "${DB_PORT}:5432"
- "127.0.0.1:${DB_PORT}:5432"
volumes:
- ./src/db/data:/var/lib/postgresql/data
- ./src/db/log:/var/log/postgresql
@@ -20,11 +20,12 @@ services:
dockerfile: Dockerfile
working_dir: /app
ports:
- "3000:3000"
- "127.0.0.1:3000:3000"
volumes:
- .:/app
- /app/node_modules
environment:
- HOST=0.0.0.0
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME}
@@ -46,7 +47,7 @@ services:
dockerfile: Dockerfile
working_dir: /app
ports:
- "4983:4983"
- "127.0.0.1:4983:4983"
volumes:
- .:/app
- /app/node_modules

63
docs/COMMANDS.md Normal file
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..."
}
]
}
]
}
```

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,
"tag": "0001_heavy_thundra",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1767716705797,
"tag": "0002_fancy_forge",
"breakpoints": true
}
]
}

View File

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

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

View File

@@ -50,75 +50,31 @@ export const warn = createCommand({
return;
}
// Create the warning case
const moderationCase = await ModerationService.createCase({
type: 'warn',
// Issue the warning via service
const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({
userId: targetUser.id,
username: targetUser.username,
moderatorId: interaction.user.id,
moderatorName: interaction.user.username,
reason,
guildName: interaction.guild?.name || undefined,
dmTarget: targetUser,
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id)
});
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
await interaction.editReply({
embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
});
// Try to DM the user if configured
if (config.moderation.cases.dmOnWarn) {
try {
const serverName = interaction.guild?.name || 'this server';
await targetUser.send({
embeds: [getUserWarningEmbed(serverName, reason, moderationCase.caseId, warningCount)]
});
} 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);
}
// Follow up if auto-timeout was issued
if (autoTimeoutIssued) {
await interaction.followUp({
embeds: [getModerationErrorEmbed(
`⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.`
)],
flags: MessageFlags.Ephemeral
});
}
} catch (error) {

View File

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

View File

@@ -33,6 +33,11 @@ export const pay = createCommand({
const amount = BigInt(interaction.options.getInteger("amount", true));
const senderId = interaction.user.id;
if (!targetUser) {
await interaction.reply({ embeds: [createErrorEmbed("User not found.")], flags: MessageFlags.Ephemeral });
return;
}
const receiverId = targetUser.id;
if (amount < config.economy.transfers.minAmount) {
@@ -40,14 +45,14 @@ export const pay = createCommand({
return;
}
if (senderId === receiverId) {
if (senderId === receiverId.toString()) {
await interaction.reply({ embeds: [createErrorEmbed("You cannot pay yourself.")], flags: MessageFlags.Ephemeral });
return;
}
try {
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");
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });

View File

@@ -4,9 +4,6 @@ import { inventoryService } from "@/modules/inventory/inventory.service";
import { userService } from "@/modules/user/user.service";
import { createErrorEmbed } from "@lib/embeds";
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
import { inventory, items } from "@/db/schema";
import { eq, and, like } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient";
import type { ItemUsageData } from "@/lib/types";
import { UserError } from "@/lib/errors";
import { config } from "@/lib/config";
@@ -58,7 +55,7 @@ export const use = createCommand({
}
}
const embed = getItemUseResultEmbed(result.results);
const embed = getItemUseResultEmbed(result.results, result.item);
await interaction.editReply({ embeds: [embed] });
@@ -75,28 +72,8 @@ export const use = createCommand({
const focusedValue = interaction.options.getFocused();
const userId = interaction.user.id;
// Fetch owned items that match the search query
// We join with items table to filter by name directly in the database
const entries = await DrizzleClient.select({
quantity: inventory.quantity,
item: items
})
.from(inventory)
.innerJoin(items, eq(inventory.itemId, items.id))
.where(and(
eq(inventory.userId, BigInt(userId)),
like(items.name, `%${focusedValue}%`)
))
.limit(20); // Fetch up to 20 matching items
const results = await inventoryService.getAutocompleteItems(userId, focusedValue);
const filtered = entries.filter(entry => {
const usageData = entry.item.usageData as ItemUsageData | null;
const isUsable = usageData && usageData.effects && usageData.effects.length > 0;
return isUsable;
});
await interaction.respond(
filtered.map(entry => ({ name: `${entry.item.name} (${entry.quantity})`, value: entry.item.id }))
);
await interaction.respond(results);
}
});

View File

@@ -1,8 +1,8 @@
import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { DrizzleClient } from "@/lib/DrizzleClient";
import { users } from "@/db/schema";
import { desc } from "drizzle-orm";
import { users, items, inventory } from "@/db/schema";
import { desc, sql, eq } from "drizzle-orm";
import { createWarningEmbed } from "@lib/embeds";
import { getLeaderboardEmbed } from "@/modules/leveling/leveling.view";
@@ -12,30 +12,49 @@ export const leaderboard = createCommand({
.setDescription("View the top players")
.addStringOption(option =>
option.setName("type")
.setDescription("Sort by XP or Balance")
.setDescription("Sort by XP, Balance, or Net Worth")
.setRequired(true)
.addChoices(
{ name: "Level / XP", value: "xp" },
{ name: "Balance", value: "balance" }
{ name: "Balance", value: "balance" },
{ name: "Net Worth", value: "networth" }
)
),
execute: async (interaction) => {
await interaction.deferReply();
const type = interaction.options.getString("type", true);
const isXp = type === "xp";
const leaders = await DrizzleClient.query.users.findMany({
orderBy: isXp ? desc(users.xp) : desc(users.balance),
limit: 10
});
let leaders;
if (type === 'networth') {
leaders = await DrizzleClient.select({
username: users.username,
level: users.level,
xp: users.xp,
balance: users.balance,
netWorth: sql<bigint>`${users.balance} + COALESCE(SUM(${items.price} * ${inventory.quantity}), 0)`.as('net_worth')
})
.from(users)
.leftJoin(inventory, eq(users.id, inventory.userId))
.leftJoin(items, eq(inventory.itemId, items.id))
.groupBy(users.id)
.orderBy(desc(sql`net_worth`))
.limit(10);
} else {
const isXp = type === "xp";
leaders = await DrizzleClient.query.users.findMany({
orderBy: isXp ? desc(users.xp) : desc(users.balance),
limit: 10
});
}
if (leaders.length === 0) {
await interaction.editReply({ embeds: [createWarningEmbed("No users found.", "Leaderboard")] });
return;
}
const embed = getLeaderboardEmbed(leaders, isXp ? 'xp' : 'balance');
const embed = getLeaderboardEmbed(leaders, type as 'xp' | 'balance' | 'networth');
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,
integer,
primaryKey,
index,
bigserial,
check
} from 'drizzle-orm/pg-core';
@@ -41,7 +42,11 @@ export const users = pgTable('users', {
settings: jsonb('settings').default({}),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
});
}, (table) => [
index('users_username_idx').on(table.username),
index('users_balance_idx').on(table.balance),
index('users_level_xp_idx').on(table.level, table.xp),
]);
// 3. Items
export const items = pgTable('items', {
@@ -82,7 +87,9 @@ export const transactions = pgTable('transactions', {
type: varchar('type', { length: 50 }).notNull(),
description: text('description'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
});
}, (table) => [
index('transactions_created_at_idx').on(table.createdAt),
]);
export const itemTransactions = pgTable('item_transactions', {
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
@@ -129,7 +136,9 @@ export const userTimers = pgTable('user_timers', {
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
metadata: jsonb('metadata').default({}), // Store channelId, specific buff amounts, etc.
}, (table) => [
primaryKey({ columns: [table.userId, table.type, table.key] })
primaryKey({ columns: [table.userId, table.type, table.key] }),
index('user_timers_expires_at_idx').on(table.expiresAt),
index('user_timers_lookup_idx').on(table.userId, table.type, table.key),
]);
// 9. Lootdrops
export const lootdrops = pgTable('lootdrops', {
@@ -158,7 +167,10 @@ export const moderationCases = pgTable('moderation_cases', {
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
resolvedBy: bigint('resolved_by', { mode: 'bigint' }),
resolvedReason: text('resolved_reason'),
});
}, (table) => [
index('moderation_cases_user_id_idx').on(table.userId),
index('moderation_cases_case_id_idx').on(table.caseId),
]);

View File

@@ -1,14 +1,26 @@
import { AuroraClient } from "@/lib/BotClient";
import { env } from "@lib/env";
import { WebServer } from "@/web/server";
// Load commands & events
await AuroraClient.loadCommands();
await AuroraClient.loadEvents();
await AuroraClient.deployCommands();
WebServer.start();
// login with the token from .env
if (!env.DISCORD_BOT_TOKEN) {
throw new Error("❌ DISCORD_BOT_TOKEN is not set in environment variables.");
}
AuroraClient.login(env.DISCORD_BOT_TOKEN);
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

@@ -9,6 +9,7 @@ import { logger } from "@lib/logger";
export class Client extends DiscordClient {
commands: Collection<string, Command>;
lastCommandTimestamp: number | null = null;
private commandLoader: CommandLoader;
private eventLoader: EventLoader;
@@ -93,6 +94,29 @@ export class Client extends DiscordClient {
}
}
}
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] });

View File

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

View File

@@ -69,6 +69,7 @@ export interface GameConfigType {
autoTimeoutThreshold?: number;
};
};
system: Record<string, any>;
}
// Initial default config state
@@ -160,7 +161,8 @@ const configSchema = z.object({
cases: {
dmOnWarn: true
}
})
}),
system: z.record(z.string(), z.any()).default({}),
});
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 type { Transaction } from "./types";
import { isShuttingDown, incrementTransactions, decrementTransactions } from "./shutdown";
export const withTransaction = async <T>(
callback: (tx: Transaction) => Promise<T>,
@@ -8,8 +9,17 @@ export const withTransaction = async <T>(
if (tx) {
return await callback(tx);
} else {
return await DrizzleClient.transaction(async (newTx) => {
return await callback(newTx);
});
if (isShuttingDown()) {
throw new Error("System is shutting down, no new transactions allowed.");
}
incrementTransactions();
try {
return await DrizzleClient.transaction(async (newTx) => {
return await callback(newTx);
});
} finally {
decrementTransactions();
}
}
};

View File

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

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

@@ -26,6 +26,7 @@ export class CommandHandler {
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!');

View File

@@ -40,7 +40,7 @@ export class CommandLoader {
continue;
}
if (!file.name.endsWith('.ts') && !file.name.endsWith('.js')) 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);
}

38
src/lib/logger.test.ts Normal file
View File

@@ -0,0 +1,38 @@
import { describe, it, expect, beforeEach } from "bun:test";
import { logger, getRecentLogs } from "./logger";
describe("Logger Buffer", () => {
// Note: Since the buffer is a module-level variable, it persists across tests.
// In a real scenario we might want a reset function, but for now we'll just check relative additions.
it("should add logs to the buffer", () => {
const initialLength = getRecentLogs().length;
logger.info("Test Info Log");
const newLogs = getRecentLogs();
expect(newLogs.length).toBe(initialLength + 1);
expect(newLogs[0]?.message).toBe("Test Info Log");
expect(newLogs[0]?.type).toBe("info");
});
it("should cap the buffer size at 50", () => {
// Fill the buffer
for (let i = 0; i < 60; i++) {
logger.debug(`Log overflow test ${i}`);
}
const logs = getRecentLogs();
expect(logs.length).toBeLessThanOrEqual(50);
expect(logs[0]?.message).toBe("Log overflow test 59");
});
it("should handle different log levels", () => {
logger.error("Critical Error");
logger.success("Operation Successful");
const logs = getRecentLogs();
expect(logs[0]?.type).toBe("success");
expect(logs[1]?.type).toBe("error");
});
});

View File

@@ -1,12 +1,32 @@
import { WebServer } from "@/web/server";
/**
* Centralized logging utility with consistent formatting
*/
const LOG_BUFFER_SIZE = 50;
const logBuffer: Array<{ time: string; type: string; message: string }> = [];
function addToBuffer(type: string, message: string) {
const time = new Date().toLocaleTimeString();
logBuffer.unshift({ time, type, message });
if (logBuffer.length > LOG_BUFFER_SIZE) {
logBuffer.pop();
}
}
export function getRecentLogs() {
return logBuffer;
}
export const logger = {
/**
* General information message
*/
info: (message: string, ...args: any[]) => {
console.log(` ${message}`, ...args);
addToBuffer("info", message);
try { WebServer.broadcastLog("info", message); } catch { }
},
/**
@@ -14,6 +34,8 @@ export const logger = {
*/
success: (message: string, ...args: any[]) => {
console.log(`${message}`, ...args);
addToBuffer("success", message);
try { WebServer.broadcastLog("success", message); } catch { }
},
/**
@@ -21,6 +43,8 @@ export const logger = {
*/
warn: (message: string, ...args: any[]) => {
console.warn(`⚠️ ${message}`, ...args);
addToBuffer("warning", message);
try { WebServer.broadcastLog("warning", message); } catch { }
},
/**
@@ -28,6 +52,8 @@ export const logger = {
*/
error: (message: string, ...args: any[]) => {
console.error(`${message}`, ...args);
addToBuffer("error", message);
try { WebServer.broadcastLog("error", message); } catch { }
},
/**
@@ -35,5 +61,7 @@ export const logger = {
*/
debug: (message: string, ...args: any[]) => {
console.log(`🔍 ${message}`, ...args);
addToBuffer("debug", message);
try { WebServer.broadcastLog("debug", message); } catch { }
},
};

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 { LootType, EffectType } from "./constants";
import { DrizzleClient } from "./DrizzleClient";
export interface Command {
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder;
@@ -14,19 +16,28 @@ export interface Event<K extends keyof ClientEvents> {
}
export type ItemEffect =
| { type: 'ADD_XP'; amount: number }
| { type: 'ADD_BALANCE'; amount: number }
| { type: 'XP_BOOST'; multiplier: number; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
| { type: 'TEMP_ROLE'; roleId: string; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
| { type: 'REPLY_MESSAGE'; message: string }
| { type: 'COLOR_ROLE'; roleId: string };
| { type: EffectType.ADD_XP; amount: number }
| { type: EffectType.ADD_BALANCE; amount: number }
| { type: EffectType.XP_BOOST; multiplier: number; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
| { type: EffectType.TEMP_ROLE; roleId: string; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
| { type: EffectType.REPLY_MESSAGE; message: string }
| { type: EffectType.COLOR_ROLE; roleId: string }
| { type: EffectType.LOOTBOX; pool: LootTableItem[] };
export interface LootTableItem {
type: LootType;
weight: number;
amount?: number; // For CURRENCY, XP
itemId?: number; // For ITEM
minAmount?: number; // Optional range for CURRENCY/XP
maxAmount?: number; // Optional range for CURRENCY/XP
message?: string; // Optional custom message for this outcome
}
export interface ItemUsageData {
consume: boolean;
effects: ItemEffect[];
}
import { DrizzleClient } from "./DrizzleClient";
export type DbClient = typeof DrizzleClient;
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 { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
import type { DraftItem } from "./item_wizard.types";
import { ItemType, EffectType } from "@/lib/constants";
// --- Types ---
@@ -23,7 +24,7 @@ export const renderWizard = (userId: string, isDraft = true) => {
name: "New Item",
description: "No description",
rarity: "Common",
type: "MATERIAL",
type: ItemType.MATERIAL,
price: null,
iconUrl: "",
imageUrl: "",
@@ -176,26 +177,26 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
if (type) {
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"));
if (!isNaN(amount)) effect = { type: type as any, amount };
}
else if (type === "REPLY_MESSAGE") {
effect = { type: "REPLY_MESSAGE", message: interaction.fields.getTextInputValue("message") };
else if (type === EffectType.REPLY_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 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 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");
if (roleId) effect = { type: "COLOR_ROLE", roleId: roleId };
if (roleId) effect = { type: EffectType.COLOR_ROLE, roleId: roleId };
}
if (effect) {

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import { config } from "@/lib/config";
import { withTransaction } from "@/lib/db";
import type { Transaction } from "@/lib/types";
import { UserError } from "@/lib/errors";
import { TimerType, TransactionType } from "@/lib/constants";
export const economyService = {
transfer: async (fromUserId: string, toUserId: string, amount: bigint, tx?: Transaction) => {
@@ -48,7 +49,7 @@ export const economyService = {
await txFn.insert(transactions).values({
userId: BigInt(fromUserId),
amount: -amount,
type: 'TRANSFER_OUT',
type: TransactionType.TRANSFER_OUT,
description: `Transfer to ${toUserId}`,
});
@@ -56,7 +57,7 @@ export const economyService = {
await txFn.insert(transactions).values({
userId: BigInt(toUserId),
amount: amount,
type: 'TRANSFER_IN',
type: TransactionType.TRANSFER_IN,
description: `Transfer from ${fromUserId}`,
});
@@ -67,20 +68,18 @@ export const economyService = {
claimDaily: async (userId: string, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const now = new Date();
const startOfDay = new Date(now);
startOfDay.setHours(0, 0, 0, 0);
// Check cooldown
const cooldown = await txFn.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, BigInt(userId)),
eq(userTimers.type, 'COOLDOWN'),
eq(userTimers.type, TimerType.COOLDOWN),
eq(userTimers.key, 'daily')
),
});
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
@@ -89,17 +88,23 @@ export const economyService = {
});
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;
// 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) {
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) {
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 {
streak = 1;
}
@@ -119,13 +124,15 @@ export const economyService = {
})
.where(eq(users.id, BigInt(userId)));
// Set new cooldown (now + 24h)
const nextReadyAt = new Date(now.getTime() + config.economy.daily.cooldownMs);
// Set new cooldown (Next UTC Midnight)
const nextReadyAt = new Date(now);
nextReadyAt.setUTCDate(nextReadyAt.getUTCDate() + 1);
nextReadyAt.setUTCHours(0, 0, 0, 0);
await txFn.insert(userTimers)
.values({
userId: BigInt(userId),
type: 'COOLDOWN',
type: TimerType.COOLDOWN,
key: 'daily',
expiresAt: nextReadyAt,
})
@@ -138,7 +145,7 @@ export const economyService = {
await txFn.insert(transactions).values({
userId: BigInt(userId),
amount: totalReward,
type: 'DAILY_REWARD',
type: TransactionType.DAILY_REWARD,
description: `Daily reward (Streak: ${streak})`,
});

View File

@@ -27,7 +27,7 @@ class LootdropService {
// Cleanup interval for activity tracking and expired lootdrops
setInterval(() => {
this.cleanupActivity();
this.cleanupExpiredLootdrops();
this.cleanupExpiredLootdrops(true);
}, 60000);
}
@@ -45,16 +45,24 @@ class LootdropService {
}
}
private async cleanupExpiredLootdrops() {
public async cleanupExpiredLootdrops(includeClaimed: boolean = false): Promise<number> {
try {
const now = new Date();
await DrizzleClient.delete(lootdrops)
.where(and(
isNull(lootdrops.claimedBy),
lt(lootdrops.expiresAt, now)
));
const whereClause = includeClaimed
? lt(lootdrops.expiresAt, now)
: and(isNull(lootdrops.claimedBy), 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) {
console.error("Failed to cleanup lootdrops:", error);
return 0;
}
}

View File

@@ -2,6 +2,11 @@ import { levelingService } from "@/modules/leveling/leveling.service";
import { economyService } from "@/modules/economy/economy.service";
import { userTimers } from "@/db/schema";
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
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) => {
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} 🪙`;
};
@@ -29,7 +34,7 @@ export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
const expiresAt = new Date(Date.now() + boostDuration * 1000);
await txFn.insert(userTimers).values({
userId: BigInt(userId),
type: 'EFFECT',
type: TimerType.EFFECT,
key: 'xp_boost',
expiresAt: expiresAt,
metadata: { multiplier: effect.multiplier }
@@ -45,7 +50,7 @@ export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
await txFn.insert(userTimers).values({
userId: BigInt(userId),
type: 'ACCESS',
type: TimerType.ACCESS,
key: `role_${effect.roleId}`,
expiresAt: roleExpiresAt,
metadata: { roleId: effect.roleId }
@@ -60,3 +65,73 @@ export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
export const handleColorRole: EffectHandler = async (_userId, _effect, _txFn) => {
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,
handleXpBoost,
handleTempRole,
handleColorRole
handleColorRole,
handleLootbox
} from "./handlers";
import type { EffectHandler } from "./types";
@@ -14,5 +15,6 @@ export const effectHandlers: Record<string, EffectHandler> = {
'REPLY_MESSAGE': handleReplyMessage,
'XP_BOOST': handleXpBoost,
'TEMP_ROLE': handleTempRole,
'COLOR_ROLE': handleColorRole
'COLOR_ROLE': handleColorRole,
'LOOTBOX': handleLootbox
};

View File

@@ -18,6 +18,8 @@ const mockWhere = mock();
const mockSelect = mock();
const mockFrom = mock();
const mockOnConflictDoUpdate = mock();
const mockInnerJoin = mock();
const mockLimit = mock();
// Chain setup
mockInsert.mockReturnValue({ values: mockValues });
@@ -34,7 +36,10 @@ mockWhere.mockReturnValue({ returning: mockReturning });
mockDelete.mockReturnValue({ where: mockWhere });
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.module("@/lib/DrizzleClient", () => {
@@ -239,4 +244,39 @@ describe("inventoryService", () => {
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 { eq, and, sql, count } from "drizzle-orm";
import { eq, and, sql, count, ilike } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient";
import { economyService } from "@/modules/economy/economy.service";
import { levelingService } from "@/modules/leveling/leveling.service";
@@ -7,6 +7,7 @@ import { config } from "@/lib/config";
import { UserError } from "@/lib/errors";
import { withTransaction } from "@/lib/db";
import type { Transaction, ItemUsageData } from "@/lib/types";
import { TransactionType } from "@/lib/constants";
@@ -121,7 +122,7 @@ export const inventoryService = {
const totalPrice = item.price * quantity;
// 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);
@@ -159,7 +160,6 @@ export const inventoryService = {
const results: string[] = [];
// 2. Apply Effects
// 2. Apply Effects
const { effectHandlers } = await import("./effects/registry");
@@ -179,7 +179,31 @@ export const inventoryService = {
await inventoryService.removeItem(userId, itemId, 1n, txFn);
}
return { success: true, results, usageData };
return { success: true, results, usageData, item };
}, 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

@@ -1,5 +1,6 @@
import { EmbedBuilder } from "discord.js";
import type { ItemUsageData } from "@/lib/types";
import { EffectType } from "@/lib/constants";
/**
* Inventory entry with item details
@@ -30,11 +31,24 @@ export function getInventoryEmbed(items: InventoryEntry[], username: string): Em
/**
* Creates an embed showing the results of using an item
*/
export function getItemUseResultEmbed(results: string[], itemName?: string): EmbedBuilder {
export function getItemUseResultEmbed(results: string[], item?: { name: string, iconUrl: string | null, usageData: any }): EmbedBuilder {
const description = results.map(r => `${r}`).join("\n");
return new EmbedBuilder()
.setTitle("✅ Item Used!")
// 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(0x2ecc71); // Green/Success
.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
// lvl 1: 100 * 1^1.5 = 100
// lvl 2: 100 * 2^1.5 = 100 * 2.828 = 282
expect(levelingService.getXpForLevel(1)).toBe(100);
expect(levelingService.getXpForLevel(2)).toBe(282);
expect(levelingService.getXpForNextLevel(1)).toBe(100);
expect(levelingService.getXpForNextLevel(2)).toBe(282);
});
});
@@ -123,7 +123,7 @@ describe("levelingService", () => {
expect(result.levelUp).toBe(true);
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 () => {

View File

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

View File

@@ -8,6 +8,7 @@ interface LeaderboardUser {
level: number | null;
xp: bigint | null;
balance: bigint | null;
netWorth?: bigint | null;
}
/**
@@ -23,23 +24,45 @@ function getMedalEmoji(index: number): string {
/**
* Formats a single leaderboard entry based on type
*/
function formatLeaderEntry(user: LeaderboardUser, index: number, type: 'xp' | 'balance'): string {
function formatLeaderEntry(user: LeaderboardUser, index: number, type: 'xp' | 'balance' | 'networth'): string {
const medal = getMedalEmoji(index);
const value = type === 'xp'
? `Lvl ${user.level ?? 1} (${user.xp ?? 0n} XP)`
: `${user.balance ?? 0n} 🪙`;
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 or Balance rankings
* Creates a leaderboard embed for either XP, Balance or Net Worth rankings
*/
export function getLeaderboardEmbed(leaders: LeaderboardUser[], type: 'xp' | 'balance'): EmbedBuilder {
export function getLeaderboardEmbed(leaders: LeaderboardUser[], type: 'xp' | 'balance' | 'networth'): EmbedBuilder {
const description = leaders.map((user, index) =>
formatLeaderEntry(user, index, type)
).join("\n");
const title = type === 'xp' ? "🏆 XP Leaderboard" : "💰 Richest Players";
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)

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 { eq, and, desc } from "drizzle-orm";
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 {
/**
@@ -41,12 +44,85 @@ export class ModerationService {
moderatorName: options.moderatorName,
reason: options.reason,
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();
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
*/
@@ -79,7 +155,7 @@ export class ModerationService {
return await DrizzleClient.query.moderationCases.findMany({
where: and(
eq(moderationCases.userId, BigInt(userId)),
eq(moderationCases.type, 'warn'),
eq(moderationCases.type, CaseType.WARN),
eq(moderationCases.active, true)
),
orderBy: [desc(moderationCases.createdAt)],
@@ -93,7 +169,7 @@ export class ModerationService {
return await DrizzleClient.query.moderationCases.findMany({
where: and(
eq(moderationCases.userId, BigInt(userId)),
eq(moderationCases.type, 'note')
eq(moderationCases.type, CaseType.NOTE)
),
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 {
type: CaseType;

View File

@@ -6,6 +6,7 @@ import { economyService } from "@/modules/economy/economy.service";
import { levelingService } from "@/modules/leveling/leveling.service";
import { withTransaction } from "@/lib/db";
import type { Transaction } from "@/lib/types";
import { TransactionType } from "@/lib/constants";
export const questService = {
assignQuest: async (userId: string, questId: number, tx?: Transaction) => {
@@ -62,7 +63,7 @@ export const questService = {
if (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;
}

View File

@@ -1,86 +1,21 @@
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 { temporaryRoleService } from "./temp-role.service";
/**
* The Janitor responsible for cleaning up expired ACCESS timers
* and revoking privileges.
*/
export const schedulerService = {
start: () => {
console.log("🕒 Scheduler started: Janitor loop running every 60s");
// Run immediately on start
schedulerService.runJanitor();
console.log("🕒 Scheduler started: Maintenance loops initialized.");
// Loop every 60 seconds
// 1. Temporary Role Revocation (every 60s)
setInterval(() => {
schedulerService.runJanitor();
temporaryRoleService.processExpiredRoles();
}, 60 * 1000);
// Terminal Update Loop (every 60s)
// 2. Terminal Update Loop (every 60s)
const { terminalService } = require("@/modules/terminal/terminal.service");
setInterval(() => {
terminalService.update();
}, 60 * 1000);
},
runJanitor: async () => {
try {
// 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);
}
// Run an initial check on start
temporaryRoleService.processExpiredRoles();
}
};

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 { DrizzleClient } from "@/lib/DrizzleClient";
import { users, transactions, lootdrops } from "@/db/schema";
import { desc } from "drizzle-orm";
import { users, transactions, lootdrops, inventory } from "@/db/schema";
import { desc, sql } from "drizzle-orm";
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 = {
init: async (channel: TextChannel) => {
// limit to one terminal for now
// Limit to one terminal for now
if (config.terminal) {
try {
const oldChannel = await AuroraClient.channels.fetch(config.terminal.channelId) as TextChannel;
@@ -16,11 +33,11 @@ export const terminalService = {
if (oldMsg) await oldMsg.delete();
}
} 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 = {
channelId: channel.id,
@@ -48,8 +65,6 @@ export const terminalService = {
const containers = await terminalService.buildMessage();
// Components V2 requires the IsComponentsV2 flag and no content/embeds
// Disable allowedMentions to prevent pings from the dashboard
await message.edit({
content: null,
embeds: null as any,
@@ -64,24 +79,51 @@ export const terminalService = {
},
buildMessage: async () => {
// 1. Data Fetching
// ═══════════════════════════════════════════════════════════
// DATA FETCHING
// ═══════════════════════════════════════════════════════════
const allUsers = await DrizzleClient.select().from(users);
const totalUsers = allUsers.length;
const totalWealth = allUsers.reduce((acc, u) => acc + (u.balance || 0n), 0n);
// 2. Leaderboards Calculation
const topLevels = [...allUsers].sort((a, b) => (b.level || 0) - (a.level || 0)).slice(0, 3);
const topWealth = [...allUsers].sort((a, b) => Number(b.balance || 0n) - Number(a.balance || 0n)).slice(0, 3);
// System stats
const uptime = process.uptime();
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) => {
const star = i === 0 ? "🌟" : i === 1 ? "⭐" : "✨";
return `${star} <@${u.id}>`;
};
// Guild member count (if available)
const guild = AuroraClient.guilds.cache.first();
const memberCount = guild?.memberCount ?? totalUsers;
const levelText = topLevels.map((u, i) => `> ${formatUser(u, i)} • Lvl ${u.level}`).join("\n") || "> *The sky is empty...*";
const wealthText = topWealth.map((u, i) => `> ${formatUser(u, i)}${u.balance} AU`).join("\n") || "> *The sky is empty...*";
// Additional metrics
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({
where: (lootdrops, { isNull }) => isNull(lootdrops.claimedBy),
limit: 1,
@@ -94,65 +136,166 @@ export const terminalService = {
orderBy: desc(lootdrops.createdAt)
});
// --- CONTAINER 1: Header ---
const headerContainer = new ContainerBuilder()
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("# 🌌 AURORA OBSERVATORY"),
new TextDisplayBuilder().setContent("*Current Moon Phase: Waxing Crescent 🌒*")
);
// --- CONTAINER 2: Observation Log ---
let phenomenaContent = "";
if (activeDrops.length > 0 && activeDrops[0]) {
const drop = activeDrops[0];
phenomenaContent = `\n**SHOOTING STAR DETECTED**\nRadiance: \`${drop.rewardAmount} ${drop.currency}\`\nCoordinates: <#${drop.channelId}>\nImpact: <t:${Math.floor(drop.expiresAt!.getTime() / 1000)}:R>`;
} else if (recentDrops.length > 0 && recentDrops[0]) {
const drop = recentDrops[0];
const claimer = allUsers.find(u => u.id === drop.claimedBy);
phenomenaContent = `\n**RECENT EVENT**\nStar yielded \`${drop.rewardAmount} ${drop.currency}\` to ${claimer ? `<@${claimer.id}>` : '**Unknown**'}`;
}
const logContainer = new ContainerBuilder()
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("## 🔭 OBSERVATION LOG"),
new TextDisplayBuilder().setContent(`> **Stargazers**: \`${totalUsers}\`\n> **Astral Wealth**: \`${totalWealth.toLocaleString()} AU\`${phenomenaContent}`)
);
// --- CONTAINER 3: Leaders ---
const leaderContainer = new ContainerBuilder()
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("## ✨ CONSTELLATION LEADERS"),
new TextDisplayBuilder().setContent(`**Brightest Stars**\n${levelText}`),
new TextDisplayBuilder().setContent(`**Gilded Nebulas**\n${wealthText}`)
);
// --- CONTAINER 4: Echoes ---
// Recent transactions
const recentTx = await DrizzleClient.query.transactions.findMany({
limit: 5,
limit: 3,
orderBy: [desc(transactions.createdAt)]
});
const activityLines = recentTx.map(tx => {
const time = Math.floor(tx.createdAt!.getTime() / 1000);
let icon = "💫";
if (tx.type.includes("LOOT")) icon = "🌠";
if (tx.type.includes("GIFT")) icon = "🌕";
const user = allUsers.find(u => u.id === tx.userId);
// ═══════════════════════════════════════════════════════════
// HELPER FORMATTERS
// ═══════════════════════════════════════════════════════════
// the description might contain a channel id all the way at the end
const channelId = tx.description?.split(" ").pop() || "";
const text = tx.description?.replace(channelId, "<#" + channelId + ">") || "";
return `<t:${time}:F> ${icon} ${user ? `<@${user.id}>` : '**Unknown**'}: ${text}`;
});
const getMedal = (i: number) => i === 0 ? "🥇" : i === 1 ? "🥈" : "🥉";
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(
new TextDisplayBuilder().setContent("## 📡 COSMIC ECHOES"),
new TextDisplayBuilder().setContent(activityLines.join("\n") || "Silence...")
new TextDisplayBuilder().setContent("# 🔮 AURORA STATION"),
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

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

@@ -4,6 +4,7 @@ import { inventoryService } from "@/modules/inventory/inventory.service";
import { itemTransactions } from "@/db/schema";
import { withTransaction } from "@/lib/db";
import type { Transaction } from "@/lib/types";
import { TransactionType, ItemTransactionType } from "@/lib/constants";
// Module-level session storage
const sessions = new Map<string, TradeSession>();
@@ -25,7 +26,7 @@ const processTransfer = async (tx: Transaction, from: TradeParticipant, to: Trad
await economyService.modifyUserBalance(
from.id,
-from.offer.money,
'TRADE_OUT',
TransactionType.TRADE_OUT,
`Trade with ${to.username} (Thread: ${threadId})`,
to.id,
tx
@@ -33,7 +34,7 @@ const processTransfer = async (tx: Transaction, from: TradeParticipant, to: Trad
await economyService.modifyUserBalance(
to.id,
from.offer.money,
'TRADE_IN',
TransactionType.TRADE_IN,
`Trade with ${from.username} (Thread: ${threadId})`,
from.id,
tx
@@ -54,7 +55,7 @@ const processTransfer = async (tx: Transaction, from: TradeParticipant, to: Trad
relatedUserId: BigInt(to.id),
itemId: item.id,
quantity: -item.quantity,
type: 'TRADE_OUT',
type: ItemTransactionType.TRADE_OUT,
description: `Traded to ${to.username}`,
});
@@ -64,13 +65,15 @@ const processTransfer = async (tx: Transaction, from: TradeParticipant, to: Trad
relatedUserId: BigInt(from.id),
itemId: item.id,
quantity: item.quantity,
type: 'TRADE_IN',
type: ItemTransactionType.TRADE_IN,
description: `Received from ${from.username}`,
});
}
};
export const tradeService = {
// Expose for testing
_sessions: sessions,
/**
* Creates a new trade session
*/

View File

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

216
src/web/public/script.js Normal file
View File

@@ -0,0 +1,216 @@
function formatUptime(seconds) {
if (seconds < 0) return "0s";
const days = Math.floor(seconds / (3600 * 24));
const hours = Math.floor((seconds % (3600 * 24)) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
const parts = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
parts.push(`${secs}s`);
return parts.join(" ");
}
function updateUptime() {
const elements = document.querySelectorAll(".uptime-display, #uptime-display");
elements.forEach(el => {
const startTimestamp = parseInt(el.getAttribute("data-start-timestamp"), 10);
if (isNaN(startTimestamp)) return;
const now = Date.now();
const elapsedSeconds = (now - startTimestamp) / 1000;
el.textContent = formatUptime(elapsedSeconds);
});
}
document.addEventListener("DOMContentLoaded", () => {
// Initialize Lucide Icons
if (window.lucide) {
window.lucide.createIcons();
}
// Update immediately to prevent stale content flash if possible
updateUptime();
// Update every second
setInterval(updateUptime, 1000);
// WebSocket Connection
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/ws`;
function connectWs() {
const ws = new WebSocket(wsUrl);
const statusIndicator = document.querySelector(".status-indicator");
ws.onopen = () => {
console.log("WS Connected");
if (statusIndicator) statusIndicator.classList.add("online");
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === "HEARTBEAT") {
updateVitals(msg.data);
} else if (msg.type === "WELCOME") {
console.log(msg.message);
} else if (msg.type === "LOG") {
appendToActivityFeed(msg.data);
}
} catch (e) {
console.error("WS Parse Error", e);
}
};
function updateVitals(data) {
// Update Stats
if (data.guildCount !== undefined) {
const el = document.getElementById("stat-servers");
if (el) el.textContent = data.guildCount;
}
if (data.userCount !== undefined) {
const el = document.getElementById("stat-users");
if (el) el.textContent = data.userCount;
}
if (data.commandCount !== undefined) {
const el = document.getElementById("stat-commands");
if (el) el.textContent = data.commandCount;
}
if (data.ping !== undefined) {
const el = document.getElementById("stat-ping");
if (el) el.textContent = `${data.ping < 0 ? "?" : data.ping}ms`;
const trend = document.getElementById("stat-ping-trend");
if (trend) {
trend.className = `stat-trend ${data.ping < 100 ? "up" : "down"}`;
const icon = trend.querySelector('i');
if (icon) {
icon.setAttribute('data-lucide', data.ping < 100 ? "check-circle" : "alert-circle");
if (window.lucide) window.lucide.createIcons();
}
const text = trend.querySelector('span');
if (text) text.textContent = data.ping < 100 ? "Excellent" : "Decent";
}
}
// Update System Health
if (data.memory !== undefined) {
const el = document.getElementById("stat-memory");
if (el && data.memoryTotal) {
el.textContent = `${data.memory} / ${data.memoryTotal} MB`;
} else if (el) {
el.textContent = `${data.memory} MB`;
}
const bar = document.getElementById("stat-memory-bar");
if (bar && data.memoryTotal) {
const percent = Math.min(100, (data.memory / data.memoryTotal) * 100);
bar.style.width = `${percent}%`;
}
}
if (data.uptime !== undefined) {
// We handle uptime fluidly in updateUptime() using data-start-timestamp.
// We just ensure the attribute is kept in sync if needed (though startTimestamp shouldn't change).
const elements = document.querySelectorAll(".uptime-display, #uptime-display");
elements.forEach(el => {
const currentStart = parseInt(el.getAttribute("data-start-timestamp"), 10);
const newStart = Math.floor(Date.now() - (data.uptime * 1000));
// Only update if there's a significant drift (> 5s)
if (isNaN(currentStart) || Math.abs(currentStart - newStart) > 5000) {
el.setAttribute("data-start-timestamp", newStart);
}
});
}
}
function appendToActivityFeed(log) {
const list = document.querySelector(".activity-feed");
if (!list) return;
const item = document.createElement("li");
item.className = "activity-item";
const timeSpan = document.createElement("span");
timeSpan.className = "time";
timeSpan.textContent = log.timestamp;
const messageSpan = document.createElement("span");
messageSpan.className = "message";
messageSpan.textContent = log.message;
item.appendChild(timeSpan);
item.appendChild(messageSpan);
// Prepend
list.insertBefore(item, list.firstChild);
if (list.children.length > 50) list.lastChild.remove();
}
ws.onclose = () => {
console.log("WS Disconnected");
if (statusIndicator) statusIndicator.classList.remove("online");
// Retry in 5s
setTimeout(connectWs, 5000);
};
ws.onerror = (err) => {
console.error("WS Error", err);
ws.close();
};
}
// Action Buttons
document.querySelectorAll(".btn[data-action]").forEach(btn => {
btn.addEventListener("click", async () => {
const action = btn.getAttribute("data-action");
const actionName = btn.textContent.trim();
if (action === "restart_bot") {
if (!confirm("Are you sure you want to restart the bot? This will cause a brief downtime.")) {
return;
}
}
btn.disabled = true;
const originalText = btn.textContent;
btn.textContent = "Processing...";
try {
const response = await fetch("/api/actions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action })
});
const result = await response.json();
if (result.success) {
alert(`${actionName} successful: ${result.message}`);
if (action === "restart_bot") {
btn.textContent = "Restarting...";
setTimeout(() => window.location.reload(), 5000);
} else {
btn.disabled = false;
btn.textContent = originalText;
}
} else {
alert(`Error: ${result.error}`);
btn.disabled = false;
btn.textContent = originalText;
}
} catch (err) {
console.error("Action error:", err);
alert("Failed to execute action. Check console.");
btn.disabled = false;
btn.textContent = originalText;
}
});
});
connectWs();
});

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

@@ -0,0 +1,385 @@
:root {
/* Geist Inspired Minimal Palette */
--background: #000;
--foreground: #fff;
--accents-1: #111;
--accents-2: #333;
--accents-3: #444;
--accents-4: #666;
--accents-5: #888;
--accents-6: #999;
--accents-7: #eaeaea;
--accents-8: #fafafa;
--success: #0070f3;
--success-light: #3291ff;
--error: #ee0000;
--error-light: #ff1a1a;
--warning: #f5a623;
--warning-light: #f7b955;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
--font-mono: 'Menlo', 'Monaco', 'Lucida Console', 'Liberation Mono', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', 'Courier New', monospace;
--radius: 5px;
--header-height: 64px;
--sidebar-width: 240px;
}
* {
box-sizing: border-box;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
margin: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-size: 14px;
}
/* Typography */
h1,
h2,
h3,
h4 {
font-weight: 600;
margin: 0;
color: var(--foreground);
}
h1 {
font-size: 2rem;
letter-spacing: -0.05rem;
}
h2 {
font-size: 1.5rem;
letter-spacing: -0.02rem;
}
h3 {
font-size: 1rem;
}
a {
color: var(--accents-5);
text-decoration: none;
transition: color 0.2s ease;
}
a:hover {
color: var(--foreground);
}
/* Header */
header {
height: var(--header-height);
border-bottom: 1px solid var(--accents-2);
display: flex;
align-items: center;
padding: 0 24px;
position: sticky;
top: 0;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: saturate(180%) blur(5px);
z-index: 1000;
}
header h1 {
font-size: 1.25rem;
font-weight: 700;
}
header nav {
margin-left: 24px;
display: flex;
gap: 16px;
}
header nav a {
font-size: 0.875rem;
}
header nav a.active {
color: var(--foreground);
}
/* Layout */
main {
max-width: 1000px;
margin: 0 auto;
padding: 48px 24px;
}
/* Dashboard Grid */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
/* Cards */
.card,
.panel,
.stat-card {
background: var(--background);
border: 1px solid var(--accents-2);
border-radius: var(--radius);
padding: 24px;
transition: border-color 0.2s ease;
}
.card:hover,
.panel:hover,
.stat-card:hover {
border-color: var(--accents-4);
}
.stat-card {
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 120px;
}
.stat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.stat-header h3 {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--accents-5);
letter-spacing: 1px;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
letter-spacing: -1px;
}
.stat-trend {
font-size: 0.75rem;
margin-top: 8px;
color: var(--accents-5);
display: flex;
align-items: center;
gap: 4px;
}
.stat-trend.up {
color: var(--success);
}
.stat-trend.down {
color: var(--error);
}
/* Panels */
.dashboard-main {
grid-column: 1 / -1;
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: 24px;
margin-top: 24px;
}
.panel-header {
margin-bottom: 20px;
}
.panel-header h2 {
font-size: 1.1rem;
font-weight: 600;
}
/* Activity Feed */
.activity-feed {
list-style: none;
padding: 0;
margin: 0;
max-height: 400px;
overflow-y: auto;
}
.activity-item {
padding: 12px 0;
border-bottom: 1px solid var(--accents-2);
font-size: 0.875rem;
}
.activity-item:last-child {
border-bottom: none;
}
.activity-item .time {
color: var(--accents-4);
font-family: var(--font-mono);
font-size: 0.75rem;
display: block;
margin-bottom: 4px;
}
/* Badges */
.badge {
padding: 2px 8px;
border-radius: 20px;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
}
.badge.live {
background: rgba(0, 112, 243, 0.1);
color: var(--success);
border: 1px solid rgba(0, 112, 243, 0.2);
}
/* System Metrics */
.metrics-grid {
display: flex;
flex-direction: column;
gap: 16px;
}
.metric-card {
padding: 12px;
border: 1px solid var(--accents-2);
border-radius: var(--radius);
}
.metric-header {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
margin-bottom: 8px;
}
.metric-label {
color: var(--accents-5);
}
.metric-value {
font-weight: 500;
font-family: var(--font-mono);
}
.progress-bar-bg {
height: 4px;
background: var(--accents-2);
border-radius: 2px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: var(--foreground);
transition: width 0.3s ease;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 16px;
height: 32px;
border-radius: var(--radius);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid var(--accents-2);
background: var(--background);
color: var(--foreground);
}
.btn:hover:not(:disabled) {
border-color: var(--foreground);
}
.btn-primary {
background: var(--foreground);
color: var(--background);
border: 1px solid var(--foreground);
}
.btn-primary:hover:not(:disabled) {
background: var(--background);
color: var(--foreground);
}
.btn-danger {
color: var(--error);
border-color: var(--error);
}
.btn-danger:hover:not(:disabled) {
background: var(--error);
color: white;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Control Panel */
.control-panel {
grid-column: 1 / -1;
margin-top: 24px;
}
.action-buttons {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
/* Footer */
footer {
padding: 48px 24px;
border-top: 1px solid var(--accents-2);
color: var(--accents-5);
font-size: 0.8rem;
}
.footer-content {
max-width: 1000px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
background: var(--accents-4);
margin-right: 8px;
}
.status-indicator.online {
background: var(--success);
box-shadow: 0 0 8px var(--success);
}
/* Responsive */
@media (max-width: 768px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
.dashboard-main {
grid-template-columns: 1fr;
}
}

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

@@ -0,0 +1,62 @@
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");
const text = await res.text();
expect(text).toContain("Aurora Web");
expect(text).toContain("Uptime:");
expect(text).toContain('id="uptime-display"');
});
it("should return dashboard page on /dashboard", async () => {
const req = new Request("http://localhost/dashboard");
const res = await router(req);
expect(res.status).toBe(200);
expect(await res.text()).toContain("Live Activity");
});
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);
});
});

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

@@ -0,0 +1,56 @@
import { homeRoute } from "./routes/home";
import { healthRoute } from "./routes/health";
import { dashboardRoute } from "./routes/dashboard";
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
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 {
return new Response("Forbidden", { status: 403 });
}
}
if (url.pathname === "/" || url.pathname === "/index.html") {
return homeRoute();
}
if (url.pathname === "/health") {
return healthRoute();
}
if (url.pathname === "/dashboard") {
return dashboardRoute();
}
}
if (method === "POST") {
if (url.pathname === "/api/actions") {
const { actionsRoute } = await import("./routes/actions");
return actionsRoute(request);
}
}
return new Response("Not Found", { status: 404 });
}

56
src/web/routes/actions.ts Normal file
View File

@@ -0,0 +1,56 @@
import { AuroraClient } from "@/lib/BotClient";
import { logger } from "@/lib/logger";
export async function actionsRoute(request: Request): Promise<Response> {
const url = new URL(request.url);
const body = await request.json().catch(() => ({})) as any;
const action = body.action;
if (!action) {
return new Response(JSON.stringify({ success: false, error: "No action provided" }), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
try {
switch (action) {
case "reload_commands":
logger.info("Web Dashboard: Triggering command reload...");
await AuroraClient.loadCommands(true);
await AuroraClient.deployCommands();
return new Response(JSON.stringify({ success: true, message: "Commands reloaded successfully" }), {
headers: { "Content-Type": "application/json" }
});
case "clear_cache":
logger.info("Web Dashboard: Triggering cache clear...");
// For now, we'll reload events and commands as a "clear cache" action
await AuroraClient.loadEvents(true);
await AuroraClient.loadCommands(true);
return new Response(JSON.stringify({ success: true, message: "Cache cleared and systems reloaded" }), {
headers: { "Content-Type": "application/json" }
});
case "restart_bot":
logger.info("Web Dashboard: Triggering bot restart...");
// We don't await this because it will exit the process
setTimeout(() => AuroraClient.shutdown(), 1000);
return new Response(JSON.stringify({ success: true, message: "Bot shutdown initiated. If managed by a process manager, it will restart." }), {
headers: { "Content-Type": "application/json" }
});
default:
return new Response(JSON.stringify({ success: false, error: `Unknown action: ${action}` }), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
} catch (error: any) {
logger.error(`Error executing action ${action}:`, error);
return new Response(JSON.stringify({ success: false, error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" }
});
}
}

154
src/web/routes/dashboard.ts Normal file
View File

@@ -0,0 +1,154 @@
import { BaseLayout } from "../views/layout";
import { AuroraClient } from "@/lib/BotClient";
import { getRecentLogs } from "@/lib/logger";
export function dashboardRoute(): Response {
// Gather real data
const guildCount = AuroraClient.guilds.cache.size;
const userCount = AuroraClient.guilds.cache.reduce((acc, guild) => acc + guild.memberCount, 0);
const commandCount = AuroraClient.commands.size;
const ping = AuroraClient.ws.ping;
// Real system metrics
const memUsage = process.memoryUsage();
const memoryUsage = (memUsage.heapUsed / 1024 / 1024).toFixed(2);
const memoryTotal = (memUsage.rss / 1024 / 1024).toFixed(1);
const uptimeSeconds = process.uptime();
const uptime = new Date(uptimeSeconds * 1000).toISOString().substr(11, 8); // HH:MM:SS
const startTimestamp = Date.now() - (uptimeSeconds * 1000);
// Real activity logs
const activityLogs = getRecentLogs();
const memPercent = Math.min(100, (memUsage.heapUsed / memUsage.rss) * 100).toFixed(1);
// Get top guilds
const topGuilds = AuroraClient.guilds.cache
.sort((a, b) => b.memberCount - a.memberCount)
.first(5);
const content = `
<div class="dashboard-grid">
<!-- Top Stats Row -->
<div class="stat-card">
<div class="stat-header">
<h3>Members</h3>
<i data-lucide="users" style="width: 14px; height: 14px; color: var(--accents-5)"></i>
</div>
<div id="stat-users" class="stat-value">${userCount.toLocaleString()}</div>
<div class="stat-trend">
<span>Total user reach</span>
</div>
</div>
<div class="stat-card">
<div class="stat-header">
<h3>Guilds</h3>
<i data-lucide="server" style="width: 14px; height: 14px; color: var(--accents-5)"></i>
</div>
<div id="stat-servers" class="stat-value">${guildCount}</div>
<div class="stat-trend">
<span>Active connections</span>
</div>
</div>
<div class="stat-card">
<div class="stat-header">
<h3>Latency</h3>
<i data-lucide="activity" style="width: 14px; height: 14px; color: var(--accents-5)"></i>
</div>
<div id="stat-ping" class="stat-value">${ping < 0 ? "?" : ping}ms</div>
<div id="stat-ping-trend" class="stat-trend ${ping < 100 ? "up" : "down"}">
<i data-lucide="${ping < 100 ? "check-circle" : "alert-circle"}" style="width: 12px; height: 12px"></i>
<span>${ping < 100 ? "Stable" : "High"}</span>
</div>
</div>
<!-- Main Content Area -->
<div class="dashboard-main">
<div class="panel activity-panel">
<div class="panel-header" style="display: flex; justify-content: space-between; align-items: center;">
<h2>Activity Flow</h2>
<span class="badge live">Live</span>
</div>
<ul class="activity-feed">
${activityLogs.length > 0 ? activityLogs.map(log => `
<li class="activity-item">
<span class="time">${log.time}</span>
<span class="message">${log.message}</span>
</li>
`).join('') : `
<li class="activity-item">
<span class="message" style="color: var(--accents-5)">Listening for activity...</span>
</li>
`}
</ul>
</div>
<div class="panel" style="display: flex; flex-direction: column; gap: 24px;">
<div>
<div class="panel-header">
<h2>Health</h2>
</div>
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-header">
<span class="metric-label">Memory</span>
<span id="stat-memory" class="metric-value">${memoryUsage} MB</span>
</div>
<div class="progress-bar-bg">
<div id="stat-memory-bar" class="progress-bar-fill" style="width: ${memPercent}%"></div>
</div>
</div>
<div class="metric-card">
<div class="metric-header">
<span class="metric-label">Uptime</span>
<span id="stat-uptime" class="metric-value uptime-display" data-start-timestamp="${Math.floor(startTimestamp)}">${uptime}</span>
</div>
</div>
</div>
</div>
<div>
<div class="panel-header">
<h2>Top Guilds</h2>
</div>
<div class="guild-list" style="display: flex; flex-direction: column; gap: 12px;">
${topGuilds.map(g => `
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 0.875rem;">
<span style="font-weight: 500;">${g.name}</span>
<span style="color: var(--accents-5); font-family: var(--font-mono);">${g.memberCount} members</span>
</div>
`).join('')}
</div>
</div>
</div>
</div>
<!-- Control Panel -->
<div class="panel control-panel">
<div class="panel-header">
<h2>Quick Actions</h2>
</div>
<div class="action-buttons">
<button class="btn" data-action="clear_cache">
Clear Cache
</button>
<button class="btn" data-action="reload_commands">
Reload Commands
</button>
<button class="btn btn-danger" data-action="restart_bot">
Restart Bot
</button>
</div>
</div>
</div>
`;
const html = BaseLayout({ title: "Dashboard", content });
return new Response(html, {
headers: { "Content-Type": "text/html" },
});
}

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

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

@@ -0,0 +1,16 @@
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>
`;
const html = BaseLayout({ title: "Home", content });
return new Response(html, {
headers: { "Content-Type": "text/html" },
});
}

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

@@ -0,0 +1,93 @@
import { env } from "@/lib/env";
import { router } from "./router";
import type { Server } from "bun";
import { AuroraClient } from "@/lib/BotClient";
export class WebServer {
private static server: Server<unknown> | null = null;
private static heartbeatInterval: ReturnType<typeof setInterval> | null = null;
public static start(port?: number) {
this.server = Bun.serve({
port: port ?? env.PORT,
hostname: env.HOST,
fetch: (req, server) => {
const url = new URL(req.url);
if (url.pathname === "/ws") {
// Upgrade the request to a WebSocket
// We pass dummy data for now
if (server.upgrade(req, { data: undefined })) {
return undefined;
}
return new Response("WebSocket upgrade failed", { status: 500 });
}
return router(req);
},
websocket: {
open(ws) {
// console.log("ws: client connected");
ws.subscribe("status-updates");
ws.send(JSON.stringify({ type: "WELCOME", message: "Connected to Aurora WebSocket" }));
},
message(ws, message) {
// Handle incoming messages if needed
},
close(ws) {
// console.log("ws: client disconnected");
ws.unsubscribe("status-updates");
},
},
});
console.log(`🌐 Web server listening on http://${this.server.hostname}:${this.server.port} (Restricted to Local Interface)`);
// Start a heartbeat loop
this.heartbeatInterval = setInterval(() => {
if (this.server) {
const memoryUsage = process.memoryUsage();
this.server.publish("status-updates", JSON.stringify({
type: "HEARTBEAT",
data: {
guildCount: AuroraClient.guilds.cache.size,
userCount: AuroraClient.guilds.cache.reduce((acc, guild) => acc + guild.memberCount, 0),
commandCount: AuroraClient.commands.size,
ping: AuroraClient.ws.ping,
memory: (memoryUsage.heapUsed / 1024 / 1024).toFixed(2),
memoryTotal: (memoryUsage.rss / 1024 / 1024).toFixed(2),
uptime: process.uptime(),
timestamp: Date.now()
}
}));
}
}, 3000); // Update every 3 seconds for better responsiveness
}
public static stop() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
if (this.server) {
this.server.stop();
console.log("🛑 Web server stopped");
this.server = null;
}
}
public static get port(): number | undefined {
return this.server?.port;
}
public static broadcastLog(type: string, message: string) {
if (this.server) {
this.server.publish("status-updates", JSON.stringify({
type: "LOG",
data: {
timestamp: new Date().toLocaleTimeString(),
type,
message
}
}));
}
}
}

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "bun:test";
import { formatUptime } from "./format";
describe("formatUptime", () => {
it("formats seconds correctly", () => {
expect(formatUptime(45)).toBe("45s");
});
it("formats minutes and seconds", () => {
expect(formatUptime(65)).toBe("1m 5s");
});
it("formats hours, minutes, and seconds", () => {
expect(formatUptime(3665)).toBe("1h 1m 5s");
});
it("formats days correctly", () => {
expect(formatUptime(90061)).toBe("1d 1h 1m 1s");
});
it("handles zero", () => {
expect(formatUptime(0)).toBe("0s");
});
});

20
src/web/utils/format.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Formats a duration in seconds into a human-readable string.
* Example: 3665 -> "1h 1m 5s"
*/
export function formatUptime(seconds: number): string {
if (seconds < 0) return "0s";
const days = Math.floor(seconds / (3600 * 24));
const hours = Math.floor((seconds % (3600 * 24)) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
const parts = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
parts.push(`${secs}s`);
return parts.join(" ");
}

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

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

@@ -0,0 +1,55 @@
import { escapeHtml } from "../utils/html";
import { formatUptime } from "../utils/format";
interface LayoutProps {
title: string;
content: string;
}
export function BaseLayout({ title, content }: LayoutProps): string {
const safeTitle = escapeHtml(title);
// Calculate uptime for the footer
const uptimeSeconds = process.uptime();
const startTimestamp = Date.now() - (uptimeSeconds * 1000);
const initialUptimeString = formatUptime(uptimeSeconds);
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">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://unpkg.com/lucide@latest"></script>
</head>
<body>
<header>
<h1>Aurora</h1>
<nav>
<a href="/">Home</a>
<a href="/dashboard" class="active">Dashboard</a>
</nav>
</header>
<main>
${content}
</main>
<footer>
<div class="footer-content">
<p>&copy; ${new Date().getFullYear()} Aurora</p>
<div class="footer-status">
<span class="status-indicator online"></span>
<span>Operational</span>
<span style="margin: 0 8px; color: var(--accents-2)">/</span>
<span id="uptime-display" data-start-timestamp="${Math.floor(startTimestamp)}">${initialUptimeString}</span>
</div>
</div>
</footer>
<script src="/script.js" defer></script>
</body>
</html>`;
}

46
src/web/websocket.test.ts Normal file
View File

@@ -0,0 +1,46 @@
import { describe, expect, it, afterAll, beforeAll } from "bun:test";
import { WebServer } from "./server";
describe("WebSocket Server", () => {
// Start server on a random port
const port = 0;
beforeAll(() => {
WebServer.start(port);
});
afterAll(() => {
WebServer.stop();
});
it("should accept websocket connection and send welcome message", async () => {
const port = WebServer.port;
expect(port).toBeDefined();
const ws = new WebSocket(`ws://localhost:${port}/ws`);
const messagePromise = new Promise<any>((resolve) => {
ws.onmessage = (event) => {
resolve(JSON.parse(event.data as string));
};
});
const msg = await messagePromise;
expect(msg.type).toBe("WELCOME");
expect(msg.message).toContain("Connected");
ws.close();
});
it("should reject non-ws upgrade requests on /ws endpoint via http", async () => {
const port = WebServer.port;
// Just a normal fetch to /ws should fail with 426 Upgrade Required usually,
// but our implementation returns "WebSocket upgrade failed" 500 or undefined -> 101 Switching Protocols if valid.
// If we send a normal GET request to /ws without Upgrade headers, server.upgrade(req) returns false.
// So it returns status 500 "WebSocket upgrade failed" based on our code.
const res = await fetch(`http://localhost:${port}/ws`);
expect(res.status).toBe(500);
expect(await res.text()).toBe("WebSocket upgrade failed");
});
});

View File

@@ -0,0 +1,52 @@
# 2026-01-07-replace-mock-dashboard-data.md: Replace Mock Dashboard Data with Live Telemetry
**Status:** Done
**Created:** 2026-01-07
**Tags:** dashboard, telemetry, logging, database
## 1. Context & User Story
* **As a:** Bot Administrator
* **I want to:** see actual system logs, real-time resource usage, and accurate database statistics on the web dashboard
* **So that:** I can monitor the true health and activity of the Aurora application without checking the terminal or database manually.
## 2. Technical Requirements
### Data Model Changes
- [ ] No strict database schema changes required, but may need a cohesive `LogService` or in-memory buffer to store recent "Activity" events for the dashboard history.
### API / Interface
- **Dashboard Route (`src/web/routes/dashboard.ts`):**
- [x] Replace `mockedActivity` array with a fetch from a real log buffer/source.
- [x] Replace `userCount` approximation with a precise count from `UserService` or `AuroraClient`.
- [x] Replace "System Metrics" mock bars with real values (RAM usage, Uptime, CPU load if possible).
- **Log Source:**
- [x] Implement a mechanism (e.g., specific `Logger` transport or `WebServer` static buffer) to capture the last ~50 distinct application events (commands, errors, warnings) for display.
- [ ] (Optional) If "Docker Compose Logs" are strictly required, implement a file reader for the standard output log file if accessible, otherwise rely on internal application logging.
### Real Data Integration
- **Activity Feed:** Must show actual commands executed, system errors, and startup events.
- **Top Stats:** Ensure `Servers`, `Users`, `Commands`, and `Ping` come from the live `AuroraClient` instance.
- **Metrics:** Display `process.memoryUsage().heapUsed` converted to MB. Display `process.uptime()`.
## 3. Constraints & Validations (CRITICAL)
- **Performance:** Fetching logs or stats must not block the event loop. Avoid heavy DB queries on every dashboard refresh; cache stats if necessary (e.g., via `setInterval` in background).
- **Security:** Do not expose sensitive data (tokens, raw SQL) in the activity feed.
- **Fallbacks:** If data is unavailable (e.g., client not ready), show "Loading..." or a neutral placeholder, not fake data.
## 4. Acceptance Criteria
1. [x] The "Activity Feed" on the dashboard displays real, recent events that occurred in the application (e.g., "Bot started", "Command /ping executed").
2. [x] The "System Metrics" section displays a visual representation (or text) of **actual** memory usage and uptime.
3. [x] The hardcoded `mockedActivity` array is removed from `dashboard.ts`.
4. [x] Refreshing the dashboard page updates the metrics and feed with the latest data.
## 5. Implementation Plan
- [x] Step 1: Create a simple in-memory `LogBuffer` in `src/lib/logger.ts` (or similar) to keep the last 50 logs.
- [x] Step 2: Hook this buffer into the existing logging system (or add manual pushes in `command.handler.ts` etc).
- [x] Step 3: Implement `getSystemMetrics()` helper to return formatted RAM/CPU data.
- [x] Step 4: Update `src/web/routes/dashboard.ts` to import the log buffer and metrics helper.
- [x] Step 5: Replace the HTML template variables with these real data sources.
## Implementation Notes
- **Log Buffer**: Added a 50-item rolling buffer in `src/lib/logger.ts` exposing `getRecentLogs()`.
- **Dashboard Update**: `src/web/routes/dashboard.ts` now uses `AuroraClient` stats and `process` metrics (Uptime, Memory) directly.
- **Tests**: Added `src/lib/logger.test.ts` to verify buffer logic.