forked from syntaxbullet/AuroraBot-discord
Compare commits
44 Commits
a227e5db59
...
66af870aa9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66af870aa9 | ||
|
|
8047bce755 | ||
|
|
9804456257 | ||
|
|
259b8d6875 | ||
|
|
a2cb684b71 | ||
|
|
9c2098bc46 | ||
|
|
618d973863 | ||
|
|
63f55b6dfd | ||
|
|
ac4025e179 | ||
|
|
ff23f22337 | ||
|
|
292991c605 | ||
|
|
4640cd11a7 | ||
|
|
43a003f641 | ||
|
|
6f4426e49d | ||
|
|
894cad91a8 | ||
|
|
2a1c4e65ae | ||
|
|
022f748517 | ||
|
|
ca392749e3 | ||
|
|
4a1e72c5f3 | ||
|
|
d29a1ec2b7 | ||
|
|
1dd269bf2f | ||
|
|
69186ff3e9 | ||
|
|
b989e807dc | ||
|
|
2e6bdec38c | ||
|
|
a9d5c806ad | ||
|
|
6f73178375 | ||
|
|
dd62336571 | ||
|
|
8280111b66 | ||
|
|
34347f0c63 | ||
|
|
c807fd4fd0 | ||
|
|
47b980eff1 | ||
|
|
bc89ddf7c0 | ||
|
|
606d83a7ae | ||
|
|
3351295bdc | ||
|
|
92cb048a7a | ||
|
|
6ead0c0393 | ||
|
|
278ef4b6b0 | ||
|
|
9a32ab298d | ||
|
|
a2596d4124 | ||
|
|
fbc8952e0a | ||
|
|
d0b4cb80de | ||
|
|
599684cde8 | ||
|
|
5606fb6e2f | ||
|
|
fb260c5beb |
57
.agent/workflows/create-ticket.md
Normal file
57
.agent/workflows/create-ticket.md
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
description: Create a new Ticket
|
||||
---
|
||||
|
||||
### Role
|
||||
You are a Senior Technical Product Manager and Lead Engineer. Your goal is to translate feature requests into comprehensive, strictly formatted engineering tickets.
|
||||
|
||||
### Task
|
||||
When I ask you to "scope a feature" or "create a ticket" for a specific functionality:
|
||||
1. Analyze the request for technical implications, edge cases, and architectural fit.
|
||||
2. Generate a new Markdown file.
|
||||
3. Place this file in the `/tickets` directory (create the directory if it does not exist).
|
||||
|
||||
### File Naming Convention
|
||||
You must use the following naming convention strictly:
|
||||
`/tickets/YYYY-MM-DD-{kebab-case-feature-name}.md`
|
||||
|
||||
*Example:* `/tickets/2024-10-12-user-authentication-flow.md`
|
||||
|
||||
### File Content Structure
|
||||
The markdown file must adhere to the following template exactly. Do not skip sections. If a section is not applicable, write "N/A" but explain why.
|
||||
|
||||
```markdown
|
||||
# [Ticket ID]: [Feature Title]
|
||||
|
||||
**Status:** Draft
|
||||
**Created:** [YYYY-MM-DD]
|
||||
**Tags:** [comma, separated, tags]
|
||||
|
||||
## 1. Context & User Story
|
||||
* **As a:** [Role]
|
||||
* **I want to:** [Action]
|
||||
* **So that:** [Benefit/Value]
|
||||
|
||||
## 2. Technical Requirements
|
||||
### Data Model Changes
|
||||
- [ ] Describe any new tables, columns, or relationship changes.
|
||||
- [ ] SQL migration required? (Yes/No)
|
||||
|
||||
### API / Interface
|
||||
- [ ] Define endpoints (method, path) or function signatures.
|
||||
- [ ] Payload definition (JSON structure or Types).
|
||||
|
||||
## 3. Constraints & Validations (CRITICAL)
|
||||
*This section must be exhaustive. Do not be vague.*
|
||||
- **Input Validation:** (e.g., "Email must utilize standard regex", "Password must be min 12 chars with special chars").
|
||||
- **System Constraints:** (e.g., "Image upload max size 5MB", "Request timeout 30s").
|
||||
- **Business Logic Guardrails:** (e.g., "User cannot upgrade if balance < $0").
|
||||
|
||||
## 4. Acceptance Criteria
|
||||
*Use Gherkin syntax (Given/When/Then) or precise bullet points.*
|
||||
1. [ ] Criteria 1
|
||||
2. [ ] Criteria 2
|
||||
|
||||
## 5. Implementation Plan
|
||||
- [ ] Step 1: ...
|
||||
- [ ] Step 2: ...
|
||||
53
.agent/workflows/review.md
Normal file
53
.agent/workflows/review.md
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
description: Review the most recent changes critically.
|
||||
---
|
||||
|
||||
### Role
|
||||
You are a Lead Security Engineer and Senior QA Automator. Your persona is **"The Hostile Reviewer."**
|
||||
* **Mindset:** You do not trust the code. You assume it contains bugs, security flaws, and logic gaps.
|
||||
* **Goal:** Your objective is to reject the most recent git changes by finding legitimate issues. If you cannot find issues, only then do you approve.
|
||||
|
||||
### Phase 1: The Security & Logic Audit
|
||||
Analyze the code changes for specific vulnerabilities. Do not summarize what the code does; look for what it *does wrong*.
|
||||
|
||||
1. **TypeScript Strictness:**
|
||||
* Flag any usage of `any`.
|
||||
* Flag any use of non-null assertions (`!`) unless strictly guarded.
|
||||
* Flag forced type casting (`as UnknownType`) without validation.
|
||||
2. **Bun/Runtime Specifics:**
|
||||
* Check for unhandled Promises (floating promises).
|
||||
* Ensure environment variables are not hardcoded.
|
||||
3. **Security Vectors:**
|
||||
* **Injection:** Check SQL/NoSQL queries for concatenation.
|
||||
* **Sanitization:** Are inputs from the generic request body validated against the schema defined in the Ticket?
|
||||
* **Auth:** Are sensitive routes actually protected by middleware?
|
||||
|
||||
### Phase 2: Test Quality Verification
|
||||
Do not just check if tests pass. Check if the tests are **valid**.
|
||||
1. **The "Happy Path" Trap:** If the tests only check for success (status 200), **FAIL** the review.
|
||||
2. **Edge Case Coverage:**
|
||||
* Did the code handle the *Constraints & Validations* listed in the original ticket?
|
||||
* *Example:* If the ticket says "Max 5MB upload", is there a test case for a 5.1MB file?
|
||||
3. **Mocking Integrity:** Are mocks too permissive? (e.g., Mocking a function to always return `true` regardless of input).
|
||||
|
||||
### Phase 3: The Verdict
|
||||
Output your review in the following strict format:
|
||||
|
||||
---
|
||||
# 🛡️ Code Review Report
|
||||
|
||||
**Ticket ID:** [Ticket Name]
|
||||
**Verdict:** [🔴 REJECT / 🟢 APPROVE]
|
||||
|
||||
## 🚨 Critical Issues (Must Fix)
|
||||
*List logic bugs, security risks, or failing tests.*
|
||||
1. ...
|
||||
2. ...
|
||||
|
||||
## ⚠️ Suggestions (Refactoring)
|
||||
*List code style improvements, variable naming, or DRY opportunities.*
|
||||
1. ...
|
||||
|
||||
## 🧪 Test Coverage Gap Analysis
|
||||
*List specific scenarios that are NOT currently tested but should be.*
|
||||
- [ ] Scenario: ...
|
||||
50
.agent/workflows/work-on-ticket.md
Normal file
50
.agent/workflows/work-on-ticket.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
description: Pick a Ticket and work on it.
|
||||
---
|
||||
|
||||
### Role
|
||||
You are an Autonomous Senior Software Engineer specializing in TypeScript and Bun. You are responsible for the full lifecycle of feature implementation: selection, coding, testing, verification, and closure.
|
||||
|
||||
|
||||
### Phase 1: Triage & Selection
|
||||
1. **Scan:** Read all files in the `/tickets` directory.
|
||||
2. **Filter:** Ignore tickets marked `Status: Done` or `Status: Archived`.
|
||||
3. **Prioritize:** Select a single ticket based on the following hierarchy:
|
||||
* **Tags:** `Critical` > `High Priority` > `Bug` > `Feature`.
|
||||
* **Age:** Oldest created date first (FIFO).
|
||||
4. **Announce:** Explicitly state: "I am picking ticket: [Ticket ID/Name] because [Reason]."
|
||||
|
||||
### Phase 2: Setup (Non-Destructive)
|
||||
1. **Branching:** Create a new git branch based on the ticket name.
|
||||
* *Format:* `feat/{ticket-kebab-name}` or `fix/{ticket-kebab-name}`.
|
||||
* *Command:* `git checkout -b feat/user-auth-flow`.
|
||||
2. **Context:** Read the selected ticket markdown file thoroughly, paying special attention to "Constraints & Validations."
|
||||
|
||||
### Phase 3: Implementation & Testing (The Loop)
|
||||
*Iterate until the requirements are met.*
|
||||
|
||||
1. **Write Code:** Implement the feature or fix using TypeScript.
|
||||
2. **Tightened Testing:**
|
||||
* You must create or update test files (`*.test.ts` or `*.spec.ts`).
|
||||
* **Requirement:** Tests must cover happy paths AND the edge cases defined in the ticket's "Constraints" section.
|
||||
* *Mocking:* Mock external dependencies where appropriate to ensure isolation.
|
||||
3. **Type Safety Check:**
|
||||
* Run: `bun x tsc --noEmit`
|
||||
* **CRITICAL:** If there are ANY TypeScript errors, you must fix them immediately. Do not proceed.
|
||||
4. **Runtime Verification:**
|
||||
* Run: `bun test`
|
||||
* Ensure all tests pass. If a test fails, analyze the stack trace, fix the implementation, and rerun.
|
||||
|
||||
### Phase 4: Self-Review & Clean Up
|
||||
Before declaring the task finished, perform a self-review:
|
||||
1. **Linting:** Check for unused variables, any types, or console logs.
|
||||
2. **Refactor:** Ensure code is DRY (Don't Repeat Yourself) and strictly typed.
|
||||
3. **Ticket Update:**
|
||||
* Modify the Markdown ticket file.
|
||||
* Change `Status: Draft` to `Status: In Review` or `Status: Done`.
|
||||
* Add a new section at the bottom: `## Implementation Notes` listing the specific files changed.
|
||||
|
||||
### Phase 5: Handover
|
||||
Only when `bun x tsc` and `bun test` pass with 0 errors:
|
||||
1. Commit the changes with a semantic message (e.g., `feat: implement user auth logic`).
|
||||
2. Present a summary of the work done and ask for a human code review.
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,3 +44,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
src/db/data
|
||||
src/db/log
|
||||
scratchpad/
|
||||
|
||||
|
||||
@@ -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
63
docs/COMMANDS.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Command Reference
|
||||
|
||||
This document lists all available slash commands in Aurora, categorized by their function.
|
||||
|
||||
## Economy
|
||||
|
||||
| Command | Description | Options | Permissions |
|
||||
|---|---|---|---|
|
||||
| `/balance` | View your or another user's balance. | `user` (Optional): The user to check. | Everyone |
|
||||
| `/daily` | Claim your daily currency reward and streak bonus. | None | Everyone |
|
||||
| `/pay` | Transfer currency to another user. | `user` (Required): Recipient.<br>`amount` (Required): Amount to send. | Everyone |
|
||||
| `/trade` | Start a trade session with another user. | `user` (Required): The user to trade with. | Everyone |
|
||||
| `/exam` | Take your weekly exam to earn rewards based on XP gain. | None | Everyone |
|
||||
|
||||
## Inventory & Items
|
||||
|
||||
| Command | Description | Options | Permissions |
|
||||
|---|---|---|---|
|
||||
| `/inventory` | View your or another user's inventory. | `user` (Optional): The user to check. | Everyone |
|
||||
| `/use` | Use an item from your inventory. | `item` (Required): The item to use (Autocomplete). | Everyone |
|
||||
|
||||
## User & Social
|
||||
|
||||
| Command | Description | Options | Permissions |
|
||||
|---|---|---|---|
|
||||
| `/profile` | View your or another user's Student ID card. | `user` (Optional): The user to view. | Everyone |
|
||||
| `/leaderboard` | View top players. | `type` (Required): 'Level / XP' or 'Balance'. | Everyone |
|
||||
| `/feedback` | Submit feedback, bug reports, or suggestions. | None | Everyone |
|
||||
| `/quests` | View your active quests. | None | Everyone |
|
||||
|
||||
## Admin
|
||||
|
||||
> [!IMPORTANT]
|
||||
> These commands require Administrator permissions or specific roles as configured.
|
||||
|
||||
### General Management
|
||||
| Command | Description | Options |
|
||||
|---|---|---|
|
||||
| `/config` | Manage bot configuration. | `group` (Req): Section.<br>`key` (Req): Setting.<br>`value` (Req): New value. |
|
||||
| `/refresh` | Refresh commands or configuration cache. | `type`: 'Commands' or 'Config'. |
|
||||
| `/update` | Update the bot from the repository. | None |
|
||||
| `/features` | Enable/Disable system features. | `feature` (Req): Feature name.<br>`enabled` (Req): True/False. |
|
||||
| `/webhook` | Send a message via webhook. | `payload` (Req): JSON payload. |
|
||||
|
||||
### Moderation
|
||||
| Command | Description | Options |
|
||||
|---|---|---|
|
||||
| `/warn` | Warn a user. | `user` (Req): Target.<br>`reason` (Req): Reason. |
|
||||
| `/warnings` | View active warnings for a user. | `user` (Req): Target. |
|
||||
| `/clearwarning`| Clear a specific warning. | `case_id` (Req): Case ID. |
|
||||
| `/case` | View details of a specific moderation case. | `case_id` (Req): Case ID. |
|
||||
| `/cases` | View moderation history for a user. | `user` (Req): Target. |
|
||||
| `/note` | Add a note to a user. | `user` (Req): Target.<br>`note` (Req): Content. |
|
||||
| `/notes` | View notes for a user. | `user` (Req): Target. |
|
||||
| `/prune` | Bulk delete messages. | `amount` (Req): Number (1-100). |
|
||||
|
||||
### Game Admin
|
||||
| Command | Description | Options |
|
||||
|---|---|---|
|
||||
| `/create_item` | Create a new item in the database. | (Modal based interaction) |
|
||||
| `/create_color`| Create a new color role. | `name` (Req): Role name.<br>`hex` (Req): Hex color code. |
|
||||
| `/listing` | Manage shop listings (Admin view). | None (Context sensitive?) |
|
||||
| `/terminal` | Control the terminal display channel. | `action`: 'setup', 'update', 'clear'. |
|
||||
160
docs/CONFIGURATION.md
Normal file
160
docs/CONFIGURATION.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Configuration Guide
|
||||
|
||||
This document outlines the structure and available options for the `config/config.json` file. The configuration is validated using Zod schemas at runtime (see `src/lib/config.ts`).
|
||||
|
||||
## Core Structure
|
||||
|
||||
### Leveling
|
||||
Configuration for the XP and leveling system.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `base` | `number` | The base XP required for the first level. |
|
||||
| `exponent` | `number` | The exponent used to calculate XP curves. |
|
||||
| `chat.cooldownMs` | `number` | Time in milliseconds between XP gains from chat. |
|
||||
| `chat.minXp` | `number` | Minimum XP awarded per message. |
|
||||
| `chat.maxXp` | `number` | Maximum XP awarded per message. |
|
||||
|
||||
### Economy
|
||||
Settings for currency, rewards, and transfers.
|
||||
|
||||
#### Daily
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `amount` | `integer` | Base amount granted by `/daily`. |
|
||||
| `streakBonus` | `integer` | Bonus amount per streak day. |
|
||||
| `weeklyBonus` | `integer` | Bonus amount for a 7-day streak. |
|
||||
| `cooldownMs` | `number` | Cooldown period for the command (usually 24h). |
|
||||
|
||||
#### Transfers
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `allowSelfTransfer` | `boolean` | Whether users can transfer money to themselves. |
|
||||
| `minAmount` | `integer` | Minimum amount required for a transfer. |
|
||||
|
||||
#### Exam
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `multMin` | `number` | Minimum multiplier for exam rewards. |
|
||||
| `multMax` | `number` | Maximum multiplier for exam rewards. |
|
||||
|
||||
### Inventory
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `maxStackSize` | `integer` | Maximum count of a single item in one slot. |
|
||||
| `maxSlots` | `number` | Total number of inventory slots available. |
|
||||
|
||||
### Lootdrop
|
||||
Settings for the random chat loot drop events.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `activityWindowMs` | `number` | Time window to track activity for spawning drops. |
|
||||
| `minMessages` | `number` | Minimum messages required in window to trigger drop. |
|
||||
| `spawnChance` | `number` | Probability (0-1) of a drop spawning when conditions met. |
|
||||
| `cooldownMs` | `number` | Minimum time between loot drops. |
|
||||
| `reward.min` | `number` | Minimum currency reward. |
|
||||
| `reward.max` | `number` | Maximum currency reward. |
|
||||
| `reward.currency` | `string` | The currency ID/Symbol used for rewards. |
|
||||
|
||||
### Roles
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `studentRole` | `string` | Discord Role ID for students. |
|
||||
| `visitorRole` | `string` | Discord Role ID for visitors. |
|
||||
| `colorRoles` | `string[]` | List of Discord Role IDs available as color roles. |
|
||||
|
||||
### Moderation
|
||||
Automated moderation settings.
|
||||
|
||||
#### Prune
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `maxAmount` | `number` | Maximum messages to delete in one go. |
|
||||
| `confirmThreshold` | `number` | Amount above which confirmation is required. |
|
||||
| `batchSize` | `number` | Size of delete batches. |
|
||||
| `batchDelayMs` | `number` | Delay between batches. |
|
||||
|
||||
#### Cases
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `dmOnWarn` | `boolean` | Whether to DM users when they are warned. |
|
||||
| `logChannelId` | `string` | (Optional) Channel ID for moderation logs. |
|
||||
| `autoTimeoutThreshold` | `number` | (Optional) Warn count to trigger auto-timeout. |
|
||||
|
||||
### System & Misc
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `commands` | `Object` | Map of command names (keys) to boolean (values) to enable/disable them. |
|
||||
| `welcomeChannelId` | `string` | (Optional) Channel ID for welcome messages. |
|
||||
| `welcomeMessage` | `string` | (Optional) Custom welcome message text. |
|
||||
| `feedbackChannelId` | `string` | (Optional) Channel ID where feedback is posted. |
|
||||
| `terminal.channelId` | `string` | (Optional) Channel ID for terminal display. |
|
||||
| `terminal.messageId` | `string` | (Optional) Message ID for terminal display. |
|
||||
|
||||
## Example Config
|
||||
|
||||
```json
|
||||
{
|
||||
"leveling": {
|
||||
"base": 100,
|
||||
"exponent": 1.5,
|
||||
"chat": {
|
||||
"cooldownMs": 60000,
|
||||
"minXp": 15,
|
||||
"maxXp": 25
|
||||
}
|
||||
},
|
||||
"economy": {
|
||||
"daily": {
|
||||
"amount": "100",
|
||||
"streakBonus": "10",
|
||||
"weeklyBonus": "500",
|
||||
"cooldownMs": 86400000
|
||||
},
|
||||
"transfers": {
|
||||
"allowSelfTransfer": false,
|
||||
"minAmount": "10"
|
||||
},
|
||||
"exam": {
|
||||
"multMin": 1.0,
|
||||
"multMax": 2.0
|
||||
}
|
||||
},
|
||||
"inventory": {
|
||||
"maxStackSize": "99",
|
||||
"maxSlots": 20
|
||||
},
|
||||
"lootdrop": {
|
||||
"activityWindowMs": 300000,
|
||||
"minMessages": 10,
|
||||
"spawnChance": 0.05,
|
||||
"cooldownMs": 3600000,
|
||||
"reward": {
|
||||
"min": 50,
|
||||
"max": 150,
|
||||
"currency": "CREDITS"
|
||||
}
|
||||
},
|
||||
"commands": {
|
||||
"example": true
|
||||
},
|
||||
"studentRole": "123456789012345678",
|
||||
"visitorRole": "123456789012345678",
|
||||
"colorRoles": [],
|
||||
"moderation": {
|
||||
"prune": {
|
||||
"maxAmount": 100,
|
||||
"confirmThreshold": 50,
|
||||
"batchSize": 100,
|
||||
"batchDelayMs": 1000
|
||||
},
|
||||
"cases": {
|
||||
"dmOnWarn": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Fields marked as `integer` or `bigint` in the types can often be provided as strings in the JSON to ensure precision, but the system handles parsing them.
|
||||
149
docs/DATABASE.md
Normal file
149
docs/DATABASE.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Database Schema
|
||||
|
||||
This document outlines the database schema for the Aurora project. The database is PostgreSQL, managed via Drizzle ORM.
|
||||
|
||||
## Tables
|
||||
|
||||
### Users (`users`)
|
||||
Stores user data, economy, and progression.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `bigint` | Primary Key. Discord User ID. |
|
||||
| `class_id` | `bigint` | Foreign Key -> `classes.id`. |
|
||||
| `username` | `varchar(255)` | User's Discord username. |
|
||||
| `is_active` | `boolean` | Whether the user is active (default: true). |
|
||||
| `balance` | `bigint` | User's currency balance. |
|
||||
| `xp` | `bigint` | User's experience points. |
|
||||
| `level` | `integer` | User's level. |
|
||||
| `daily_streak` | `integer` | Current streak of daily command usage. |
|
||||
| `settings` | `jsonb` | User-specific settings. |
|
||||
| `created_at` | `timestamp` | Record creation time. |
|
||||
| `updated_at` | `timestamp` | Last update time. |
|
||||
|
||||
### Classes (`classes`)
|
||||
Available character classes.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `bigint` | Primary Key. Custom ID. |
|
||||
| `name` | `varchar(255)` | Class name (Unique). |
|
||||
| `balance` | `bigint` | Class bank balance (shared/flavor). |
|
||||
| `role_id` | `varchar(255)` | Discord Role ID associated with the class. |
|
||||
|
||||
### Items (`items`)
|
||||
Definitions of items available in the game.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `serial` | Primary Key. Auto-incrementing ID. |
|
||||
| `name` | `varchar(255)` | Item name (Unique). |
|
||||
| `description` | `text` | Item description. |
|
||||
| `rarity` | `varchar(20)` | Common, Rare, etc. Default: 'Common'. |
|
||||
| `type` | `varchar(50)` | MATERIAL, CONSUMABLE, EQUIPMENT, etc. |
|
||||
| `usage_data` | `jsonb` | Effect data for consumables/usables. |
|
||||
| `price` | `bigint` | Base value of the item. |
|
||||
| `icon_url` | `text` | URL for the item's icon. |
|
||||
| `image_url` | `text` | URL for the item's large image. |
|
||||
|
||||
### Inventory (`inventory`)
|
||||
Items held by users.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `user_id` | `bigint` | PK/FK -> `users.id`. |
|
||||
| `item_id` | `integer` | PK/FK -> `items.id`. |
|
||||
| `quantity` | `bigint` | Amount held. Must be > 0. |
|
||||
|
||||
### Transactions (`transactions`)
|
||||
Currency transaction history.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `bigserial` | Primary Key. |
|
||||
| `user_id` | `bigint` | FK -> `users.id`. The user affecting the balance. |
|
||||
| `related_user_id` | `bigint` | FK -> `users.id`. The other party (if any). |
|
||||
| `amount` | `bigint` | Amount transferred. |
|
||||
| `type` | `varchar(50)` | Transaction type identifier. |
|
||||
| `description` | `text` | Human-readable description. |
|
||||
| `created_at` | `timestamp` | Time of transaction. |
|
||||
|
||||
### Item Transactions (`item_transactions`)
|
||||
Item flow history.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `bigserial` | Primary Key. |
|
||||
| `user_id` | `bigint` | FK -> `users.id`. |
|
||||
| `related_user_id` | `bigint` | FK -> `users.id`. |
|
||||
| `item_id` | `integer` | FK -> `items.id`. |
|
||||
| `quantity` | `bigint` | Amount gained (+) or lost (-). |
|
||||
| `type` | `varchar(50)` | TRADE, SHOP_BUY, DROP, etc. |
|
||||
| `description` | `text` | Description. |
|
||||
| `created_at` | `timestamp` | Time of transaction. |
|
||||
|
||||
### Quests (`quests`)
|
||||
Quest definitions.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `serial` | Primary Key. |
|
||||
| `name` | `varchar(255)` | Quest title. |
|
||||
| `description` | `text` | Quest text. |
|
||||
| `trigger_event` | `varchar(50)` | Event that triggers progress checks. |
|
||||
| `requirements` | `jsonb` | Completion criteria. |
|
||||
| `rewards` | `jsonb` | Rewards for completion. |
|
||||
|
||||
### User Quests (`user_quests`)
|
||||
User progress on quests.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `user_id` | `bigint` | PK/FK -> `users.id`. |
|
||||
| `quest_id` | `integer` | PK/FK -> `quests.id`. |
|
||||
| `progress` | `integer` | Current progress value. |
|
||||
| `completed_at` | `timestamp` | Completion time (null if active). |
|
||||
|
||||
### User Timers (`user_timers`)
|
||||
Generic timers for cooldowns, temporary effects, etc.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `user_id` | `bigint` | PK/FK -> `users.id`. |
|
||||
| `type` | `varchar(50)` | PK. Timer type (COOLDOWN, EFFECT, ACCESS). |
|
||||
| `key` | `varchar(100)` | PK. specific ID (e.g. 'daily'). |
|
||||
| `expires_at` | `timestamp` | When the timer expires. |
|
||||
| `metadata` | `jsonb` | Extra data. |
|
||||
|
||||
### Lootdrops (`lootdrops`)
|
||||
Active chat loot drop events.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `message_id` | `varchar(255)` | Primary Key. Discord Message ID. |
|
||||
| `channel_id` | `varchar(255)` | Discord Channel ID. |
|
||||
| `reward_amount` | `integer` | Currency amount. |
|
||||
| `currency` | `varchar(50)` | Currency type constant. |
|
||||
| `claimed_by` | `bigint` | FK -> `users.id`. Null if unclaimed. |
|
||||
| `created_at` | `timestamp` | Spawn time. |
|
||||
| `expires_at` | `timestamp` | Despawn time. |
|
||||
|
||||
### Moderation Cases (`moderation_cases`)
|
||||
History of moderation actions.
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `bigserial` | Primary Key. |
|
||||
| `case_id` | `varchar(50)` | Unique friendly ID. |
|
||||
| `type` | `varchar(20)` | warn, timeout, kick, ban, etc. |
|
||||
| `user_id` | `bigint` | Target user ID. |
|
||||
| `username` | `varchar(255)` | Target username snapshot. |
|
||||
| `moderator_id` | `bigint` | Acting moderator ID. |
|
||||
| `moderator_name` | `varchar(255)` | Moderator username snapshot. |
|
||||
| `reason` | `text` | Reason for action. |
|
||||
| `metadata` | `jsonb` | Extra data. |
|
||||
| `active` | `boolean` | Is this case active? |
|
||||
| `created_at` | `timestamp` | Creation time. |
|
||||
| `resolved_at` | `timestamp` | Resolution/Expiration time. |
|
||||
| `resolved_by` | `bigint` | User ID who resolved it. |
|
||||
| `resolved_reason` | `text` | Reason for resolution. |
|
||||
127
docs/LOOTBOX_GUIDE.md
Normal file
127
docs/LOOTBOX_GUIDE.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Lootbox Creation Guide
|
||||
|
||||
Currently, the Item Wizard does not support creating **Lootbox** items directly. Instead, they must be inserted manually into the database. This guide details the required JSON structure for the `LOOTBOX` effect.
|
||||
|
||||
## Item Structure
|
||||
|
||||
To create a lootbox, you need to insert a row into the `items` table. The critical part is the `usageData` JSON column.
|
||||
|
||||
```json
|
||||
{
|
||||
"consume": true,
|
||||
"effects": [
|
||||
{
|
||||
"type": "LOOTBOX",
|
||||
"pool": [ ... ]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Loot Table Structure
|
||||
|
||||
The `pool` property is an array of `LootTableItem` objects. A random item is selected based on the total `weight` of all items in the pool.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `type` | `string` | One of: `CURRENCY`, `ITEM`, `XP`, `NOTHING`. |
|
||||
| `weight` | `number` | The relative probability weight of this outcome. |
|
||||
| `message` | `string` | (Optional) Custom message to display when this outcome is selected. |
|
||||
|
||||
### Outcome Types
|
||||
|
||||
#### 1. Currency
|
||||
Gives the user coins.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "CURRENCY",
|
||||
"weight": 50,
|
||||
"amount": 100, // Fixed amount OR
|
||||
"minAmount": 50, // Minimum random amount
|
||||
"maxAmount": 150 // Maximum random amount
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. XP
|
||||
Gives the user Experience Points.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "XP",
|
||||
"weight": 30,
|
||||
"amount": 500 // Fixed amount OR range (minAmount/maxAmount)
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Item
|
||||
Gives the user another item (by ID).
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ITEM",
|
||||
"weight": 10,
|
||||
"itemId": 42, // The ID of the item to give
|
||||
"amount": 1 // (Optional) Quantity to give, default 1
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Nothing
|
||||
An empty roll.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "NOTHING",
|
||||
"weight": 10,
|
||||
"message": "The box was empty! Better luck next time."
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
Here is a full SQL insert example (using a hypothetical SQL client or Drizzle studio) for a "Basic Lootbox":
|
||||
|
||||
**Name**: Basic Lootbox
|
||||
**Type**: CONSUMABLE
|
||||
**Effect**:
|
||||
- 50% chance for 100-200 Coins
|
||||
- 30% chance for 500 XP
|
||||
- 10% chance for Item ID 5 (e.g. Rare Gem)
|
||||
- 10% chance for Nothing
|
||||
|
||||
**JSON for `usageData`**:
|
||||
```json
|
||||
{
|
||||
"consume": true,
|
||||
"effects": [
|
||||
{
|
||||
"type": "LOOTBOX",
|
||||
"pool": [
|
||||
{
|
||||
"type": "CURRENCY",
|
||||
"weight": 50,
|
||||
"minAmount": 100,
|
||||
"maxAmount": 200
|
||||
},
|
||||
{
|
||||
"type": "XP",
|
||||
"weight": 30,
|
||||
"amount": 500
|
||||
},
|
||||
{
|
||||
"type": "ITEM",
|
||||
"weight": 10,
|
||||
"itemId": 5,
|
||||
"amount": 1,
|
||||
"message": "Startstruck! You found a Rare Gem!"
|
||||
},
|
||||
{
|
||||
"type": "NOTHING",
|
||||
"weight": 10,
|
||||
"message": "It's empty..."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
8
drizzle/0002_fancy_forge.sql
Normal file
8
drizzle/0002_fancy_forge.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
CREATE INDEX "moderation_cases_user_id_idx" ON "moderation_cases" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "moderation_cases_case_id_idx" ON "moderation_cases" USING btree ("case_id");--> statement-breakpoint
|
||||
CREATE INDEX "transactions_created_at_idx" ON "transactions" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX "user_timers_expires_at_idx" ON "user_timers" USING btree ("expires_at");--> statement-breakpoint
|
||||
CREATE INDEX "user_timers_lookup_idx" ON "user_timers" USING btree ("user_id","type","key");--> statement-breakpoint
|
||||
CREATE INDEX "users_username_idx" ON "users" USING btree ("username");--> statement-breakpoint
|
||||
CREATE INDEX "users_balance_idx" ON "users" USING btree ("balance");--> statement-breakpoint
|
||||
CREATE INDEX "users_level_xp_idx" ON "users" USING btree ("level","xp");
|
||||
1020
drizzle/meta/0002_snapshot.json
Normal file
1020
drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,13 @@
|
||||
"when": 1766606046050,
|
||||
"tag": "0001_heavy_thundra",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1767716705797,
|
||||
"tag": "0002_fancy_forge",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,8 +5,7 @@
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"drizzle-kit": "^0.31.7",
|
||||
"postgres": "^3.4.7"
|
||||
"drizzle-kit": "^0.31.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
84
src/commands/admin/health.test.ts
Normal file
84
src/commands/admin/health.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { health } from "./health";
|
||||
import { ChatInputCommandInteraction, Colors } from "discord.js";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
|
||||
// Mock DrizzleClient
|
||||
const executeMock = mock(() => Promise.resolve());
|
||||
mock.module("@/lib/DrizzleClient", () => ({
|
||||
DrizzleClient: {
|
||||
execute: executeMock
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock BotClient (already has lastCommandTimestamp if imported, but we might want to control it)
|
||||
AuroraClient.lastCommandTimestamp = 1641481200000; // Fixed timestamp for testing
|
||||
|
||||
describe("Health Command", () => {
|
||||
beforeEach(() => {
|
||||
executeMock.mockClear();
|
||||
});
|
||||
|
||||
test("should execute successfully and return health embed", async () => {
|
||||
const interaction = {
|
||||
deferReply: mock(() => Promise.resolve()),
|
||||
editReply: mock(() => Promise.resolve()),
|
||||
client: {
|
||||
ws: {
|
||||
ping: 42
|
||||
}
|
||||
},
|
||||
user: { id: "123", username: "testuser" },
|
||||
commandName: "health"
|
||||
} as unknown as ChatInputCommandInteraction;
|
||||
|
||||
await health.execute(interaction);
|
||||
|
||||
expect(interaction.deferReply).toHaveBeenCalled();
|
||||
expect(executeMock).toHaveBeenCalled();
|
||||
expect(interaction.editReply).toHaveBeenCalled();
|
||||
|
||||
const editReplyCall = (interaction.editReply as any).mock.calls[0][0];
|
||||
const embed = editReplyCall.embeds[0];
|
||||
|
||||
expect(embed.data.title).toBe("System Health Status");
|
||||
expect(embed.data.color).toBe(Colors.Aqua);
|
||||
|
||||
// Check fields
|
||||
const fields = embed.data.fields;
|
||||
expect(fields).toBeDefined();
|
||||
|
||||
// Connectivity field
|
||||
const connectivityField = fields.find((f: any) => f.name === "📡 Connectivity");
|
||||
expect(connectivityField.value).toContain("42ms");
|
||||
expect(connectivityField.value).toContain("Connected");
|
||||
|
||||
// Activity field
|
||||
const activityField = fields.find((f: any) => f.name === "⌨️ Activity");
|
||||
expect(activityField.value).toContain("R>"); // Relative Discord timestamp
|
||||
});
|
||||
|
||||
test("should handle database disconnection", async () => {
|
||||
executeMock.mockImplementationOnce(() => Promise.reject(new Error("DB Down")));
|
||||
|
||||
const interaction = {
|
||||
deferReply: mock(() => Promise.resolve()),
|
||||
editReply: mock(() => Promise.resolve()),
|
||||
client: {
|
||||
ws: {
|
||||
ping: 42
|
||||
}
|
||||
},
|
||||
user: { id: "123", username: "testuser" },
|
||||
commandName: "health"
|
||||
} as unknown as ChatInputCommandInteraction;
|
||||
|
||||
await health.execute(interaction);
|
||||
|
||||
const editReplyCall = (interaction.editReply as any).mock.calls[0][0];
|
||||
const embed = editReplyCall.embeds[0];
|
||||
const connectivityField = embed.data.fields.find((f: any) => f.name === "📡 Connectivity");
|
||||
|
||||
expect(connectivityField.value).toContain("Disconnected");
|
||||
});
|
||||
});
|
||||
60
src/commands/admin/health.ts
Normal file
60
src/commands/admin/health.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { createCommand } from "@lib/utils";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, Colors } from "discord.js";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { createBaseEmbed } from "@lib/embeds";
|
||||
|
||||
export const health = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("health")
|
||||
.setDescription("Check the bot's health status")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
|
||||
// 1. Check Discord API latency
|
||||
const wsPing = interaction.client.ws.ping;
|
||||
|
||||
// 2. Verify database connection
|
||||
let dbStatus = "Connected";
|
||||
let dbPing = -1;
|
||||
try {
|
||||
const start = Date.now();
|
||||
await DrizzleClient.execute(sql`SELECT 1`);
|
||||
dbPing = Date.now() - start;
|
||||
} catch (error) {
|
||||
dbStatus = "Disconnected";
|
||||
console.error("Health check DB error:", error);
|
||||
}
|
||||
|
||||
// 3. Uptime
|
||||
const uptime = process.uptime();
|
||||
const days = Math.floor(uptime / 86400);
|
||||
const hours = Math.floor((uptime % 86400) / 3600);
|
||||
const minutes = Math.floor((uptime % 3600) / 60);
|
||||
const seconds = Math.floor(uptime % 60);
|
||||
const uptimeString = `${days}d ${hours}h ${minutes}m ${seconds}s`;
|
||||
|
||||
// 4. Memory usage
|
||||
const memory = process.memoryUsage();
|
||||
const heapUsed = (memory.heapUsed / 1024 / 1024).toFixed(2);
|
||||
const heapTotal = (memory.heapTotal / 1024 / 1024).toFixed(2);
|
||||
const rss = (memory.rss / 1024 / 1024).toFixed(2);
|
||||
|
||||
// 5. Last successful command
|
||||
const lastCommand = AuroraClient.lastCommandTimestamp
|
||||
? `<t:${Math.floor(AuroraClient.lastCommandTimestamp / 1000)}:R>`
|
||||
: "None since startup";
|
||||
|
||||
const embed = createBaseEmbed("System Health Status", undefined, Colors.Aqua)
|
||||
.addFields(
|
||||
{ name: "📡 Connectivity", value: `**Discord WS:** ${wsPing}ms\n**Database:** ${dbStatus} ${dbPing >= 0 ? `(${dbPing}ms)` : ""}`, inline: true },
|
||||
{ name: "⏱️ Uptime", value: uptimeString, inline: true },
|
||||
{ name: "🧠 Memory Usage", value: `**RSS:** ${rss} MB\n**Heap:** ${heapUsed} / ${heapTotal} MB`, inline: false },
|
||||
{ name: "⌨️ Activity", value: `**Last Command:** ${lastCommand}`, inline: true }
|
||||
);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() });
|
||||
|
||||
|
||||
@@ -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!"
|
||||
)]
|
||||
});
|
||||
|
||||
@@ -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}>` });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
43
src/db/indexes.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { expect, test, describe } from "bun:test";
|
||||
import { postgres } from "../lib/DrizzleClient";
|
||||
|
||||
describe("Database Indexes", () => {
|
||||
test("should have indexes on users table", async () => {
|
||||
const result = await postgres`
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'users'
|
||||
`;
|
||||
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
|
||||
expect(indexNames).toContain("users_balance_idx");
|
||||
expect(indexNames).toContain("users_level_xp_idx");
|
||||
});
|
||||
|
||||
test("should have index on transactions table", async () => {
|
||||
const result = await postgres`
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'transactions'
|
||||
`;
|
||||
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
|
||||
expect(indexNames).toContain("transactions_created_at_idx");
|
||||
});
|
||||
|
||||
test("should have indexes on moderation_cases table", async () => {
|
||||
const result = await postgres`
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'moderation_cases'
|
||||
`;
|
||||
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
|
||||
expect(indexNames).toContain("moderation_cases_user_id_idx");
|
||||
expect(indexNames).toContain("moderation_cases_case_id_idx");
|
||||
});
|
||||
|
||||
test("should have indexes on user_timers table", async () => {
|
||||
const result = await postgres`
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'user_timers'
|
||||
`;
|
||||
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
|
||||
expect(indexNames).toContain("user_timers_expires_at_idx");
|
||||
expect(indexNames).toContain("user_timers_lookup_idx");
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
text,
|
||||
integer,
|
||||
primaryKey,
|
||||
index,
|
||||
bigserial,
|
||||
check
|
||||
} from 'drizzle-orm/pg-core';
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
|
||||
|
||||
|
||||
14
src/index.ts
14
src/index.ts
@@ -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);
|
||||
@@ -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] });
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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
65
src/lib/constants.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Global Constants and Enums
|
||||
*/
|
||||
|
||||
export enum TimerType {
|
||||
COOLDOWN = 'COOLDOWN',
|
||||
EFFECT = 'EFFECT',
|
||||
ACCESS = 'ACCESS',
|
||||
EXAM_SYSTEM = 'EXAM_SYSTEM',
|
||||
}
|
||||
|
||||
export enum EffectType {
|
||||
ADD_XP = 'ADD_XP',
|
||||
ADD_BALANCE = 'ADD_BALANCE',
|
||||
REPLY_MESSAGE = 'REPLY_MESSAGE',
|
||||
XP_BOOST = 'XP_BOOST',
|
||||
TEMP_ROLE = 'TEMP_ROLE',
|
||||
COLOR_ROLE = 'COLOR_ROLE',
|
||||
LOOTBOX = 'LOOTBOX',
|
||||
}
|
||||
|
||||
export enum TransactionType {
|
||||
TRANSFER_IN = 'TRANSFER_IN',
|
||||
TRANSFER_OUT = 'TRANSFER_OUT',
|
||||
DAILY_REWARD = 'DAILY_REWARD',
|
||||
ITEM_USE = 'ITEM_USE',
|
||||
LOOTBOX = 'LOOTBOX',
|
||||
EXAM_REWARD = 'EXAM_REWARD',
|
||||
PURCHASE = 'PURCHASE',
|
||||
TRADE_IN = 'TRADE_IN',
|
||||
TRADE_OUT = 'TRADE_OUT',
|
||||
QUEST_REWARD = 'QUEST_REWARD',
|
||||
}
|
||||
|
||||
export enum ItemTransactionType {
|
||||
TRADE_IN = 'TRADE_IN',
|
||||
TRADE_OUT = 'TRADE_OUT',
|
||||
SHOP_BUY = 'SHOP_BUY',
|
||||
DROP = 'DROP',
|
||||
GIVE = 'GIVE',
|
||||
USE = 'USE',
|
||||
}
|
||||
|
||||
export enum ItemType {
|
||||
MATERIAL = 'MATERIAL',
|
||||
CONSUMABLE = 'CONSUMABLE',
|
||||
EQUIPMENT = 'EQUIPMENT',
|
||||
QUEST = 'QUEST',
|
||||
}
|
||||
|
||||
export enum CaseType {
|
||||
WARN = 'warn',
|
||||
TIMEOUT = 'timeout',
|
||||
KICK = 'kick',
|
||||
BAN = 'ban',
|
||||
NOTE = 'note',
|
||||
PRUNE = 'prune',
|
||||
}
|
||||
|
||||
export enum LootType {
|
||||
NOTHING = 'NOTHING',
|
||||
CURRENCY = 'CURRENCY',
|
||||
XP = 'XP',
|
||||
ITEM = 'ITEM',
|
||||
}
|
||||
47
src/lib/db.test.ts
Normal file
47
src/lib/db.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||
|
||||
// Mock DrizzleClient
|
||||
mock.module("./DrizzleClient", () => ({
|
||||
DrizzleClient: {
|
||||
transaction: async (cb: any) => cb("MOCK_TX")
|
||||
}
|
||||
}));
|
||||
|
||||
import { withTransaction } from "./db";
|
||||
import { setShuttingDown, getActiveTransactions, decrementTransactions } from "./shutdown";
|
||||
|
||||
describe("db withTransaction", () => {
|
||||
beforeEach(() => {
|
||||
setShuttingDown(false);
|
||||
// Reset transaction count
|
||||
while (getActiveTransactions() > 0) {
|
||||
decrementTransactions();
|
||||
}
|
||||
});
|
||||
|
||||
it("should allow transactions when not shutting down", async () => {
|
||||
const result = await withTransaction(async (tx) => {
|
||||
return "success";
|
||||
});
|
||||
expect(result).toBe("success");
|
||||
expect(getActiveTransactions()).toBe(0);
|
||||
});
|
||||
|
||||
it("should throw error when shutting down", async () => {
|
||||
setShuttingDown(true);
|
||||
expect(withTransaction(async (tx) => {
|
||||
return "success";
|
||||
})).rejects.toThrow("System is shutting down, no new transactions allowed.");
|
||||
expect(getActiveTransactions()).toBe(0);
|
||||
});
|
||||
|
||||
it("should increment and decrement transaction count", async () => {
|
||||
let countDuring = 0;
|
||||
await withTransaction(async (tx) => {
|
||||
countDuring = getActiveTransactions();
|
||||
return "ok";
|
||||
});
|
||||
expect(countDuring).toBe(1);
|
||||
expect(getActiveTransactions()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
59
src/lib/handlers/CommandHandler.test.ts
Normal file
59
src/lib/handlers/CommandHandler.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { CommandHandler } from "./CommandHandler";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
|
||||
// Mock UserService
|
||||
mock.module("@/modules/user/user.service", () => ({
|
||||
userService: {
|
||||
getOrCreateUser: mock(() => Promise.resolve())
|
||||
}
|
||||
}));
|
||||
|
||||
describe("CommandHandler", () => {
|
||||
beforeEach(() => {
|
||||
AuroraClient.commands.clear();
|
||||
AuroraClient.lastCommandTimestamp = null;
|
||||
});
|
||||
|
||||
test("should update lastCommandTimestamp on successful execution", async () => {
|
||||
const executeSuccess = mock(() => Promise.resolve());
|
||||
AuroraClient.commands.set("test", {
|
||||
data: { name: "test" } as any,
|
||||
execute: executeSuccess
|
||||
} as any);
|
||||
|
||||
const interaction = {
|
||||
commandName: "test",
|
||||
user: { id: "123", username: "testuser" }
|
||||
} as unknown as ChatInputCommandInteraction;
|
||||
|
||||
await CommandHandler.handle(interaction);
|
||||
|
||||
expect(executeSuccess).toHaveBeenCalled();
|
||||
expect(AuroraClient.lastCommandTimestamp).not.toBeNull();
|
||||
expect(AuroraClient.lastCommandTimestamp).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("should not update lastCommandTimestamp on failed execution", async () => {
|
||||
const executeError = mock(() => Promise.reject(new Error("Command Failed")));
|
||||
AuroraClient.commands.set("fail", {
|
||||
data: { name: "fail" } as any,
|
||||
execute: executeError
|
||||
} as any);
|
||||
|
||||
const interaction = {
|
||||
commandName: "fail",
|
||||
user: { id: "123", username: "testuser" },
|
||||
replied: false,
|
||||
deferred: false,
|
||||
reply: mock(() => Promise.resolve()),
|
||||
followUp: mock(() => Promise.resolve())
|
||||
} as unknown as ChatInputCommandInteraction;
|
||||
|
||||
await CommandHandler.handle(interaction);
|
||||
|
||||
expect(executeError).toHaveBeenCalled();
|
||||
expect(AuroraClient.lastCommandTimestamp).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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!');
|
||||
|
||||
@@ -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
38
src/lib/logger.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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
56
src/lib/shutdown.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, beforeEach } from "bun:test";
|
||||
import { isShuttingDown, setShuttingDown, incrementTransactions, decrementTransactions, getActiveTransactions, waitForTransactions } from "./shutdown";
|
||||
|
||||
describe("shutdown logic", () => {
|
||||
beforeEach(() => {
|
||||
setShuttingDown(false);
|
||||
while (getActiveTransactions() > 0) {
|
||||
decrementTransactions();
|
||||
}
|
||||
});
|
||||
|
||||
it("should initialize with shuttingDown as false", () => {
|
||||
expect(isShuttingDown()).toBe(false);
|
||||
});
|
||||
|
||||
it("should update shuttingDown state", () => {
|
||||
setShuttingDown(true);
|
||||
expect(isShuttingDown()).toBe(true);
|
||||
});
|
||||
|
||||
it("should track active transactions", () => {
|
||||
expect(getActiveTransactions()).toBe(0);
|
||||
incrementTransactions();
|
||||
expect(getActiveTransactions()).toBe(1);
|
||||
decrementTransactions();
|
||||
expect(getActiveTransactions()).toBe(0);
|
||||
});
|
||||
|
||||
it("should wait for transactions to complete", async () => {
|
||||
incrementTransactions();
|
||||
|
||||
const start = Date.now();
|
||||
const waitPromise = waitForTransactions(1000);
|
||||
|
||||
// Simulate completion after 200ms
|
||||
setTimeout(() => {
|
||||
decrementTransactions();
|
||||
}, 200);
|
||||
|
||||
await waitPromise;
|
||||
const duration = Date.now() - start;
|
||||
|
||||
expect(duration).toBeGreaterThanOrEqual(200);
|
||||
expect(getActiveTransactions()).toBe(0);
|
||||
});
|
||||
|
||||
it("should timeout if transactions never complete", async () => {
|
||||
incrementTransactions();
|
||||
const start = Date.now();
|
||||
await waitForTransactions(500);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
expect(duration).toBeGreaterThanOrEqual(500);
|
||||
expect(getActiveTransactions()).toBe(1); // Still 1 because we didn't decrement
|
||||
});
|
||||
});
|
||||
30
src/lib/shutdown.ts
Normal file
30
src/lib/shutdown.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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));
|
||||
}
|
||||
};
|
||||
@@ -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];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = () => [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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})`,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.";
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
291
src/modules/moderation/moderation.service.test.ts
Normal file
291
src/modules/moderation/moderation.service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)],
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
114
src/modules/system/temp-role.service.test.ts
Normal file
114
src/modules/system/temp-role.service.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
67
src/modules/system/temp-role.service.ts
Normal file
67
src/modules/system/temp-role.service.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
216
src/web/public/script.js
Normal 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
385
src/web/public/style.css
Normal 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
62
src/web/router.test.ts
Normal 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
56
src/web/router.ts
Normal 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
56
src/web/routes/actions.ts
Normal 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
154
src/web/routes/dashboard.ts
Normal 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
9
src/web/routes/health.ts
Normal 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
16
src/web/routes/home.ts
Normal 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
93
src/web/server.ts
Normal 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
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/web/utils/format.test.ts
Normal file
24
src/web/utils/format.test.ts
Normal 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
20
src/web/utils/format.ts
Normal 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(" ");
|
||||
}
|
||||
17
src/web/utils/html.test.ts
Normal file
17
src/web/utils/html.test.ts
Normal 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("<script>alert("xss")</script>");
|
||||
});
|
||||
|
||||
it("should handle mixed content", () => {
|
||||
const unsafe = 'Hello & "World"';
|
||||
const safe = escapeHtml(unsafe);
|
||||
expect(safe).toBe("Hello & "World"");
|
||||
});
|
||||
});
|
||||
14
src/web/utils/html.ts
Normal file
14
src/web/utils/html.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
55
src/web/views/layout.ts
Normal file
55
src/web/views/layout.ts
Normal 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>© ${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
46
src/web/websocket.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
52
tickets/2026-01-07-replace-mock-dashboard-data.md
Normal file
52
tickets/2026-01-07-replace-mock-dashboard-data.md
Normal 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.
|
||||
Reference in New Issue
Block a user