Compare commits
46 Commits
1523a392c2
...
feat/repla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9804456257 | ||
|
|
259b8d6875 | ||
|
|
a2cb684b71 | ||
|
|
9c2098bc46 | ||
|
|
618d973863 | ||
|
|
63f55b6dfd | ||
|
|
ac4025e179 | ||
|
|
ff23f22337 | ||
|
|
292991c605 | ||
|
|
4640cd11a7 | ||
|
|
43a003f641 | ||
|
|
6f4426e49d | ||
|
|
894cad91a8 | ||
|
|
2a1c4e65ae | ||
|
|
022f748517 | ||
|
|
ca392749e3 | ||
|
|
4a1e72c5f3 | ||
|
|
d29a1ec2b7 | ||
|
|
1dd269bf2f | ||
|
|
69186ff3e9 | ||
|
|
b989e807dc | ||
|
|
2e6bdec38c | ||
|
|
a9d5c806ad | ||
|
|
6f73178375 | ||
|
|
dd62336571 | ||
|
|
8280111b66 | ||
|
|
34347f0c63 | ||
|
|
c807fd4fd0 | ||
|
|
47b980eff1 | ||
|
|
bc89ddf7c0 | ||
|
|
606d83a7ae | ||
|
|
3351295bdc | ||
|
|
92cb048a7a | ||
|
|
6ead0c0393 | ||
|
|
278ef4b6b0 | ||
|
|
9a32ab298d | ||
|
|
a2596d4124 | ||
|
|
fbc8952e0a | ||
|
|
d0b4cb80de | ||
|
|
599684cde8 | ||
|
|
5606fb6e2f | ||
|
|
fb260c5beb | ||
|
|
a227e5db59 | ||
|
|
66d5145885 | ||
|
|
2412098536 | ||
|
|
d0c48188b9 |
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/data
|
||||||
src/db/log
|
src/db/log
|
||||||
scratchpad/
|
scratchpad/
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ services:
|
|||||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
- POSTGRES_DB=${DB_NAME}
|
- POSTGRES_DB=${DB_NAME}
|
||||||
ports:
|
ports:
|
||||||
- "${DB_PORT}:5432"
|
- "127.0.0.1:${DB_PORT}:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- ./src/db/data:/var/lib/postgresql/data
|
- ./src/db/data:/var/lib/postgresql/data
|
||||||
- ./src/db/log:/var/log/postgresql
|
- ./src/db/log:/var/log/postgresql
|
||||||
@@ -46,7 +46,7 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
ports:
|
ports:
|
||||||
- "4983:4983"
|
- "127.0.0.1:4983:4983"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
|
|||||||
63
docs/COMMANDS.md
Normal file
63
docs/COMMANDS.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Command Reference
|
||||||
|
|
||||||
|
This document lists all available slash commands in Aurora, categorized by their function.
|
||||||
|
|
||||||
|
## Economy
|
||||||
|
|
||||||
|
| Command | Description | Options | Permissions |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/balance` | View your or another user's balance. | `user` (Optional): The user to check. | Everyone |
|
||||||
|
| `/daily` | Claim your daily currency reward and streak bonus. | None | Everyone |
|
||||||
|
| `/pay` | Transfer currency to another user. | `user` (Required): Recipient.<br>`amount` (Required): Amount to send. | Everyone |
|
||||||
|
| `/trade` | Start a trade session with another user. | `user` (Required): The user to trade with. | Everyone |
|
||||||
|
| `/exam` | Take your weekly exam to earn rewards based on XP gain. | None | Everyone |
|
||||||
|
|
||||||
|
## Inventory & Items
|
||||||
|
|
||||||
|
| Command | Description | Options | Permissions |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/inventory` | View your or another user's inventory. | `user` (Optional): The user to check. | Everyone |
|
||||||
|
| `/use` | Use an item from your inventory. | `item` (Required): The item to use (Autocomplete). | Everyone |
|
||||||
|
|
||||||
|
## User & Social
|
||||||
|
|
||||||
|
| Command | Description | Options | Permissions |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/profile` | View your or another user's Student ID card. | `user` (Optional): The user to view. | Everyone |
|
||||||
|
| `/leaderboard` | View top players. | `type` (Required): 'Level / XP' or 'Balance'. | Everyone |
|
||||||
|
| `/feedback` | Submit feedback, bug reports, or suggestions. | None | Everyone |
|
||||||
|
| `/quests` | View your active quests. | None | Everyone |
|
||||||
|
|
||||||
|
## Admin
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> These commands require Administrator permissions or specific roles as configured.
|
||||||
|
|
||||||
|
### General Management
|
||||||
|
| Command | Description | Options |
|
||||||
|
|---|---|---|
|
||||||
|
| `/config` | Manage bot configuration. | `group` (Req): Section.<br>`key` (Req): Setting.<br>`value` (Req): New value. |
|
||||||
|
| `/refresh` | Refresh commands or configuration cache. | `type`: 'Commands' or 'Config'. |
|
||||||
|
| `/update` | Update the bot from the repository. | None |
|
||||||
|
| `/features` | Enable/Disable system features. | `feature` (Req): Feature name.<br>`enabled` (Req): True/False. |
|
||||||
|
| `/webhook` | Send a message via webhook. | `payload` (Req): JSON payload. |
|
||||||
|
|
||||||
|
### Moderation
|
||||||
|
| Command | Description | Options |
|
||||||
|
|---|---|---|
|
||||||
|
| `/warn` | Warn a user. | `user` (Req): Target.<br>`reason` (Req): Reason. |
|
||||||
|
| `/warnings` | View active warnings for a user. | `user` (Req): Target. |
|
||||||
|
| `/clearwarning`| Clear a specific warning. | `case_id` (Req): Case ID. |
|
||||||
|
| `/case` | View details of a specific moderation case. | `case_id` (Req): Case ID. |
|
||||||
|
| `/cases` | View moderation history for a user. | `user` (Req): Target. |
|
||||||
|
| `/note` | Add a note to a user. | `user` (Req): Target.<br>`note` (Req): Content. |
|
||||||
|
| `/notes` | View notes for a user. | `user` (Req): Target. |
|
||||||
|
| `/prune` | Bulk delete messages. | `amount` (Req): Number (1-100). |
|
||||||
|
|
||||||
|
### Game Admin
|
||||||
|
| Command | Description | Options |
|
||||||
|
|---|---|---|
|
||||||
|
| `/create_item` | Create a new item in the database. | (Modal based interaction) |
|
||||||
|
| `/create_color`| Create a new color role. | `name` (Req): Role name.<br>`hex` (Req): Hex color code. |
|
||||||
|
| `/listing` | Manage shop listings (Admin view). | None (Context sensitive?) |
|
||||||
|
| `/terminal` | Control the terminal display channel. | `action`: 'setup', 'update', 'clear'. |
|
||||||
160
docs/CONFIGURATION.md
Normal file
160
docs/CONFIGURATION.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# Configuration Guide
|
||||||
|
|
||||||
|
This document outlines the structure and available options for the `config/config.json` file. The configuration is validated using Zod schemas at runtime (see `src/lib/config.ts`).
|
||||||
|
|
||||||
|
## Core Structure
|
||||||
|
|
||||||
|
### Leveling
|
||||||
|
Configuration for the XP and leveling system.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `base` | `number` | The base XP required for the first level. |
|
||||||
|
| `exponent` | `number` | The exponent used to calculate XP curves. |
|
||||||
|
| `chat.cooldownMs` | `number` | Time in milliseconds between XP gains from chat. |
|
||||||
|
| `chat.minXp` | `number` | Minimum XP awarded per message. |
|
||||||
|
| `chat.maxXp` | `number` | Maximum XP awarded per message. |
|
||||||
|
|
||||||
|
### Economy
|
||||||
|
Settings for currency, rewards, and transfers.
|
||||||
|
|
||||||
|
#### Daily
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `amount` | `integer` | Base amount granted by `/daily`. |
|
||||||
|
| `streakBonus` | `integer` | Bonus amount per streak day. |
|
||||||
|
| `weeklyBonus` | `integer` | Bonus amount for a 7-day streak. |
|
||||||
|
| `cooldownMs` | `number` | Cooldown period for the command (usually 24h). |
|
||||||
|
|
||||||
|
#### Transfers
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `allowSelfTransfer` | `boolean` | Whether users can transfer money to themselves. |
|
||||||
|
| `minAmount` | `integer` | Minimum amount required for a transfer. |
|
||||||
|
|
||||||
|
#### Exam
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `multMin` | `number` | Minimum multiplier for exam rewards. |
|
||||||
|
| `multMax` | `number` | Maximum multiplier for exam rewards. |
|
||||||
|
|
||||||
|
### Inventory
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `maxStackSize` | `integer` | Maximum count of a single item in one slot. |
|
||||||
|
| `maxSlots` | `number` | Total number of inventory slots available. |
|
||||||
|
|
||||||
|
### Lootdrop
|
||||||
|
Settings for the random chat loot drop events.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `activityWindowMs` | `number` | Time window to track activity for spawning drops. |
|
||||||
|
| `minMessages` | `number` | Minimum messages required in window to trigger drop. |
|
||||||
|
| `spawnChance` | `number` | Probability (0-1) of a drop spawning when conditions met. |
|
||||||
|
| `cooldownMs` | `number` | Minimum time between loot drops. |
|
||||||
|
| `reward.min` | `number` | Minimum currency reward. |
|
||||||
|
| `reward.max` | `number` | Maximum currency reward. |
|
||||||
|
| `reward.currency` | `string` | The currency ID/Symbol used for rewards. |
|
||||||
|
|
||||||
|
### Roles
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `studentRole` | `string` | Discord Role ID for students. |
|
||||||
|
| `visitorRole` | `string` | Discord Role ID for visitors. |
|
||||||
|
| `colorRoles` | `string[]` | List of Discord Role IDs available as color roles. |
|
||||||
|
|
||||||
|
### Moderation
|
||||||
|
Automated moderation settings.
|
||||||
|
|
||||||
|
#### Prune
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `maxAmount` | `number` | Maximum messages to delete in one go. |
|
||||||
|
| `confirmThreshold` | `number` | Amount above which confirmation is required. |
|
||||||
|
| `batchSize` | `number` | Size of delete batches. |
|
||||||
|
| `batchDelayMs` | `number` | Delay between batches. |
|
||||||
|
|
||||||
|
#### Cases
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `dmOnWarn` | `boolean` | Whether to DM users when they are warned. |
|
||||||
|
| `logChannelId` | `string` | (Optional) Channel ID for moderation logs. |
|
||||||
|
| `autoTimeoutThreshold` | `number` | (Optional) Warn count to trigger auto-timeout. |
|
||||||
|
|
||||||
|
### System & Misc
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `commands` | `Object` | Map of command names (keys) to boolean (values) to enable/disable them. |
|
||||||
|
| `welcomeChannelId` | `string` | (Optional) Channel ID for welcome messages. |
|
||||||
|
| `welcomeMessage` | `string` | (Optional) Custom welcome message text. |
|
||||||
|
| `feedbackChannelId` | `string` | (Optional) Channel ID where feedback is posted. |
|
||||||
|
| `terminal.channelId` | `string` | (Optional) Channel ID for terminal display. |
|
||||||
|
| `terminal.messageId` | `string` | (Optional) Message ID for terminal display. |
|
||||||
|
|
||||||
|
## Example Config
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"leveling": {
|
||||||
|
"base": 100,
|
||||||
|
"exponent": 1.5,
|
||||||
|
"chat": {
|
||||||
|
"cooldownMs": 60000,
|
||||||
|
"minXp": 15,
|
||||||
|
"maxXp": 25
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"daily": {
|
||||||
|
"amount": "100",
|
||||||
|
"streakBonus": "10",
|
||||||
|
"weeklyBonus": "500",
|
||||||
|
"cooldownMs": 86400000
|
||||||
|
},
|
||||||
|
"transfers": {
|
||||||
|
"allowSelfTransfer": false,
|
||||||
|
"minAmount": "10"
|
||||||
|
},
|
||||||
|
"exam": {
|
||||||
|
"multMin": 1.0,
|
||||||
|
"multMax": 2.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inventory": {
|
||||||
|
"maxStackSize": "99",
|
||||||
|
"maxSlots": 20
|
||||||
|
},
|
||||||
|
"lootdrop": {
|
||||||
|
"activityWindowMs": 300000,
|
||||||
|
"minMessages": 10,
|
||||||
|
"spawnChance": 0.05,
|
||||||
|
"cooldownMs": 3600000,
|
||||||
|
"reward": {
|
||||||
|
"min": 50,
|
||||||
|
"max": 150,
|
||||||
|
"currency": "CREDITS"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"commands": {
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"studentRole": "123456789012345678",
|
||||||
|
"visitorRole": "123456789012345678",
|
||||||
|
"colorRoles": [],
|
||||||
|
"moderation": {
|
||||||
|
"prune": {
|
||||||
|
"maxAmount": 100,
|
||||||
|
"confirmThreshold": 50,
|
||||||
|
"batchSize": 100,
|
||||||
|
"batchDelayMs": 1000
|
||||||
|
},
|
||||||
|
"cases": {
|
||||||
|
"dmOnWarn": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Fields marked as `integer` or `bigint` in the types can often be provided as strings in the JSON to ensure precision, but the system handles parsing them.
|
||||||
149
docs/DATABASE.md
Normal file
149
docs/DATABASE.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# Database Schema
|
||||||
|
|
||||||
|
This document outlines the database schema for the Aurora project. The database is PostgreSQL, managed via Drizzle ORM.
|
||||||
|
|
||||||
|
## Tables
|
||||||
|
|
||||||
|
### Users (`users`)
|
||||||
|
Stores user data, economy, and progression.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `bigint` | Primary Key. Discord User ID. |
|
||||||
|
| `class_id` | `bigint` | Foreign Key -> `classes.id`. |
|
||||||
|
| `username` | `varchar(255)` | User's Discord username. |
|
||||||
|
| `is_active` | `boolean` | Whether the user is active (default: true). |
|
||||||
|
| `balance` | `bigint` | User's currency balance. |
|
||||||
|
| `xp` | `bigint` | User's experience points. |
|
||||||
|
| `level` | `integer` | User's level. |
|
||||||
|
| `daily_streak` | `integer` | Current streak of daily command usage. |
|
||||||
|
| `settings` | `jsonb` | User-specific settings. |
|
||||||
|
| `created_at` | `timestamp` | Record creation time. |
|
||||||
|
| `updated_at` | `timestamp` | Last update time. |
|
||||||
|
|
||||||
|
### Classes (`classes`)
|
||||||
|
Available character classes.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `bigint` | Primary Key. Custom ID. |
|
||||||
|
| `name` | `varchar(255)` | Class name (Unique). |
|
||||||
|
| `balance` | `bigint` | Class bank balance (shared/flavor). |
|
||||||
|
| `role_id` | `varchar(255)` | Discord Role ID associated with the class. |
|
||||||
|
|
||||||
|
### Items (`items`)
|
||||||
|
Definitions of items available in the game.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `serial` | Primary Key. Auto-incrementing ID. |
|
||||||
|
| `name` | `varchar(255)` | Item name (Unique). |
|
||||||
|
| `description` | `text` | Item description. |
|
||||||
|
| `rarity` | `varchar(20)` | Common, Rare, etc. Default: 'Common'. |
|
||||||
|
| `type` | `varchar(50)` | MATERIAL, CONSUMABLE, EQUIPMENT, etc. |
|
||||||
|
| `usage_data` | `jsonb` | Effect data for consumables/usables. |
|
||||||
|
| `price` | `bigint` | Base value of the item. |
|
||||||
|
| `icon_url` | `text` | URL for the item's icon. |
|
||||||
|
| `image_url` | `text` | URL for the item's large image. |
|
||||||
|
|
||||||
|
### Inventory (`inventory`)
|
||||||
|
Items held by users.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `user_id` | `bigint` | PK/FK -> `users.id`. |
|
||||||
|
| `item_id` | `integer` | PK/FK -> `items.id`. |
|
||||||
|
| `quantity` | `bigint` | Amount held. Must be > 0. |
|
||||||
|
|
||||||
|
### Transactions (`transactions`)
|
||||||
|
Currency transaction history.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `bigserial` | Primary Key. |
|
||||||
|
| `user_id` | `bigint` | FK -> `users.id`. The user affecting the balance. |
|
||||||
|
| `related_user_id` | `bigint` | FK -> `users.id`. The other party (if any). |
|
||||||
|
| `amount` | `bigint` | Amount transferred. |
|
||||||
|
| `type` | `varchar(50)` | Transaction type identifier. |
|
||||||
|
| `description` | `text` | Human-readable description. |
|
||||||
|
| `created_at` | `timestamp` | Time of transaction. |
|
||||||
|
|
||||||
|
### Item Transactions (`item_transactions`)
|
||||||
|
Item flow history.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `bigserial` | Primary Key. |
|
||||||
|
| `user_id` | `bigint` | FK -> `users.id`. |
|
||||||
|
| `related_user_id` | `bigint` | FK -> `users.id`. |
|
||||||
|
| `item_id` | `integer` | FK -> `items.id`. |
|
||||||
|
| `quantity` | `bigint` | Amount gained (+) or lost (-). |
|
||||||
|
| `type` | `varchar(50)` | TRADE, SHOP_BUY, DROP, etc. |
|
||||||
|
| `description` | `text` | Description. |
|
||||||
|
| `created_at` | `timestamp` | Time of transaction. |
|
||||||
|
|
||||||
|
### Quests (`quests`)
|
||||||
|
Quest definitions.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `serial` | Primary Key. |
|
||||||
|
| `name` | `varchar(255)` | Quest title. |
|
||||||
|
| `description` | `text` | Quest text. |
|
||||||
|
| `trigger_event` | `varchar(50)` | Event that triggers progress checks. |
|
||||||
|
| `requirements` | `jsonb` | Completion criteria. |
|
||||||
|
| `rewards` | `jsonb` | Rewards for completion. |
|
||||||
|
|
||||||
|
### User Quests (`user_quests`)
|
||||||
|
User progress on quests.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `user_id` | `bigint` | PK/FK -> `users.id`. |
|
||||||
|
| `quest_id` | `integer` | PK/FK -> `quests.id`. |
|
||||||
|
| `progress` | `integer` | Current progress value. |
|
||||||
|
| `completed_at` | `timestamp` | Completion time (null if active). |
|
||||||
|
|
||||||
|
### User Timers (`user_timers`)
|
||||||
|
Generic timers for cooldowns, temporary effects, etc.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `user_id` | `bigint` | PK/FK -> `users.id`. |
|
||||||
|
| `type` | `varchar(50)` | PK. Timer type (COOLDOWN, EFFECT, ACCESS). |
|
||||||
|
| `key` | `varchar(100)` | PK. specific ID (e.g. 'daily'). |
|
||||||
|
| `expires_at` | `timestamp` | When the timer expires. |
|
||||||
|
| `metadata` | `jsonb` | Extra data. |
|
||||||
|
|
||||||
|
### Lootdrops (`lootdrops`)
|
||||||
|
Active chat loot drop events.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `message_id` | `varchar(255)` | Primary Key. Discord Message ID. |
|
||||||
|
| `channel_id` | `varchar(255)` | Discord Channel ID. |
|
||||||
|
| `reward_amount` | `integer` | Currency amount. |
|
||||||
|
| `currency` | `varchar(50)` | Currency type constant. |
|
||||||
|
| `claimed_by` | `bigint` | FK -> `users.id`. Null if unclaimed. |
|
||||||
|
| `created_at` | `timestamp` | Spawn time. |
|
||||||
|
| `expires_at` | `timestamp` | Despawn time. |
|
||||||
|
|
||||||
|
### Moderation Cases (`moderation_cases`)
|
||||||
|
History of moderation actions.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `bigserial` | Primary Key. |
|
||||||
|
| `case_id` | `varchar(50)` | Unique friendly ID. |
|
||||||
|
| `type` | `varchar(20)` | warn, timeout, kick, ban, etc. |
|
||||||
|
| `user_id` | `bigint` | Target user ID. |
|
||||||
|
| `username` | `varchar(255)` | Target username snapshot. |
|
||||||
|
| `moderator_id` | `bigint` | Acting moderator ID. |
|
||||||
|
| `moderator_name` | `varchar(255)` | Moderator username snapshot. |
|
||||||
|
| `reason` | `text` | Reason for action. |
|
||||||
|
| `metadata` | `jsonb` | Extra data. |
|
||||||
|
| `active` | `boolean` | Is this case active? |
|
||||||
|
| `created_at` | `timestamp` | Creation time. |
|
||||||
|
| `resolved_at` | `timestamp` | Resolution/Expiration time. |
|
||||||
|
| `resolved_by` | `bigint` | User ID who resolved it. |
|
||||||
|
| `resolved_reason` | `text` | Reason for resolution. |
|
||||||
127
docs/LOOTBOX_GUIDE.md
Normal file
127
docs/LOOTBOX_GUIDE.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# Lootbox Creation Guide
|
||||||
|
|
||||||
|
Currently, the Item Wizard does not support creating **Lootbox** items directly. Instead, they must be inserted manually into the database. This guide details the required JSON structure for the `LOOTBOX` effect.
|
||||||
|
|
||||||
|
## Item Structure
|
||||||
|
|
||||||
|
To create a lootbox, you need to insert a row into the `items` table. The critical part is the `usageData` JSON column.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"consume": true,
|
||||||
|
"effects": [
|
||||||
|
{
|
||||||
|
"type": "LOOTBOX",
|
||||||
|
"pool": [ ... ]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Loot Table Structure
|
||||||
|
|
||||||
|
The `pool` property is an array of `LootTableItem` objects. A random item is selected based on the total `weight` of all items in the pool.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `type` | `string` | One of: `CURRENCY`, `ITEM`, `XP`, `NOTHING`. |
|
||||||
|
| `weight` | `number` | The relative probability weight of this outcome. |
|
||||||
|
| `message` | `string` | (Optional) Custom message to display when this outcome is selected. |
|
||||||
|
|
||||||
|
### Outcome Types
|
||||||
|
|
||||||
|
#### 1. Currency
|
||||||
|
Gives the user coins.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "CURRENCY",
|
||||||
|
"weight": 50,
|
||||||
|
"amount": 100, // Fixed amount OR
|
||||||
|
"minAmount": 50, // Minimum random amount
|
||||||
|
"maxAmount": 150 // Maximum random amount
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. XP
|
||||||
|
Gives the user Experience Points.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "XP",
|
||||||
|
"weight": 30,
|
||||||
|
"amount": 500 // Fixed amount OR range (minAmount/maxAmount)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Item
|
||||||
|
Gives the user another item (by ID).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "ITEM",
|
||||||
|
"weight": 10,
|
||||||
|
"itemId": 42, // The ID of the item to give
|
||||||
|
"amount": 1 // (Optional) Quantity to give, default 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Nothing
|
||||||
|
An empty roll.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "NOTHING",
|
||||||
|
"weight": 10,
|
||||||
|
"message": "The box was empty! Better luck next time."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
Here is a full SQL insert example (using a hypothetical SQL client or Drizzle studio) for a "Basic Lootbox":
|
||||||
|
|
||||||
|
**Name**: Basic Lootbox
|
||||||
|
**Type**: CONSUMABLE
|
||||||
|
**Effect**:
|
||||||
|
- 50% chance for 100-200 Coins
|
||||||
|
- 30% chance for 500 XP
|
||||||
|
- 10% chance for Item ID 5 (e.g. Rare Gem)
|
||||||
|
- 10% chance for Nothing
|
||||||
|
|
||||||
|
**JSON for `usageData`**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"consume": true,
|
||||||
|
"effects": [
|
||||||
|
{
|
||||||
|
"type": "LOOTBOX",
|
||||||
|
"pool": [
|
||||||
|
{
|
||||||
|
"type": "CURRENCY",
|
||||||
|
"weight": 50,
|
||||||
|
"minAmount": 100,
|
||||||
|
"maxAmount": 200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "XP",
|
||||||
|
"weight": 30,
|
||||||
|
"amount": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "ITEM",
|
||||||
|
"weight": 10,
|
||||||
|
"itemId": 5,
|
||||||
|
"amount": 1,
|
||||||
|
"message": "Startstruck! You found a Rare Gem!"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "NOTHING",
|
||||||
|
"weight": 10,
|
||||||
|
"message": "It's empty..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
72
docs/MODULE_STRUCTURE.md
Normal file
72
docs/MODULE_STRUCTURE.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Aurora Module Structure Guide
|
||||||
|
|
||||||
|
This guide documents the standard module organization patterns used in the Aurora codebase. Following these patterns ensures consistency, maintainability, and clear separation of concerns.
|
||||||
|
|
||||||
|
## Module Anatomy
|
||||||
|
|
||||||
|
A typical module in `@modules/` is organized into several files, each with a specific responsibility.
|
||||||
|
|
||||||
|
Example: `trade` module
|
||||||
|
- `trade.service.ts`: Business logic and data access.
|
||||||
|
- `trade.view.ts`: Discord UI components (embeds, modals, select menus).
|
||||||
|
- `trade.interaction.ts`: Handler for interaction events (buttons, modals, etc.).
|
||||||
|
- `trade.types.ts`: TypeScript interfaces and types.
|
||||||
|
- `trade.service.test.ts`: Unit tests for the service logic.
|
||||||
|
|
||||||
|
## File Responsibilities
|
||||||
|
|
||||||
|
### 1. Service (`*.service.ts`)
|
||||||
|
The core of the module. It contains the business logic, database interactions (using Drizzle), and state management.
|
||||||
|
- **Rules**:
|
||||||
|
- Export a singleton instance: `export const tradeService = new TradeService();`
|
||||||
|
- Should not contain Discord-specific rendering logic (return data, not embeds).
|
||||||
|
- Throw `UserError` for validation issues that should be shown to the user.
|
||||||
|
|
||||||
|
### 2. View (`*.view.ts`)
|
||||||
|
Handles the creation of Discord-specific UI elements like `EmbedBuilder`, `ActionRowBuilder`, and `ModalBuilder`.
|
||||||
|
- **Rules**:
|
||||||
|
- Focus on formatting and presentation.
|
||||||
|
- Takes raw data (from services) and returns Discord components.
|
||||||
|
|
||||||
|
### 3. Interaction Handler (`*.interaction.ts`)
|
||||||
|
The entry point for Discord component interactions (buttons, select menus, modals).
|
||||||
|
- **Rules**:
|
||||||
|
- Export a single handler function: `export async function handleTradeInteraction(interaction: Interaction) { ... }`
|
||||||
|
- Routes internal `customId` patterns to specific logic.
|
||||||
|
- Relies on `ComponentInteractionHandler` for centralized error handling.
|
||||||
|
- **No local try-catch** for standard validation errors; let them bubble up as `UserError`.
|
||||||
|
|
||||||
|
### 4. Types (`*.types.ts`)
|
||||||
|
Central location for module-specific TypeScript types and constants.
|
||||||
|
- **Rules**:
|
||||||
|
- Define interfaces for complex data structures.
|
||||||
|
- Use enums or literal types for states and custom IDs.
|
||||||
|
|
||||||
|
## Interaction Routing
|
||||||
|
|
||||||
|
All interaction handlers must be registered in `src/lib/interaction.routes.ts`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
predicate: (i) => i.customId.startsWith("module_"),
|
||||||
|
handler: () => import("@/modules/module/module.interaction"),
|
||||||
|
method: 'handleModuleInteraction'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling Standards
|
||||||
|
|
||||||
|
Aurora uses a centralized error handling pattern in `ComponentInteractionHandler`.
|
||||||
|
|
||||||
|
1. **UserError**: Use this for validation errors or issues the user can fix (e.g., "Insufficient funds").
|
||||||
|
- `throw new UserError("You need more coins!");`
|
||||||
|
2. **SystemError / Generic Error**: Use this for unexpected system failures.
|
||||||
|
- These are logged to the console/logger and show a generic "Unexpected error" message to the user.
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
- **Directory Name**: Lowercase, singular (e.g., `trade`, `inventory`).
|
||||||
|
- **File Names**: `moduleName.type.ts` (e.g., `trade.service.ts`).
|
||||||
|
- **Class Names**: PascalCase (e.g., `TradeService`).
|
||||||
|
- **Service Instances**: camelCase (e.g., `tradeService`).
|
||||||
|
- **Interaction Method**: `handle[ModuleName]Interaction`.
|
||||||
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,
|
"when": 1766606046050,
|
||||||
"tag": "0001_heavy_thundra",
|
"tag": "0001_heavy_thundra",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1767716705797,
|
||||||
|
"tag": "0002_fancy_forge",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"drizzle-kit": "^0.31.7",
|
"drizzle-kit": "^0.31.7"
|
||||||
"postgres": "^3.4.7"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
@@ -26,6 +25,7 @@
|
|||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
|
"postgres": "^3.4.7",
|
||||||
"zod": "^4.1.13"
|
"zod": "^4.1.13"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BIN
src/assets/graphics/lootdrop/template.png
Normal file
BIN
src/assets/graphics/lootdrop/template.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
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 { createCommand } from "@/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@/modules/moderation/moderation.service";
|
import { ModerationService } from "@/modules/moderation/moderation.service";
|
||||||
|
import { CaseType } from "@/lib/constants";
|
||||||
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
export const note = createCommand({
|
export const note = createCommand({
|
||||||
@@ -31,7 +32,7 @@ export const note = createCommand({
|
|||||||
|
|
||||||
// Create the note case
|
// Create the note case
|
||||||
const moderationCase = await ModerationService.createCase({
|
const moderationCase = await ModerationService.createCase({
|
||||||
type: 'note',
|
type: CaseType.NOTE,
|
||||||
userId: targetUser.id,
|
userId: targetUser.id,
|
||||||
username: targetUser.username,
|
username: targetUser.username,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
|
|||||||
@@ -50,65 +50,25 @@ export const warn = createCommand({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the warning case
|
// Issue the warning via service
|
||||||
const moderationCase = await ModerationService.createCase({
|
const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({
|
||||||
type: 'warn',
|
|
||||||
userId: targetUser.id,
|
userId: targetUser.id,
|
||||||
username: targetUser.username,
|
username: targetUser.username,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
moderatorName: interaction.user.username,
|
moderatorName: interaction.user.username,
|
||||||
reason,
|
reason,
|
||||||
|
guildName: interaction.guild?.name || undefined,
|
||||||
|
dmTarget: targetUser,
|
||||||
|
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!moderationCase) {
|
|
||||||
await interaction.editReply({
|
|
||||||
embeds: [getModerationErrorEmbed("Failed to create warning case.")]
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get total warning count for the user
|
|
||||||
const warningCount = await ModerationService.getActiveWarningCount(targetUser.id);
|
|
||||||
|
|
||||||
// Send success message to moderator
|
// Send success message to moderator
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
|
embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Try to DM the user if configured
|
// Follow up if auto-timeout was issued
|
||||||
if (config.moderation.cases.dmOnWarn) {
|
if (autoTimeoutIssued) {
|
||||||
try {
|
|
||||||
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({
|
await interaction.followUp({
|
||||||
embeds: [getModerationErrorEmbed(
|
embeds: [getModerationErrorEmbed(
|
||||||
`⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.`
|
`⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.`
|
||||||
@@ -116,10 +76,6 @@ export const warn = createCommand({
|
|||||||
flags: MessageFlags.Ephemeral
|
flags: MessageFlags.Ephemeral
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to auto-timeout user:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Warn command error:", error);
|
console.error("Warn command error:", error);
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export const balance = createCommand({
|
|||||||
|
|
||||||
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
||||||
|
|
||||||
|
if (!user) throw new Error("Failed to retrieve user data.");
|
||||||
|
|
||||||
const embed = createBaseEmbed(undefined, `**Balance**: ${user.balance || 0n} AU`, "Yellow")
|
const embed = createBaseEmbed(undefined, `**Balance**: ${user.balance || 0n} AU`, "Yellow")
|
||||||
.setAuthor({ name: targetUser.username, iconURL: targetUser.displayAvatarURL() });
|
.setAuthor({ name: targetUser.username, iconURL: targetUser.displayAvatarURL() });
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import { userTimers, users } from "@/db/schema";
|
|||||||
import { eq, and, sql } from "drizzle-orm";
|
import { eq, and, sql } from "drizzle-orm";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||||
import { config } from "@lib/config";
|
import { config } from "@lib/config";
|
||||||
|
import { TimerType } from "@/lib/constants";
|
||||||
|
|
||||||
const EXAM_TIMER_TYPE = 'EXAM_SYSTEM';
|
const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
|
||||||
const EXAM_TIMER_KEY = 'default';
|
const EXAM_TIMER_KEY = 'default';
|
||||||
|
|
||||||
interface ExamMetadata {
|
interface ExamMetadata {
|
||||||
@@ -25,6 +26,10 @@ export const exam = createCommand({
|
|||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply();
|
await interaction.deferReply();
|
||||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||||
|
if (!user) {
|
||||||
|
await interaction.editReply({ embeds: [createErrorEmbed("Failed to retrieve user data.")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const currentDay = now.getDay();
|
const currentDay = now.getDay();
|
||||||
|
|
||||||
@@ -43,11 +48,12 @@ export const exam = createCommand({
|
|||||||
// Set exam day to today
|
// Set exam day to today
|
||||||
const nextExamDate = new Date(now);
|
const nextExamDate = new Date(now);
|
||||||
nextExamDate.setDate(now.getDate() + 7);
|
nextExamDate.setDate(now.getDate() + 7);
|
||||||
|
nextExamDate.setHours(0, 0, 0, 0);
|
||||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||||
|
|
||||||
const metadata: ExamMetadata = {
|
const metadata: ExamMetadata = {
|
||||||
examDay: currentDay,
|
examDay: currentDay,
|
||||||
lastXp: user.xp.toString()
|
lastXp: (user.xp ?? 0n).toString()
|
||||||
};
|
};
|
||||||
|
|
||||||
await DrizzleClient.insert(userTimers).values({
|
await DrizzleClient.insert(userTimers).values({
|
||||||
@@ -61,7 +67,7 @@ export const exam = createCommand({
|
|||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [createSuccessEmbed(
|
embeds: [createSuccessEmbed(
|
||||||
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}** (Server Time).\n` +
|
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}** (Server Time).\n` +
|
||||||
`Come back on <t:${nextExamTimestamp}:F> (<t:${nextExamTimestamp}:R>) to take your first exam!`,
|
`Come back on <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>) to take your first exam!`,
|
||||||
"Exam Registration Successful"
|
"Exam Registration Successful"
|
||||||
)]
|
)]
|
||||||
});
|
});
|
||||||
@@ -72,15 +78,17 @@ export const exam = createCommand({
|
|||||||
const examDay = metadata.examDay;
|
const examDay = metadata.examDay;
|
||||||
|
|
||||||
// 3. Cooldown Check
|
// 3. Cooldown Check
|
||||||
if (now < new Date(timer.expiresAt)) {
|
|
||||||
// Calculate time remaining
|
|
||||||
const expiresAt = new Date(timer.expiresAt);
|
const expiresAt = new Date(timer.expiresAt);
|
||||||
|
expiresAt.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
if (now < expiresAt) {
|
||||||
|
// Calculate time remaining
|
||||||
const timestamp = Math.floor(expiresAt.getTime() / 1000);
|
const timestamp = Math.floor(expiresAt.getTime() / 1000);
|
||||||
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [createErrorEmbed(
|
embeds: [createErrorEmbed(
|
||||||
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
||||||
`Next exam available: <t:${timestamp}:F> (<t:${timestamp}:R>)`
|
`Next exam available: <t:${timestamp}:D> (<t:${timestamp}:R>)`
|
||||||
)]
|
)]
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -94,11 +102,12 @@ export const exam = createCommand({
|
|||||||
|
|
||||||
const nextExamDate = new Date(now);
|
const nextExamDate = new Date(now);
|
||||||
nextExamDate.setDate(now.getDate() + daysUntil);
|
nextExamDate.setDate(now.getDate() + daysUntil);
|
||||||
|
nextExamDate.setHours(0, 0, 0, 0);
|
||||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||||
|
|
||||||
const newMetadata: ExamMetadata = {
|
const newMetadata: ExamMetadata = {
|
||||||
examDay: examDay,
|
examDay: examDay,
|
||||||
lastXp: user.xp.toString() // Reset tracking
|
lastXp: (user.xp ?? 0n).toString()
|
||||||
};
|
};
|
||||||
|
|
||||||
await DrizzleClient.update(userTimers)
|
await DrizzleClient.update(userTimers)
|
||||||
@@ -116,7 +125,7 @@ export const exam = createCommand({
|
|||||||
embeds: [createErrorEmbed(
|
embeds: [createErrorEmbed(
|
||||||
`You missed your exam day! Your exam day is **${DAYS[examDay]}** (Server Time).\n` +
|
`You missed your exam day! Your exam day is **${DAYS[examDay]}** (Server Time).\n` +
|
||||||
`You verify your attendance but score a **0**.\n` +
|
`You verify your attendance but score a **0**.\n` +
|
||||||
`Your next exam opportunity is: <t:${nextExamTimestamp}:F> (<t:${nextExamTimestamp}:R>)`,
|
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
|
||||||
"Exam Failed"
|
"Exam Failed"
|
||||||
)]
|
)]
|
||||||
});
|
});
|
||||||
@@ -125,7 +134,7 @@ export const exam = createCommand({
|
|||||||
|
|
||||||
// 5. Reward Calculation
|
// 5. Reward Calculation
|
||||||
const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case
|
const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case
|
||||||
const currentXp = user.xp;
|
const currentXp = user.xp ?? 0n;
|
||||||
const diff = currentXp - lastXp;
|
const diff = currentXp - lastXp;
|
||||||
|
|
||||||
// Calculate Reward
|
// Calculate Reward
|
||||||
@@ -143,6 +152,7 @@ export const exam = createCommand({
|
|||||||
// 6. Update State
|
// 6. Update State
|
||||||
const nextExamDate = new Date(now);
|
const nextExamDate = new Date(now);
|
||||||
nextExamDate.setDate(now.getDate() + 7);
|
nextExamDate.setDate(now.getDate() + 7);
|
||||||
|
nextExamDate.setHours(0, 0, 0, 0);
|
||||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||||
|
|
||||||
const newMetadata: ExamMetadata = {
|
const newMetadata: ExamMetadata = {
|
||||||
@@ -178,7 +188,7 @@ export const exam = createCommand({
|
|||||||
`**XP Gained:** ${diff.toString()}\n` +
|
`**XP Gained:** ${diff.toString()}\n` +
|
||||||
`**Multiplier:** x${multiplier.toFixed(2)}\n` +
|
`**Multiplier:** x${multiplier.toFixed(2)}\n` +
|
||||||
`**Reward:** ${reward.toString()} Currency\n\n` +
|
`**Reward:** ${reward.toString()} Currency\n\n` +
|
||||||
`See you next week: <t:${nextExamTimestamp}:F>`,
|
`See you next week: <t:${nextExamTimestamp}:D>`,
|
||||||
"Exam Passed!"
|
"Exam Passed!"
|
||||||
)]
|
)]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ export const pay = createCommand({
|
|||||||
|
|
||||||
const amount = BigInt(interaction.options.getInteger("amount", true));
|
const amount = BigInt(interaction.options.getInteger("amount", true));
|
||||||
const senderId = interaction.user.id;
|
const senderId = interaction.user.id;
|
||||||
|
if (!targetUser) {
|
||||||
|
await interaction.reply({ embeds: [createErrorEmbed("User not found.")], flags: MessageFlags.Ephemeral });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const receiverId = targetUser.id;
|
const receiverId = targetUser.id;
|
||||||
|
|
||||||
if (amount < config.economy.transfers.minAmount) {
|
if (amount < config.economy.transfers.minAmount) {
|
||||||
@@ -40,14 +45,14 @@ export const pay = createCommand({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (senderId === receiverId) {
|
if (senderId === receiverId.toString()) {
|
||||||
await interaction.reply({ embeds: [createErrorEmbed("You cannot pay yourself.")], flags: MessageFlags.Ephemeral });
|
await interaction.reply({ embeds: [createErrorEmbed("You cannot pay yourself.")], flags: MessageFlags.Ephemeral });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await interaction.deferReply();
|
await interaction.deferReply();
|
||||||
await economyService.transfer(senderId, receiverId, amount);
|
await economyService.transfer(senderId, receiverId.toString(), amount);
|
||||||
|
|
||||||
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
|
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
|
||||||
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
|
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ import { inventoryService } from "@/modules/inventory/inventory.service";
|
|||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@/modules/user/user.service";
|
||||||
import { createErrorEmbed } from "@lib/embeds";
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
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 type { ItemUsageData } from "@/lib/types";
|
||||||
import { UserError } from "@/lib/errors";
|
import { UserError } from "@/lib/errors";
|
||||||
import { config } from "@/lib/config";
|
import { config } from "@/lib/config";
|
||||||
@@ -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] });
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
|
||||||
@@ -75,28 +72,8 @@ export const use = createCommand({
|
|||||||
const focusedValue = interaction.options.getFocused();
|
const focusedValue = interaction.options.getFocused();
|
||||||
const userId = interaction.user.id;
|
const userId = interaction.user.id;
|
||||||
|
|
||||||
// Fetch owned items that match the search query
|
const results = await inventoryService.getAutocompleteItems(userId, focusedValue);
|
||||||
// We join with items table to filter by name directly in the database
|
|
||||||
const entries = await DrizzleClient.select({
|
|
||||||
quantity: inventory.quantity,
|
|
||||||
item: items
|
|
||||||
})
|
|
||||||
.from(inventory)
|
|
||||||
.innerJoin(items, eq(inventory.itemId, items.id))
|
|
||||||
.where(and(
|
|
||||||
eq(inventory.userId, BigInt(userId)),
|
|
||||||
like(items.name, `%${focusedValue}%`)
|
|
||||||
))
|
|
||||||
.limit(20); // Fetch up to 20 matching items
|
|
||||||
|
|
||||||
const filtered = entries.filter(entry => {
|
await interaction.respond(results);
|
||||||
const usageData = entry.item.usageData as ItemUsageData | null;
|
|
||||||
const isUsable = usageData && usageData.effects && usageData.effects.length > 0;
|
|
||||||
return isUsable;
|
|
||||||
});
|
|
||||||
|
|
||||||
await interaction.respond(
|
|
||||||
filtered.map(entry => ({ name: `${entry.item.name} (${entry.quantity})`, value: entry.item.id }))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||||
import { users } from "@/db/schema";
|
import { users, items, inventory } from "@/db/schema";
|
||||||
import { desc } from "drizzle-orm";
|
import { desc, sql, eq } from "drizzle-orm";
|
||||||
import { createWarningEmbed } from "@lib/embeds";
|
import { createWarningEmbed } from "@lib/embeds";
|
||||||
import { getLeaderboardEmbed } from "@/modules/leveling/leveling.view";
|
import { getLeaderboardEmbed } from "@/modules/leveling/leveling.view";
|
||||||
|
|
||||||
@@ -12,30 +12,49 @@ export const leaderboard = createCommand({
|
|||||||
.setDescription("View the top players")
|
.setDescription("View the top players")
|
||||||
.addStringOption(option =>
|
.addStringOption(option =>
|
||||||
option.setName("type")
|
option.setName("type")
|
||||||
.setDescription("Sort by XP or Balance")
|
.setDescription("Sort by XP, Balance, or Net Worth")
|
||||||
.setRequired(true)
|
.setRequired(true)
|
||||||
.addChoices(
|
.addChoices(
|
||||||
{ name: "Level / XP", value: "xp" },
|
{ name: "Level / XP", value: "xp" },
|
||||||
{ name: "Balance", value: "balance" }
|
{ name: "Balance", value: "balance" },
|
||||||
|
{ name: "Net Worth", value: "networth" }
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply();
|
await interaction.deferReply();
|
||||||
|
|
||||||
const type = interaction.options.getString("type", true);
|
const type = interaction.options.getString("type", true);
|
||||||
const isXp = type === "xp";
|
|
||||||
|
|
||||||
const leaders = await DrizzleClient.query.users.findMany({
|
let leaders;
|
||||||
|
|
||||||
|
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),
|
orderBy: isXp ? desc(users.xp) : desc(users.balance),
|
||||||
limit: 10
|
limit: 10
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (leaders.length === 0) {
|
if (leaders.length === 0) {
|
||||||
await interaction.editReply({ embeds: [createWarningEmbed("No users found.", "Leaderboard")] });
|
await interaction.editReply({ embeds: [createWarningEmbed("No users found.", "Leaderboard")] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const embed = getLeaderboardEmbed(leaders, isXp ? 'xp' : 'balance');
|
const embed = getLeaderboardEmbed(leaders, type as 'xp' | 'balance' | 'networth');
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
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,
|
text,
|
||||||
integer,
|
integer,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
|
index,
|
||||||
bigserial,
|
bigserial,
|
||||||
check
|
check
|
||||||
} from 'drizzle-orm/pg-core';
|
} from 'drizzle-orm/pg-core';
|
||||||
@@ -41,7 +42,11 @@ export const users = pgTable('users', {
|
|||||||
settings: jsonb('settings').default({}),
|
settings: jsonb('settings').default({}),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
|
||||||
});
|
}, (table) => [
|
||||||
|
index('users_username_idx').on(table.username),
|
||||||
|
index('users_balance_idx').on(table.balance),
|
||||||
|
index('users_level_xp_idx').on(table.level, table.xp),
|
||||||
|
]);
|
||||||
|
|
||||||
// 3. Items
|
// 3. Items
|
||||||
export const items = pgTable('items', {
|
export const items = pgTable('items', {
|
||||||
@@ -82,7 +87,9 @@ export const transactions = pgTable('transactions', {
|
|||||||
type: varchar('type', { length: 50 }).notNull(),
|
type: varchar('type', { length: 50 }).notNull(),
|
||||||
description: text('description'),
|
description: text('description'),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||||
});
|
}, (table) => [
|
||||||
|
index('transactions_created_at_idx').on(table.createdAt),
|
||||||
|
]);
|
||||||
|
|
||||||
export const itemTransactions = pgTable('item_transactions', {
|
export const itemTransactions = pgTable('item_transactions', {
|
||||||
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
|
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
|
||||||
@@ -129,7 +136,9 @@ export const userTimers = pgTable('user_timers', {
|
|||||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||||
metadata: jsonb('metadata').default({}), // Store channelId, specific buff amounts, etc.
|
metadata: jsonb('metadata').default({}), // Store channelId, specific buff amounts, etc.
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
primaryKey({ columns: [table.userId, table.type, table.key] })
|
primaryKey({ columns: [table.userId, table.type, table.key] }),
|
||||||
|
index('user_timers_expires_at_idx').on(table.expiresAt),
|
||||||
|
index('user_timers_lookup_idx').on(table.userId, table.type, table.key),
|
||||||
]);
|
]);
|
||||||
// 9. Lootdrops
|
// 9. Lootdrops
|
||||||
export const lootdrops = pgTable('lootdrops', {
|
export const lootdrops = pgTable('lootdrops', {
|
||||||
@@ -158,7 +167,10 @@ export const moderationCases = pgTable('moderation_cases', {
|
|||||||
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
|
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
|
||||||
resolvedBy: bigint('resolved_by', { mode: 'bigint' }),
|
resolvedBy: bigint('resolved_by', { mode: 'bigint' }),
|
||||||
resolvedReason: text('resolved_reason'),
|
resolvedReason: text('resolved_reason'),
|
||||||
});
|
}, (table) => [
|
||||||
|
index('moderation_cases_user_id_idx').on(table.userId),
|
||||||
|
index('moderation_cases_case_id_idx').on(table.caseId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
135
src/graphics/lootdrop.ts
Normal file
135
src/graphics/lootdrop.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// Register Fonts (same as studentID.ts)
|
||||||
|
const fontDir = path.join(process.cwd(), 'src', 'assets', 'fonts');
|
||||||
|
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold');
|
||||||
|
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
|
||||||
|
|
||||||
|
export async function generateLootdropCard(amount: number, currency: string): Promise<Buffer> {
|
||||||
|
const templatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'lootdrop', 'template.png');
|
||||||
|
const template = await loadImage(templatePath);
|
||||||
|
|
||||||
|
const canvas = createCanvas(template.width, template.height);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Draw Template
|
||||||
|
ctx.drawImage(template, 0, 0);
|
||||||
|
|
||||||
|
// Draw Lootdrop Text (Title-ish)
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '48px IBMPlexSansCondensed-SemiBold';
|
||||||
|
ctx.fillStyle = '#FFFFFF';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.shadowBlur = 10;
|
||||||
|
ctx.shadowColor = 'rgba(255, 255, 255, 0.5)';
|
||||||
|
// Center of lower half (512-1024) is roughly 768
|
||||||
|
ctx.fillText('A STAR IS FALLING', canvas.width / 2, 660);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Draw Reward Amount
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '72px IBMPlexMono-Bold';
|
||||||
|
ctx.fillStyle = '#DAC7A1';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
//ctx.shadowBlur = 15;
|
||||||
|
//ctx.shadowColor = 'rgba(255, 215, 0, 0.8)';
|
||||||
|
ctx.fillText(`${amount} ${currency}`, canvas.width / 2, 760); // Below title
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Crop the image by 64px on all sides
|
||||||
|
const croppedWidth = template.width - 128;
|
||||||
|
const croppedHeight = template.height - 128;
|
||||||
|
const croppedCanvas = createCanvas(croppedWidth, croppedHeight);
|
||||||
|
const croppedCtx = croppedCanvas.getContext('2d');
|
||||||
|
|
||||||
|
// Draw the original canvas onto the cropped canvas, shifted by -64
|
||||||
|
croppedCtx.drawImage(canvas, -64, -64);
|
||||||
|
|
||||||
|
return croppedCanvas.toBuffer('image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateClaimedLootdropCard(amount: number, currency: string, username: string, avatarUrl: string): Promise<Buffer> {
|
||||||
|
const templatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'lootdrop', 'template.png');
|
||||||
|
const template = await loadImage(templatePath);
|
||||||
|
|
||||||
|
const canvas = createCanvas(template.width, template.height);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Draw Template
|
||||||
|
ctx.drawImage(template, 0, 0);
|
||||||
|
|
||||||
|
// Add a colored overlay to signify "claimed"
|
||||||
|
ctx.fillStyle = 'rgba(10, 10, 20, 0.85)';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// Draw Claimed Text (Title-ish)
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '48px IBMPlexSansCondensed-SemiBold';
|
||||||
|
ctx.fillStyle = '#FFFFFF';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.shadowBlur = 10;
|
||||||
|
ctx.shadowColor = 'rgba(255, 255, 255, 0.5)';
|
||||||
|
ctx.fillText('STAR CLAIMED', canvas.width / 2, 660);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Draw "by username" with Avatar
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '36px IBMPlexSansCondensed-SemiBold';
|
||||||
|
ctx.fillStyle = '#AAAAAA';
|
||||||
|
|
||||||
|
// Calculate layout for centering Group (Avatar + Text)
|
||||||
|
const text = `by ${username}`;
|
||||||
|
const textMetrics = ctx.measureText(text);
|
||||||
|
const textWidth = textMetrics.width;
|
||||||
|
const avatarSize = 50;
|
||||||
|
const gap = 15;
|
||||||
|
const totalWidth = avatarSize + gap + textWidth;
|
||||||
|
|
||||||
|
const startX = (canvas.width - totalWidth) / 2;
|
||||||
|
const baselineY = 830;
|
||||||
|
|
||||||
|
// Draw Avatar
|
||||||
|
try {
|
||||||
|
const avatar = await loadImage(avatarUrl);
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
// Center avatar vertically relative to text roughly (baseline - ~half cap height)
|
||||||
|
// 36px text ~ 27px cap height. Center roughly at baselineY - 14
|
||||||
|
const avatarCenterY = baselineY - 14;
|
||||||
|
ctx.arc(startX + avatarSize / 2, avatarCenterY, avatarSize / 2, 0, Math.PI * 2);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.clip();
|
||||||
|
ctx.drawImage(avatar, startX, avatarCenterY - avatarSize / 2, avatarSize, avatarSize);
|
||||||
|
ctx.restore();
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback if avatar fails to load, just don't draw it (or maybe shift text?)
|
||||||
|
// For now, let's just proceed, the text will be off-center if avatar is missing but that's acceptable edge case
|
||||||
|
console.error("Failed to load avatar", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw Text
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(text, startX + avatarSize + gap, baselineY);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '72px IBMPlexMono-Bold'; // Match Amount size
|
||||||
|
ctx.fillStyle = '#E6D2B5'; // Lighter gold/beige for better contrast
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.shadowBlur = 10;
|
||||||
|
ctx.shadowColor = 'rgba(0, 0, 0, 0.8)'; // Dark shadow for contrast
|
||||||
|
ctx.fillText(`${amount} ${currency}`, canvas.width / 2, 760); // Same position as Unclaimed Amount
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Crop the image by 64px on all sides
|
||||||
|
const croppedWidth = template.width - 128;
|
||||||
|
const croppedHeight = template.height - 128;
|
||||||
|
const croppedCanvas = createCanvas(croppedWidth, croppedHeight);
|
||||||
|
const croppedCtx = croppedCanvas.getContext('2d');
|
||||||
|
|
||||||
|
// Draw the original canvas onto the cropped canvas, shifted by -64
|
||||||
|
croppedCtx.drawImage(canvas, -64, -64);
|
||||||
|
|
||||||
|
return croppedCanvas.toBuffer('image/png');
|
||||||
|
}
|
||||||
12
src/index.ts
12
src/index.ts
@@ -1,14 +1,26 @@
|
|||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { env } from "@lib/env";
|
import { env } from "@lib/env";
|
||||||
|
|
||||||
|
import { WebServer } from "@/web/server";
|
||||||
|
|
||||||
// Load commands & events
|
// Load commands & events
|
||||||
await AuroraClient.loadCommands();
|
await AuroraClient.loadCommands();
|
||||||
await AuroraClient.loadEvents();
|
await AuroraClient.loadEvents();
|
||||||
await AuroraClient.deployCommands();
|
await AuroraClient.deployCommands();
|
||||||
|
|
||||||
|
WebServer.start();
|
||||||
|
|
||||||
// login with the token from .env
|
// login with the token from .env
|
||||||
if (!env.DISCORD_BOT_TOKEN) {
|
if (!env.DISCORD_BOT_TOKEN) {
|
||||||
throw new Error("❌ DISCORD_BOT_TOKEN is not set in environment variables.");
|
throw new Error("❌ DISCORD_BOT_TOKEN is not set in environment variables.");
|
||||||
}
|
}
|
||||||
AuroraClient.login(env.DISCORD_BOT_TOKEN);
|
AuroraClient.login(env.DISCORD_BOT_TOKEN);
|
||||||
|
|
||||||
|
// Handle graceful shutdown
|
||||||
|
const shutdownHandler = () => {
|
||||||
|
WebServer.stop();
|
||||||
|
AuroraClient.shutdown();
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on("SIGINT", shutdownHandler);
|
||||||
|
process.on("SIGTERM", shutdownHandler);
|
||||||
@@ -9,6 +9,7 @@ import { logger } from "@lib/logger";
|
|||||||
export class Client extends DiscordClient {
|
export class Client extends DiscordClient {
|
||||||
|
|
||||||
commands: Collection<string, Command>;
|
commands: Collection<string, Command>;
|
||||||
|
lastCommandTimestamp: number | null = null;
|
||||||
private commandLoader: CommandLoader;
|
private commandLoader: CommandLoader;
|
||||||
private eventLoader: EventLoader;
|
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] });
|
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";
|
import { env } from "@lib/env";
|
||||||
|
|
||||||
const connectionString = env.DATABASE_URL;
|
const connectionString = env.DATABASE_URL;
|
||||||
const postgres = new SQL(connectionString);
|
export const postgres = new SQL(connectionString);
|
||||||
|
|
||||||
export const DrizzleClient = drizzle(postgres, { schema });
|
export const DrizzleClient = drizzle(postgres, { schema });
|
||||||
|
|
||||||
|
export const closeDatabase = async () => {
|
||||||
|
await postgres.close();
|
||||||
|
};
|
||||||
@@ -69,6 +69,7 @@ export interface GameConfigType {
|
|||||||
autoTimeoutThreshold?: number;
|
autoTimeoutThreshold?: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
system: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial default config state
|
// Initial default config state
|
||||||
@@ -160,7 +161,8 @@ const configSchema = z.object({
|
|||||||
cases: {
|
cases: {
|
||||||
dmOnWarn: true
|
dmOnWarn: true
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
|
system: z.record(z.string(), z.any()).default({}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function reloadConfig() {
|
export function reloadConfig() {
|
||||||
|
|||||||
65
src/lib/constants.ts
Normal file
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 { DrizzleClient } from "./DrizzleClient";
|
||||||
import type { Transaction } from "./types";
|
import type { Transaction } from "./types";
|
||||||
|
import { isShuttingDown, incrementTransactions, decrementTransactions } from "./shutdown";
|
||||||
|
|
||||||
export const withTransaction = async <T>(
|
export const withTransaction = async <T>(
|
||||||
callback: (tx: Transaction) => Promise<T>,
|
callback: (tx: Transaction) => Promise<T>,
|
||||||
@@ -8,8 +9,17 @@ export const withTransaction = async <T>(
|
|||||||
if (tx) {
|
if (tx) {
|
||||||
return await callback(tx);
|
return await callback(tx);
|
||||||
} else {
|
} else {
|
||||||
|
if (isShuttingDown()) {
|
||||||
|
throw new Error("System is shutting down, no new transactions allowed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
incrementTransactions();
|
||||||
|
try {
|
||||||
return await DrizzleClient.transaction(async (newTx) => {
|
return await DrizzleClient.transaction(async (newTx) => {
|
||||||
return await callback(newTx);
|
return await callback(newTx);
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
decrementTransactions();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const envSchema = z.object({
|
|||||||
DISCORD_CLIENT_ID: z.string().optional(),
|
DISCORD_CLIENT_ID: z.string().optional(),
|
||||||
DISCORD_GUILD_ID: z.string().optional(),
|
DISCORD_GUILD_ID: z.string().optional(),
|
||||||
DATABASE_URL: z.string().min(1, "Database URL is required"),
|
DATABASE_URL: z.string().min(1, "Database URL is required"),
|
||||||
|
PORT: z.coerce.number().default(3000),
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsedEnv = envSchema.safeParse(process.env);
|
const parsedEnv = envSchema.safeParse(process.env);
|
||||||
|
|||||||
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 {
|
try {
|
||||||
await command.execute(interaction);
|
await command.execute(interaction);
|
||||||
|
AuroraClient.lastCommandTimestamp = Date.now();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(String(error));
|
logger.error(String(error));
|
||||||
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
|
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction } from "discord.js";
|
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js";
|
||||||
import { logger } from "@lib/logger";
|
import { logger } from "@lib/logger";
|
||||||
|
import { UserError } from "@lib/errors";
|
||||||
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles component interactions (buttons, select menus, modals)
|
* Handles component interactions (buttons, select menus, modals)
|
||||||
* Routes to appropriate handlers based on customId patterns
|
* Routes to appropriate handlers based on customId patterns
|
||||||
|
* Provides centralized error handling with UserError differentiation
|
||||||
*/
|
*/
|
||||||
export class ComponentInteractionHandler {
|
export class ComponentInteractionHandler {
|
||||||
static async handle(interaction: ComponentInteraction): Promise<void> {
|
static async handle(interaction: ComponentInteraction): Promise<void> {
|
||||||
@@ -17,12 +20,59 @@ export class ComponentInteractionHandler {
|
|||||||
const handlerMethod = module[route.method];
|
const handlerMethod = module[route.method];
|
||||||
|
|
||||||
if (typeof handlerMethod === 'function') {
|
if (typeof handlerMethod === 'function') {
|
||||||
|
try {
|
||||||
await handlerMethod(interaction);
|
await handlerMethod(interaction);
|
||||||
return;
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
await this.handleError(interaction, error, route.method);
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.error(`Handler method ${route.method} not found in module`);
|
logger.error(`Handler method ${route.method} not found in module`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles errors from interaction handlers
|
||||||
|
* Differentiates between UserError (user-facing) and system errors
|
||||||
|
*/
|
||||||
|
private static async handleError(
|
||||||
|
interaction: ComponentInteraction,
|
||||||
|
error: unknown,
|
||||||
|
handlerName: string
|
||||||
|
): Promise<void> {
|
||||||
|
const isUserError = error instanceof UserError;
|
||||||
|
|
||||||
|
// Determine error message
|
||||||
|
const errorMessage = isUserError
|
||||||
|
? (error as Error).message
|
||||||
|
: 'An unexpected error occurred. Please try again later.';
|
||||||
|
|
||||||
|
// Log system errors (non-user errors) for debugging
|
||||||
|
if (!isUserError) {
|
||||||
|
logger.error(`Error in ${handlerName}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorEmbed = createErrorEmbed(errorMessage);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Handle different interaction states
|
||||||
|
if (interaction.replied || interaction.deferred) {
|
||||||
|
await interaction.followUp({
|
||||||
|
embeds: [errorEmbed],
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await interaction.reply({
|
||||||
|
embeds: [errorEmbed],
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (replyError) {
|
||||||
|
// If we can't send a reply, log it
|
||||||
|
logger.error(`Failed to send error response in ${handlerName}:`, replyError);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,14 @@ interface InteractionRoute {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const interactionRoutes: InteractionRoute[] = [
|
export const interactionRoutes: InteractionRoute[] = [
|
||||||
|
// --- TRADE MODULE ---
|
||||||
{
|
{
|
||||||
predicate: (i) => i.customId.startsWith("trade_") || i.customId === "amount",
|
predicate: (i) => i.customId.startsWith("trade_") || i.customId === "amount",
|
||||||
handler: () => import("@/modules/trade/trade.interaction"),
|
handler: () => import("@/modules/trade/trade.interaction"),
|
||||||
method: 'handleTradeInteraction'
|
method: 'handleTradeInteraction'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- ECONOMY MODULE ---
|
||||||
{
|
{
|
||||||
predicate: (i) => i.isButton() && i.customId.startsWith("shop_buy_"),
|
predicate: (i) => i.isButton() && i.customId.startsWith("shop_buy_"),
|
||||||
handler: () => import("@/modules/economy/shop.interaction"),
|
handler: () => import("@/modules/economy/shop.interaction"),
|
||||||
@@ -34,16 +37,22 @@ export const interactionRoutes: InteractionRoute[] = [
|
|||||||
handler: () => import("@/modules/economy/lootdrop.interaction"),
|
handler: () => import("@/modules/economy/lootdrop.interaction"),
|
||||||
method: 'handleLootdropInteraction'
|
method: 'handleLootdropInteraction'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- ADMIN MODULE ---
|
||||||
{
|
{
|
||||||
predicate: (i) => i.customId.startsWith("createitem_"),
|
predicate: (i) => i.customId.startsWith("createitem_"),
|
||||||
handler: () => import("@/modules/admin/item_wizard"),
|
handler: () => import("@/modules/admin/item_wizard"),
|
||||||
method: 'handleItemWizardInteraction'
|
method: 'handleItemWizardInteraction'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- USER MODULE ---
|
||||||
{
|
{
|
||||||
predicate: (i) => i.isButton() && i.customId === "enrollment",
|
predicate: (i) => i.isButton() && i.customId === "enrollment",
|
||||||
handler: () => import("@/modules/user/enrollment.interaction"),
|
handler: () => import("@/modules/user/enrollment.interaction"),
|
||||||
method: 'handleEnrollmentInteraction'
|
method: 'handleEnrollmentInteraction'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- FEEDBACK MODULE ---
|
||||||
{
|
{
|
||||||
predicate: (i) => i.customId.startsWith("feedback_"),
|
predicate: (i) => i.customId.startsWith("feedback_"),
|
||||||
handler: () => import("@/modules/feedback/feedback.interaction"),
|
handler: () => import("@/modules/feedback/feedback.interaction"),
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export class CommandLoader {
|
|||||||
continue;
|
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);
|
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
|
* 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 = {
|
export const logger = {
|
||||||
/**
|
/**
|
||||||
* General information message
|
* General information message
|
||||||
*/
|
*/
|
||||||
info: (message: string, ...args: any[]) => {
|
info: (message: string, ...args: any[]) => {
|
||||||
console.log(`ℹ️ ${message}`, ...args);
|
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[]) => {
|
success: (message: string, ...args: any[]) => {
|
||||||
console.log(`✅ ${message}`, ...args);
|
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[]) => {
|
warn: (message: string, ...args: any[]) => {
|
||||||
console.warn(`⚠️ ${message}`, ...args);
|
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[]) => {
|
error: (message: string, ...args: any[]) => {
|
||||||
console.error(`❌ ${message}`, ...args);
|
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[]) => {
|
debug: (message: string, ...args: any[]) => {
|
||||||
console.log(`🔍 ${message}`, ...args);
|
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 type { AutocompleteInteraction, ChatInputCommandInteraction, ClientEvents, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder } from "discord.js";
|
||||||
|
import { LootType, EffectType } from "./constants";
|
||||||
|
import { DrizzleClient } from "./DrizzleClient";
|
||||||
|
|
||||||
export interface Command {
|
export interface Command {
|
||||||
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder;
|
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder;
|
||||||
@@ -14,19 +16,28 @@ export interface Event<K extends keyof ClientEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ItemEffect =
|
export type ItemEffect =
|
||||||
| { type: 'ADD_XP'; amount: number }
|
| { type: EffectType.ADD_XP; amount: number }
|
||||||
| { type: 'ADD_BALANCE'; amount: number }
|
| { type: EffectType.ADD_BALANCE; amount: number }
|
||||||
| { type: 'XP_BOOST'; multiplier: number; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
|
| { type: EffectType.XP_BOOST; multiplier: number; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
|
||||||
| { type: 'TEMP_ROLE'; roleId: string; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
|
| { type: EffectType.TEMP_ROLE; roleId: string; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
|
||||||
| { type: 'REPLY_MESSAGE'; message: string }
|
| { type: EffectType.REPLY_MESSAGE; message: string }
|
||||||
| { type: 'COLOR_ROLE'; roleId: string };
|
| { type: EffectType.COLOR_ROLE; roleId: string }
|
||||||
|
| { type: EffectType.LOOTBOX; pool: LootTableItem[] };
|
||||||
|
|
||||||
|
export interface LootTableItem {
|
||||||
|
type: LootType;
|
||||||
|
weight: number;
|
||||||
|
amount?: number; // For CURRENCY, XP
|
||||||
|
itemId?: number; // For ITEM
|
||||||
|
minAmount?: number; // Optional range for CURRENCY/XP
|
||||||
|
maxAmount?: number; // Optional range for CURRENCY/XP
|
||||||
|
message?: string; // Optional custom message for this outcome
|
||||||
|
}
|
||||||
|
|
||||||
export interface ItemUsageData {
|
export interface ItemUsageData {
|
||||||
consume: boolean;
|
consume: boolean;
|
||||||
effects: ItemEffect[];
|
effects: ItemEffect[];
|
||||||
}
|
}
|
||||||
|
|
||||||
import { DrizzleClient } from "./DrizzleClient";
|
|
||||||
|
|
||||||
export type DbClient = typeof DrizzleClient;
|
export type DbClient = typeof DrizzleClient;
|
||||||
export type Transaction = Parameters<Parameters<DbClient['transaction']>[0]>[0];
|
export type Transaction = Parameters<Parameters<DbClient['transaction']>[0]>[0];
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { DrizzleClient } from "@/lib/DrizzleClient";
|
|||||||
import type { ItemUsageData, ItemEffect } from "@/lib/types";
|
import type { ItemUsageData, ItemEffect } from "@/lib/types";
|
||||||
import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
|
import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
|
||||||
import type { DraftItem } from "./item_wizard.types";
|
import type { DraftItem } from "./item_wizard.types";
|
||||||
|
import { ItemType, EffectType } from "@/lib/constants";
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ export const renderWizard = (userId: string, isDraft = true) => {
|
|||||||
name: "New Item",
|
name: "New Item",
|
||||||
description: "No description",
|
description: "No description",
|
||||||
rarity: "Common",
|
rarity: "Common",
|
||||||
type: "MATERIAL",
|
type: ItemType.MATERIAL,
|
||||||
price: null,
|
price: null,
|
||||||
iconUrl: "",
|
iconUrl: "",
|
||||||
imageUrl: "",
|
imageUrl: "",
|
||||||
@@ -176,26 +177,26 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
if (type) {
|
if (type) {
|
||||||
let effect: ItemEffect | null = null;
|
let effect: ItemEffect | null = null;
|
||||||
|
|
||||||
if (type === "ADD_XP" || type === "ADD_BALANCE") {
|
if (type === EffectType.ADD_XP || type === EffectType.ADD_BALANCE) {
|
||||||
const amount = parseInt(interaction.fields.getTextInputValue("amount"));
|
const amount = parseInt(interaction.fields.getTextInputValue("amount"));
|
||||||
if (!isNaN(amount)) effect = { type: type as any, amount };
|
if (!isNaN(amount)) effect = { type: type as any, amount };
|
||||||
}
|
}
|
||||||
else if (type === "REPLY_MESSAGE") {
|
else if (type === EffectType.REPLY_MESSAGE) {
|
||||||
effect = { type: "REPLY_MESSAGE", message: interaction.fields.getTextInputValue("message") };
|
effect = { type: EffectType.REPLY_MESSAGE, message: interaction.fields.getTextInputValue("message") };
|
||||||
}
|
}
|
||||||
else if (type === "XP_BOOST") {
|
else if (type === EffectType.XP_BOOST) {
|
||||||
const multiplier = parseFloat(interaction.fields.getTextInputValue("multiplier"));
|
const multiplier = parseFloat(interaction.fields.getTextInputValue("multiplier"));
|
||||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
||||||
if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: "XP_BOOST", multiplier, durationSeconds: duration };
|
if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: EffectType.XP_BOOST, multiplier, durationSeconds: duration };
|
||||||
}
|
}
|
||||||
else if (type === "TEMP_ROLE") {
|
else if (type === EffectType.TEMP_ROLE) {
|
||||||
const roleId = interaction.fields.getTextInputValue("role_id");
|
const roleId = interaction.fields.getTextInputValue("role_id");
|
||||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
||||||
if (roleId && !isNaN(duration)) effect = { type: "TEMP_ROLE", roleId: roleId, durationSeconds: duration };
|
if (roleId && !isNaN(duration)) effect = { type: EffectType.TEMP_ROLE, roleId: roleId, durationSeconds: duration };
|
||||||
}
|
}
|
||||||
else if (type === "COLOR_ROLE") {
|
else if (type === EffectType.COLOR_ROLE) {
|
||||||
const roleId = interaction.fields.getTextInputValue("role_id");
|
const roleId = interaction.fields.getTextInputValue("role_id");
|
||||||
if (roleId) effect = { type: "COLOR_ROLE", roleId: roleId };
|
if (roleId) effect = { type: EffectType.COLOR_ROLE, roleId: roleId };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (effect) {
|
if (effect) {
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ import {
|
|||||||
} from "discord.js";
|
} from "discord.js";
|
||||||
import { createBaseEmbed } from "@lib/embeds";
|
import { createBaseEmbed } from "@lib/embeds";
|
||||||
import type { DraftItem } from "./item_wizard.types";
|
import type { DraftItem } from "./item_wizard.types";
|
||||||
|
import { ItemType } from "@/lib/constants";
|
||||||
|
|
||||||
const getItemTypeOptions = () => [
|
const getItemTypeOptions = () => [
|
||||||
{ label: "Material", value: "MATERIAL", description: "Used for crafting or trading" },
|
{ label: "Material", value: ItemType.MATERIAL, description: "Used for crafting or trading" },
|
||||||
{ label: "Consumable", value: "CONSUMABLE", description: "Can be used to gain effects" },
|
{ label: "Consumable", value: ItemType.CONSUMABLE, description: "Can be used to gain effects" },
|
||||||
{ label: "Equipment", value: "EQUIPMENT", description: "Can be equipped (Not yet implemented)" },
|
{ label: "Equipment", value: ItemType.EQUIPMENT, description: "Can be equipped (Not yet implemented)" },
|
||||||
{ label: "Quest Item", value: "QUEST", description: "Required for quests" },
|
{ label: "Quest Item", value: ItemType.QUEST, description: "Required for quests" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const getEffectTypeOptions = () => [
|
const getEffectTypeOptions = () => [
|
||||||
|
|||||||
@@ -173,10 +173,22 @@ describe("economyService", () => {
|
|||||||
it("should throw if cooldown is active", async () => {
|
it("should throw if cooldown is active", async () => {
|
||||||
const future = new Date("2023-01-02T12:00:00Z"); // +24h
|
const future = new Date("2023-01-02T12:00:00Z"); // +24h
|
||||||
mockFindFirst.mockResolvedValue({ expiresAt: future });
|
mockFindFirst.mockResolvedValue({ expiresAt: future });
|
||||||
|
|
||||||
expect(economyService.claimDaily("1")).rejects.toThrow("Daily already claimed");
|
expect(economyService.claimDaily("1")).rejects.toThrow("Daily already claimed");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should set cooldown to next UTC midnight", async () => {
|
||||||
|
// 2023-01-01T12:00:00Z -> Should be 2023-01-02T00:00:00Z
|
||||||
|
|
||||||
|
mockFindFirst
|
||||||
|
.mockResolvedValueOnce(undefined) // No cooldown
|
||||||
|
.mockResolvedValueOnce({ id: 1n, dailyStreak: 5, balance: 1000n });
|
||||||
|
|
||||||
|
const result = await economyService.claimDaily("1");
|
||||||
|
|
||||||
|
const expectedReset = new Date("2023-01-02T00:00:00Z");
|
||||||
|
expect(result.nextReadyAt.toISOString()).toBe(expectedReset.toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
it("should reset streak if missed a day (long time gap)", async () => {
|
it("should reset streak if missed a day (long time gap)", async () => {
|
||||||
// Expired 3 days ago
|
// Expired 3 days ago
|
||||||
const past = new Date("2023-01-01T00:00:00Z"); // now is 12:00
|
const past = new Date("2023-01-01T00:00:00Z"); // now is 12:00
|
||||||
@@ -193,8 +205,40 @@ describe("economyService", () => {
|
|||||||
const result = await economyService.claimDaily("1");
|
const result = await economyService.claimDaily("1");
|
||||||
|
|
||||||
// timeSinceReady = 48h.
|
// timeSinceReady = 48h.
|
||||||
// streak = (5+1) - floor(48h / 24h) = 6 - 2 = 4.
|
// streak should reset to 1
|
||||||
expect(result.streak).toBe(4);
|
expect(result.streak).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve streak if cooldown is missing but user has a streak", async () => {
|
||||||
|
mockFindFirst
|
||||||
|
.mockResolvedValueOnce(undefined) // No cooldown
|
||||||
|
.mockResolvedValueOnce({ id: 1n, dailyStreak: 10 });
|
||||||
|
|
||||||
|
const result = await economyService.claimDaily("1");
|
||||||
|
expect(result.streak).toBe(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prevent weekly bonus exploit by resetting streak", async () => {
|
||||||
|
// Mock user at streak 7.
|
||||||
|
// Mock time as 24h + 1m after expiry.
|
||||||
|
|
||||||
|
const expiredAt = new Date("2023-01-01T11:59:00Z"); // now is 12:00 next day, plus 1 min gap?
|
||||||
|
// no, 'now' is 2023-01-01T12:00:00Z set in beforeEach
|
||||||
|
|
||||||
|
// We want gap > 24h.
|
||||||
|
// If expiry was yesterday 11:59:59. Gap is 24h + 1s.
|
||||||
|
|
||||||
|
const expiredAtExploit = new Date("2022-12-31T11:59:00Z"); // Over 24h ago
|
||||||
|
|
||||||
|
mockFindFirst
|
||||||
|
.mockResolvedValueOnce({ expiresAt: expiredAtExploit })
|
||||||
|
.mockResolvedValueOnce({ id: 1n, dailyStreak: 7 });
|
||||||
|
|
||||||
|
const result = await economyService.claimDaily("1");
|
||||||
|
|
||||||
|
// Should reset to 1
|
||||||
|
expect(result.streak).toBe(1);
|
||||||
|
expect(result.isWeekly).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { config } from "@/lib/config";
|
|||||||
import { withTransaction } from "@/lib/db";
|
import { withTransaction } from "@/lib/db";
|
||||||
import type { Transaction } from "@/lib/types";
|
import type { Transaction } from "@/lib/types";
|
||||||
import { UserError } from "@/lib/errors";
|
import { UserError } from "@/lib/errors";
|
||||||
|
import { TimerType, TransactionType } from "@/lib/constants";
|
||||||
|
|
||||||
export const economyService = {
|
export const economyService = {
|
||||||
transfer: async (fromUserId: string, toUserId: string, amount: bigint, tx?: Transaction) => {
|
transfer: async (fromUserId: string, toUserId: string, amount: bigint, tx?: Transaction) => {
|
||||||
@@ -48,7 +49,7 @@ export const economyService = {
|
|||||||
await txFn.insert(transactions).values({
|
await txFn.insert(transactions).values({
|
||||||
userId: BigInt(fromUserId),
|
userId: BigInt(fromUserId),
|
||||||
amount: -amount,
|
amount: -amount,
|
||||||
type: 'TRANSFER_OUT',
|
type: TransactionType.TRANSFER_OUT,
|
||||||
description: `Transfer to ${toUserId}`,
|
description: `Transfer to ${toUserId}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ export const economyService = {
|
|||||||
await txFn.insert(transactions).values({
|
await txFn.insert(transactions).values({
|
||||||
userId: BigInt(toUserId),
|
userId: BigInt(toUserId),
|
||||||
amount: amount,
|
amount: amount,
|
||||||
type: 'TRANSFER_IN',
|
type: TransactionType.TRANSFER_IN,
|
||||||
description: `Transfer from ${fromUserId}`,
|
description: `Transfer from ${fromUserId}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,20 +68,18 @@ export const economyService = {
|
|||||||
claimDaily: async (userId: string, tx?: Transaction) => {
|
claimDaily: async (userId: string, tx?: Transaction) => {
|
||||||
return await withTransaction(async (txFn) => {
|
return await withTransaction(async (txFn) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const startOfDay = new Date(now);
|
|
||||||
startOfDay.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
// Check cooldown
|
// Check cooldown
|
||||||
const cooldown = await txFn.query.userTimers.findFirst({
|
const cooldown = await txFn.query.userTimers.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(userTimers.userId, BigInt(userId)),
|
eq(userTimers.userId, BigInt(userId)),
|
||||||
eq(userTimers.type, 'COOLDOWN'),
|
eq(userTimers.type, TimerType.COOLDOWN),
|
||||||
eq(userTimers.key, 'daily')
|
eq(userTimers.key, 'daily')
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (cooldown && cooldown.expiresAt > now) {
|
if (cooldown && cooldown.expiresAt > now) {
|
||||||
throw new UserError(`Daily already claimed. Ready <t:${Math.floor(cooldown.expiresAt.getTime() / 1000)}:R>`);
|
throw new UserError(`Daily already claimed today. Next claim <t:${Math.floor(cooldown.expiresAt.getTime() / 1000)}:F>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user for streak logic
|
// Get user for streak logic
|
||||||
@@ -89,17 +88,23 @@ export const economyService = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error("User not found"); // This might be system error because user should exist if authenticated, but keeping simple for now
|
throw new Error("User not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
let streak = (user.dailyStreak || 0) + 1;
|
let streak = (user.dailyStreak || 0) + 1;
|
||||||
|
|
||||||
// If previous cooldown exists and expired more than 24h ago (meaning >48h since last claim), reduce streak by one for each day passed minimum 1
|
// Check if streak should be reset due to missing a day
|
||||||
if (cooldown) {
|
if (cooldown) {
|
||||||
const timeSinceReady = now.getTime() - cooldown.expiresAt.getTime();
|
const timeSinceReady = now.getTime() - cooldown.expiresAt.getTime();
|
||||||
|
// If more than 24h passed since it became ready, they missed a full calendar day
|
||||||
if (timeSinceReady > 24 * 60 * 60 * 1000) {
|
if (timeSinceReady > 24 * 60 * 60 * 1000) {
|
||||||
streak = Math.max(1, streak - Math.floor(timeSinceReady / (24 * 60 * 60 * 1000)));
|
streak = 1;
|
||||||
}
|
}
|
||||||
|
} else if ((user.dailyStreak || 0) > 0) {
|
||||||
|
// If no cooldown record exists but user has a streak,
|
||||||
|
// we'll allow one "free" increment to restore the timer state.
|
||||||
|
// This prevents unfair resets if timers were cleared/lost.
|
||||||
|
streak = (user.dailyStreak || 0) + 1;
|
||||||
} else {
|
} else {
|
||||||
streak = 1;
|
streak = 1;
|
||||||
}
|
}
|
||||||
@@ -119,13 +124,15 @@ export const economyService = {
|
|||||||
})
|
})
|
||||||
.where(eq(users.id, BigInt(userId)));
|
.where(eq(users.id, BigInt(userId)));
|
||||||
|
|
||||||
// Set new cooldown (now + 24h)
|
// Set new cooldown (Next UTC Midnight)
|
||||||
const nextReadyAt = new Date(now.getTime() + config.economy.daily.cooldownMs);
|
const nextReadyAt = new Date(now);
|
||||||
|
nextReadyAt.setUTCDate(nextReadyAt.getUTCDate() + 1);
|
||||||
|
nextReadyAt.setUTCHours(0, 0, 0, 0);
|
||||||
|
|
||||||
await txFn.insert(userTimers)
|
await txFn.insert(userTimers)
|
||||||
.values({
|
.values({
|
||||||
userId: BigInt(userId),
|
userId: BigInt(userId),
|
||||||
type: 'COOLDOWN',
|
type: TimerType.COOLDOWN,
|
||||||
key: 'daily',
|
key: 'daily',
|
||||||
expiresAt: nextReadyAt,
|
expiresAt: nextReadyAt,
|
||||||
})
|
})
|
||||||
@@ -138,7 +145,7 @@ export const economyService = {
|
|||||||
await txFn.insert(transactions).values({
|
await txFn.insert(transactions).values({
|
||||||
userId: BigInt(userId),
|
userId: BigInt(userId),
|
||||||
amount: totalReward,
|
amount: totalReward,
|
||||||
type: 'DAILY_REWARD',
|
type: TransactionType.DAILY_REWARD,
|
||||||
description: `Daily reward (Streak: ${streak})`,
|
description: `Daily reward (Streak: ${streak})`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ButtonInteraction } from "discord.js";
|
import { ButtonInteraction } from "discord.js";
|
||||||
import { lootdropService } from "./lootdrop.service";
|
import { lootdropService } from "./lootdrop.service";
|
||||||
import { createErrorEmbed } from "@/lib/embeds";
|
import { UserError } from "@/lib/errors";
|
||||||
import { getLootdropClaimedMessage } from "./lootdrop.view";
|
import { getLootdropClaimedMessage } from "./lootdrop.view";
|
||||||
|
|
||||||
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
|
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
|
||||||
@@ -9,28 +9,27 @@ export async function handleLootdropInteraction(interaction: ButtonInteraction)
|
|||||||
|
|
||||||
const result = await lootdropService.tryClaim(interaction.message.id, interaction.user.id, interaction.user.username);
|
const result = await lootdropService.tryClaim(interaction.message.id, interaction.user.id, interaction.user.username);
|
||||||
|
|
||||||
if (result.success) {
|
if (!result.success) {
|
||||||
|
throw new UserError(result.error || "Failed to claim.");
|
||||||
|
}
|
||||||
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
content: `🎉 You successfully claimed **${result.amount} ${result.currency}**!`
|
content: `🎉 You successfully claimed **${result.amount} ${result.currency}**!`
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update original message to show claimed state
|
const { content, files, components } = await getLootdropClaimedMessage(
|
||||||
const originalEmbed = interaction.message.embeds[0];
|
|
||||||
if (!originalEmbed) return;
|
|
||||||
|
|
||||||
const { embeds, components } = getLootdropClaimedMessage(
|
|
||||||
originalEmbed.title || "💰 LOOTDROP!",
|
|
||||||
interaction.user.id,
|
interaction.user.id,
|
||||||
|
interaction.user.username,
|
||||||
|
interaction.user.displayAvatarURL({ extension: "png" }),
|
||||||
result.amount || 0,
|
result.amount || 0,
|
||||||
result.currency || "Coins"
|
result.currency || "Coins"
|
||||||
);
|
);
|
||||||
|
|
||||||
await interaction.message.edit({ embeds, components });
|
await interaction.message.edit({
|
||||||
|
content,
|
||||||
} else {
|
embeds: [],
|
||||||
await interaction.editReply({
|
files,
|
||||||
embeds: [createErrorEmbed(result.error || "Failed to claim.")]
|
components
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class LootdropService {
|
|||||||
// Cleanup interval for activity tracking and expired lootdrops
|
// Cleanup interval for activity tracking and expired lootdrops
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
this.cleanupActivity();
|
this.cleanupActivity();
|
||||||
this.cleanupExpiredLootdrops();
|
this.cleanupExpiredLootdrops(true);
|
||||||
}, 60000);
|
}, 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,16 +45,24 @@ class LootdropService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cleanupExpiredLootdrops() {
|
public async cleanupExpiredLootdrops(includeClaimed: boolean = false): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await DrizzleClient.delete(lootdrops)
|
const whereClause = includeClaimed
|
||||||
.where(and(
|
? lt(lootdrops.expiresAt, now)
|
||||||
isNull(lootdrops.claimedBy),
|
: and(isNull(lootdrops.claimedBy), lt(lootdrops.expiresAt, now));
|
||||||
lt(lootdrops.expiresAt, now)
|
|
||||||
));
|
const result = await DrizzleClient.delete(lootdrops)
|
||||||
|
.where(whereClause)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (result.length > 0) {
|
||||||
|
console.log(`[LootdropService] Cleaned up ${result.length} expired lootdrops.`);
|
||||||
|
}
|
||||||
|
return result.length;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to cleanup lootdrops:", error);
|
console.error("Failed to cleanup lootdrops:", error);
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,10 +102,10 @@ class LootdropService {
|
|||||||
const reward = Math.floor(Math.random() * (max - min + 1)) + min;
|
const reward = Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
const currency = config.lootdrop.reward.currency;
|
const currency = config.lootdrop.reward.currency;
|
||||||
|
|
||||||
const { embeds, components } = getLootdropMessage(reward, currency);
|
const { content, files, components } = await getLootdropMessage(reward, currency);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const message = await channel.send({ embeds, components });
|
const message = await channel.send({ content, files, components });
|
||||||
|
|
||||||
// Persist to DB
|
// Persist to DB
|
||||||
await DrizzleClient.insert(lootdrops).values({
|
await DrizzleClient.insert(lootdrops).values({
|
||||||
|
|||||||
@@ -1,31 +1,29 @@
|
|||||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js";
|
import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
|
||||||
import { createBaseEmbed } from "@lib/embeds";
|
import { generateLootdropCard, generateClaimedLootdropCard } from "@/graphics/lootdrop";
|
||||||
|
|
||||||
export function getLootdropMessage(reward: number, currency: string) {
|
export async function getLootdropMessage(reward: number, currency: string) {
|
||||||
const embed = createBaseEmbed(
|
const cardBuffer = await generateLootdropCard(reward, currency);
|
||||||
"💰 LOOTDROP!",
|
const attachment = new AttachmentBuilder(cardBuffer, { name: "lootdrop.png" });
|
||||||
`A lootdrop has appeared! Click the button below to claim **${reward} ${currency}**!`,
|
|
||||||
"#FFD700"
|
|
||||||
);
|
|
||||||
|
|
||||||
const claimButton = new ButtonBuilder()
|
const claimButton = new ButtonBuilder()
|
||||||
.setCustomId("lootdrop_claim")
|
.setCustomId("lootdrop_claim")
|
||||||
.setLabel("CLAIM REWARD")
|
.setLabel("CLAIM REWARD")
|
||||||
.setStyle(ButtonStyle.Success)
|
.setStyle(ButtonStyle.Secondary) // Changed to Secondary to fit the darker theme better? Or keep Success? Let's try Secondary with custom emoji
|
||||||
.setEmoji("💸");
|
.setEmoji("🌠");
|
||||||
|
|
||||||
const row = new ActionRowBuilder<ButtonBuilder>()
|
const row = new ActionRowBuilder<ButtonBuilder>()
|
||||||
.addComponents(claimButton);
|
.addComponents(claimButton);
|
||||||
|
|
||||||
return { embeds: [embed], components: [row] };
|
return {
|
||||||
|
content: "",
|
||||||
|
files: [attachment],
|
||||||
|
components: [row]
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLootdropClaimedMessage(originalTitle: string, userId: string, amount: number, currency: string) {
|
export async function getLootdropClaimedMessage(userId: string, username: string, avatarUrl: string, amount: number, currency: string) {
|
||||||
const newEmbed = createBaseEmbed(
|
const cardBuffer = await generateClaimedLootdropCard(amount, currency, username, avatarUrl);
|
||||||
originalTitle || "💰 LOOTDROP!",
|
const attachment = new AttachmentBuilder(cardBuffer, { name: "lootdrop_claimed.png" });
|
||||||
`✅ Claimed by <@${userId}> for **${amount} ${currency}**!`,
|
|
||||||
"#00FF00"
|
|
||||||
);
|
|
||||||
|
|
||||||
const newRow = new ActionRowBuilder<ButtonBuilder>()
|
const newRow = new ActionRowBuilder<ButtonBuilder>()
|
||||||
.addComponents(
|
.addComponents(
|
||||||
@@ -37,5 +35,9 @@ export function getLootdropClaimedMessage(originalTitle: string, userId: string,
|
|||||||
.setDisabled(true)
|
.setDisabled(true)
|
||||||
);
|
);
|
||||||
|
|
||||||
return { embeds: [newEmbed], components: [newRow] };
|
return {
|
||||||
|
content: ``, // Remove content as the image says it all
|
||||||
|
files: [attachment],
|
||||||
|
components: [newRow]
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,34 @@
|
|||||||
import { ButtonInteraction, MessageFlags } from "discord.js";
|
import { ButtonInteraction, MessageFlags } from "discord.js";
|
||||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@/modules/user/user.service";
|
||||||
import { createErrorEmbed, createWarningEmbed } from "@/lib/embeds";
|
import { UserError } from "@/lib/errors";
|
||||||
|
|
||||||
export async function handleShopInteraction(interaction: ButtonInteraction) {
|
export async function handleShopInteraction(interaction: ButtonInteraction) {
|
||||||
if (!interaction.customId.startsWith("shop_buy_")) return;
|
if (!interaction.customId.startsWith("shop_buy_")) return;
|
||||||
|
|
||||||
try {
|
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
const itemId = parseInt(interaction.customId.replace("shop_buy_", ""));
|
const itemId = parseInt(interaction.customId.replace("shop_buy_", ""));
|
||||||
if (isNaN(itemId)) {
|
if (isNaN(itemId)) {
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Item ID.")] });
|
throw new UserError("Invalid Item ID.");
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = await inventoryService.getItem(itemId);
|
const item = await inventoryService.getItem(itemId);
|
||||||
if (!item || !item.price) {
|
if (!item || !item.price) {
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("Item not found or not for sale.")] });
|
throw new UserError("Item not found or not for sale.");
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||||
|
if (!user) {
|
||||||
|
throw new UserError("User profiles could not be loaded. Please try again later.");
|
||||||
|
}
|
||||||
|
|
||||||
// Double check balance here too, although service handles it, we want a nice message
|
// Double check balance here too, although service handles it, we want a nice message
|
||||||
if ((user.balance ?? 0n) < item.price) {
|
if ((user.balance ?? 0n) < item.price) {
|
||||||
await interaction.editReply({ embeds: [createWarningEmbed(`You need ${item.price} 🪙 to buy this item. You have ${user.balance} 🪙.`)] });
|
throw new UserError(`You need ${item.price} 🪙 to buy this item. You have ${user.balance?.toString() ?? "0"} 🪙.`);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await inventoryService.buyItem(user.id, item.id, 1n);
|
await inventoryService.buyItem(user.id.toString(), item.id, 1n);
|
||||||
|
|
||||||
await interaction.editReply({ content: `✅ **Success!** You bought **${item.name}** for ${item.price} 🪙.` });
|
await interaction.editReply({ content: `✅ **Success!** You bought **${item.name}** for ${item.price} 🪙.` });
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Shop Purchase Error:", error);
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An error occurred while processing your purchase.")] });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { config } from "@/lib/config";
|
|||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
|
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
|
||||||
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
|
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@/lib/embeds";
|
import { UserError } from "@/lib/errors";
|
||||||
|
|
||||||
export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
||||||
// Handle select menu for choosing feedback type
|
// Handle select menu for choosing feedback type
|
||||||
@@ -12,11 +12,7 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
|||||||
const feedbackType = interaction.values[0] as FeedbackType;
|
const feedbackType = interaction.values[0] as FeedbackType;
|
||||||
|
|
||||||
if (!feedbackType) {
|
if (!feedbackType) {
|
||||||
await interaction.reply({
|
throw new UserError("Invalid feedback type selected.");
|
||||||
embeds: [createErrorEmbed("Invalid feedback type selected.")],
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const modal = getFeedbackModal(feedbackType);
|
const modal = getFeedbackModal(feedbackType);
|
||||||
@@ -34,22 +30,13 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
|||||||
|
|
||||||
if (!feedbackType || !["FEATURE_REQUEST", "BUG_REPORT", "GENERAL"].includes(feedbackType)) {
|
if (!feedbackType || !["FEATURE_REQUEST", "BUG_REPORT", "GENERAL"].includes(feedbackType)) {
|
||||||
console.error(`Invalid feedback type extracted: ${feedbackType} from customId: ${interaction.customId}`);
|
console.error(`Invalid feedback type extracted: ${feedbackType} from customId: ${interaction.customId}`);
|
||||||
await interaction.reply({
|
throw new UserError("An error occurred processing your feedback. Please try again.");
|
||||||
embeds: [createErrorEmbed("An error occurred processing your feedback. Please try again.")],
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!config.feedbackChannelId) {
|
if (!config.feedbackChannelId) {
|
||||||
await interaction.reply({
|
throw new UserError("Feedback channel is not configured. Please contact an administrator.");
|
||||||
embeds: [createErrorEmbed("Feedback channel is not configured. Please contact an administrator.")],
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
// Parse modal inputs
|
// Parse modal inputs
|
||||||
const title = interaction.fields.getTextInputValue(FEEDBACK_CUSTOM_IDS.TITLE_FIELD);
|
const title = interaction.fields.getTextInputValue(FEEDBACK_CUSTOM_IDS.TITLE_FIELD);
|
||||||
const description = interaction.fields.getTextInputValue(FEEDBACK_CUSTOM_IDS.DESCRIPTION_FIELD);
|
const description = interaction.fields.getTextInputValue(FEEDBACK_CUSTOM_IDS.DESCRIPTION_FIELD);
|
||||||
@@ -68,11 +55,7 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
|||||||
const channel = await AuroraClient.channels.fetch(config.feedbackChannelId).catch(() => null) as TextChannel | null;
|
const channel = await AuroraClient.channels.fetch(config.feedbackChannelId).catch(() => null) as TextChannel | null;
|
||||||
|
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
await interaction.reply({
|
throw new UserError("Feedback channel not found. Please contact an administrator.");
|
||||||
embeds: [createErrorEmbed("Feedback channel not found. Please contact an administrator.")],
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build and send beautiful message
|
// Build and send beautiful message
|
||||||
@@ -89,24 +72,8 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
|||||||
|
|
||||||
// Confirm to user
|
// Confirm to user
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
embeds: [createSuccessEmbed("Your feedback has been submitted successfully! Thank you for helping improve Aurora.", "✨ Feedback Submitted")],
|
content: "✨ **Feedback Submitted**\nYour feedback has been submitted successfully! Thank you for helping improve Aurora.",
|
||||||
ephemeral: true
|
flags: MessageFlags.Ephemeral
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error submitting feedback:", error);
|
|
||||||
|
|
||||||
if (!interaction.replied && !interaction.deferred) {
|
|
||||||
await interaction.reply({
|
|
||||||
embeds: [createErrorEmbed("An error occurred while submitting your feedback. Please try again later.")],
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await interaction.followUp({
|
|
||||||
embeds: [createErrorEmbed("An error occurred while submitting your feedback. Please try again later.")],
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { levelingService } from "@/modules/leveling/leveling.service";
|
|||||||
import { economyService } from "@/modules/economy/economy.service";
|
import { economyService } from "@/modules/economy/economy.service";
|
||||||
import { userTimers } from "@/db/schema";
|
import { userTimers } from "@/db/schema";
|
||||||
import type { EffectHandler } from "./types";
|
import type { EffectHandler } from "./types";
|
||||||
|
import type { LootTableItem } from "@/lib/types";
|
||||||
|
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||||
|
import { inventory, items } from "@/db/schema";
|
||||||
|
import { TimerType, TransactionType, LootType } from "@/lib/constants";
|
||||||
|
|
||||||
|
|
||||||
// Helper to extract duration in seconds
|
// Helper to extract duration in seconds
|
||||||
const getDuration = (effect: any): number => {
|
const getDuration = (effect: any): number => {
|
||||||
@@ -16,7 +21,7 @@ export const handleAddXp: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const handleAddBalance: EffectHandler = async (userId, effect, txFn) => {
|
export const handleAddBalance: EffectHandler = async (userId, effect, txFn) => {
|
||||||
await economyService.modifyUserBalance(userId, BigInt(effect.amount), 'ITEM_USE', `Used Item`, null, txFn);
|
await economyService.modifyUserBalance(userId, BigInt(effect.amount), TransactionType.ITEM_USE, `Used Item`, null, txFn);
|
||||||
return `Gained ${effect.amount} 🪙`;
|
return `Gained ${effect.amount} 🪙`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,7 +34,7 @@ export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
const expiresAt = new Date(Date.now() + boostDuration * 1000);
|
const expiresAt = new Date(Date.now() + boostDuration * 1000);
|
||||||
await txFn.insert(userTimers).values({
|
await txFn.insert(userTimers).values({
|
||||||
userId: BigInt(userId),
|
userId: BigInt(userId),
|
||||||
type: 'EFFECT',
|
type: TimerType.EFFECT,
|
||||||
key: 'xp_boost',
|
key: 'xp_boost',
|
||||||
expiresAt: expiresAt,
|
expiresAt: expiresAt,
|
||||||
metadata: { multiplier: effect.multiplier }
|
metadata: { multiplier: effect.multiplier }
|
||||||
@@ -45,7 +50,7 @@ export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
|
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
|
||||||
await txFn.insert(userTimers).values({
|
await txFn.insert(userTimers).values({
|
||||||
userId: BigInt(userId),
|
userId: BigInt(userId),
|
||||||
type: 'ACCESS',
|
type: TimerType.ACCESS,
|
||||||
key: `role_${effect.roleId}`,
|
key: `role_${effect.roleId}`,
|
||||||
expiresAt: roleExpiresAt,
|
expiresAt: roleExpiresAt,
|
||||||
metadata: { roleId: effect.roleId }
|
metadata: { roleId: effect.roleId }
|
||||||
@@ -60,3 +65,73 @@ export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
export const handleColorRole: EffectHandler = async (_userId, _effect, _txFn) => {
|
export const handleColorRole: EffectHandler = async (_userId, _effect, _txFn) => {
|
||||||
return "Color Role Equipped";
|
return "Color Role Equipped";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
||||||
|
const pool = effect.pool as LootTableItem[];
|
||||||
|
if (!pool || pool.length === 0) return "The box is empty...";
|
||||||
|
|
||||||
|
const totalWeight = pool.reduce((sum, item) => sum + item.weight, 0);
|
||||||
|
let random = Math.random() * totalWeight;
|
||||||
|
|
||||||
|
let winner: LootTableItem | null = null;
|
||||||
|
for (const item of pool) {
|
||||||
|
if (random < item.weight) {
|
||||||
|
winner = item;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
random -= item.weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!winner) return "The box is empty..."; // Should not happen
|
||||||
|
|
||||||
|
// Process Winner
|
||||||
|
if (winner.type === LootType.NOTHING) {
|
||||||
|
return winner.message || "You found nothing inside.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (winner.type === LootType.CURRENCY) {
|
||||||
|
let amount = winner.amount || 0;
|
||||||
|
if (winner.minAmount && winner.maxAmount) {
|
||||||
|
amount = Math.floor(Math.random() * (winner.maxAmount - winner.minAmount + 1)) + winner.minAmount;
|
||||||
|
}
|
||||||
|
if (amount > 0) {
|
||||||
|
await economyService.modifyUserBalance(userId, BigInt(amount), TransactionType.LOOTBOX, 'Lootbox Reward', null, txFn);
|
||||||
|
return winner.message || `You found ${amount} 🪙!`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (winner.type === LootType.XP) {
|
||||||
|
let amount = winner.amount || 0;
|
||||||
|
if (winner.minAmount && winner.maxAmount) {
|
||||||
|
amount = Math.floor(Math.random() * (winner.maxAmount - winner.minAmount + 1)) + winner.minAmount;
|
||||||
|
}
|
||||||
|
if (amount > 0) {
|
||||||
|
await levelingService.addXp(userId, BigInt(amount), txFn);
|
||||||
|
return winner.message || `You gained ${amount} XP!`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (winner.type === LootType.ITEM) {
|
||||||
|
if (winner.itemId) {
|
||||||
|
const quantity = BigInt(winner.amount || 1);
|
||||||
|
|
||||||
|
await inventoryService.addItem(userId, winner.itemId, quantity, txFn);
|
||||||
|
|
||||||
|
// Try to fetch item name for the message
|
||||||
|
try {
|
||||||
|
const item = await txFn.query.items.findFirst({
|
||||||
|
where: (items, { eq }) => eq(items.id, winner.itemId!)
|
||||||
|
});
|
||||||
|
if (item) {
|
||||||
|
return winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch item name for lootbox message", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return winner.message || `You found an item! (ID: ${winner.itemId})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "You found nothing suitable inside.";
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import {
|
|||||||
handleReplyMessage,
|
handleReplyMessage,
|
||||||
handleXpBoost,
|
handleXpBoost,
|
||||||
handleTempRole,
|
handleTempRole,
|
||||||
handleColorRole
|
handleColorRole,
|
||||||
|
handleLootbox
|
||||||
} from "./handlers";
|
} from "./handlers";
|
||||||
import type { EffectHandler } from "./types";
|
import type { EffectHandler } from "./types";
|
||||||
|
|
||||||
@@ -14,5 +15,6 @@ export const effectHandlers: Record<string, EffectHandler> = {
|
|||||||
'REPLY_MESSAGE': handleReplyMessage,
|
'REPLY_MESSAGE': handleReplyMessage,
|
||||||
'XP_BOOST': handleXpBoost,
|
'XP_BOOST': handleXpBoost,
|
||||||
'TEMP_ROLE': handleTempRole,
|
'TEMP_ROLE': handleTempRole,
|
||||||
'COLOR_ROLE': handleColorRole
|
'COLOR_ROLE': handleColorRole,
|
||||||
|
'LOOTBOX': handleLootbox
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const mockWhere = mock();
|
|||||||
const mockSelect = mock();
|
const mockSelect = mock();
|
||||||
const mockFrom = mock();
|
const mockFrom = mock();
|
||||||
const mockOnConflictDoUpdate = mock();
|
const mockOnConflictDoUpdate = mock();
|
||||||
|
const mockInnerJoin = mock();
|
||||||
|
const mockLimit = mock();
|
||||||
|
|
||||||
// Chain setup
|
// Chain setup
|
||||||
mockInsert.mockReturnValue({ values: mockValues });
|
mockInsert.mockReturnValue({ values: mockValues });
|
||||||
@@ -34,7 +36,10 @@ mockWhere.mockReturnValue({ returning: mockReturning });
|
|||||||
mockDelete.mockReturnValue({ where: mockWhere });
|
mockDelete.mockReturnValue({ where: mockWhere });
|
||||||
|
|
||||||
mockSelect.mockReturnValue({ from: mockFrom });
|
mockSelect.mockReturnValue({ from: mockFrom });
|
||||||
mockFrom.mockReturnValue({ where: mockWhere });
|
mockFrom.mockReturnValue({ where: mockWhere, innerJoin: mockInnerJoin });
|
||||||
|
mockInnerJoin.mockReturnValue({ where: mockWhere });
|
||||||
|
mockWhere.mockReturnValue({ returning: mockReturning, limit: mockLimit });
|
||||||
|
mockLimit.mockResolvedValue([]);
|
||||||
|
|
||||||
// Mock DrizzleClient
|
// Mock DrizzleClient
|
||||||
mock.module("@/lib/DrizzleClient", () => {
|
mock.module("@/lib/DrizzleClient", () => {
|
||||||
@@ -239,4 +244,39 @@ describe("inventoryService", () => {
|
|||||||
expect(mockDelete).toHaveBeenCalledWith(inventory); // Consume
|
expect(mockDelete).toHaveBeenCalledWith(inventory); // Consume
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getAutocompleteItems", () => {
|
||||||
|
it("should return formatted autocomplete results with rarity", async () => {
|
||||||
|
const mockItems = [
|
||||||
|
{
|
||||||
|
item: { id: 1, name: "Common Sword", rarity: "Common", usageData: { effects: [{}] } },
|
||||||
|
quantity: 5n
|
||||||
|
},
|
||||||
|
{
|
||||||
|
item: { id: 2, name: "Epic Shield", rarity: "Epic", usageData: { effects: [{}] } },
|
||||||
|
quantity: 1n
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
mockLimit.mockResolvedValue(mockItems);
|
||||||
|
|
||||||
|
// Restore mocks that might have been polluted by other tests
|
||||||
|
mockFrom.mockReturnValue({ where: mockWhere, innerJoin: mockInnerJoin });
|
||||||
|
mockWhere.mockReturnValue({ returning: mockReturning, limit: mockLimit });
|
||||||
|
|
||||||
|
const result = await inventoryService.getAutocompleteItems("1", "Sw");
|
||||||
|
|
||||||
|
expect(mockSelect).toHaveBeenCalled();
|
||||||
|
expect(mockFrom).toHaveBeenCalledWith(inventory);
|
||||||
|
expect(mockInnerJoin).toHaveBeenCalled(); // checks join
|
||||||
|
expect(mockWhere).toHaveBeenCalled(); // checks filters
|
||||||
|
expect(mockLimit).toHaveBeenCalledWith(20);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0]?.name).toBe("Common Sword (5) [Common]");
|
||||||
|
expect(result[0]?.value).toBe(1);
|
||||||
|
expect(result[1]?.name).toBe("Epic Shield (1) [Epic]");
|
||||||
|
expect(result[1]?.value).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { inventory, items, users, userTimers } from "@/db/schema";
|
import { inventory, items, users, userTimers } from "@/db/schema";
|
||||||
import { eq, and, sql, count } from "drizzle-orm";
|
import { eq, and, sql, count, ilike } from "drizzle-orm";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||||
import { economyService } from "@/modules/economy/economy.service";
|
import { economyService } from "@/modules/economy/economy.service";
|
||||||
import { levelingService } from "@/modules/leveling/leveling.service";
|
import { levelingService } from "@/modules/leveling/leveling.service";
|
||||||
@@ -7,6 +7,7 @@ import { config } from "@/lib/config";
|
|||||||
import { UserError } from "@/lib/errors";
|
import { UserError } from "@/lib/errors";
|
||||||
import { withTransaction } from "@/lib/db";
|
import { withTransaction } from "@/lib/db";
|
||||||
import type { Transaction, ItemUsageData } from "@/lib/types";
|
import type { Transaction, ItemUsageData } from "@/lib/types";
|
||||||
|
import { TransactionType } from "@/lib/constants";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -121,7 +122,7 @@ export const inventoryService = {
|
|||||||
const totalPrice = item.price * quantity;
|
const totalPrice = item.price * quantity;
|
||||||
|
|
||||||
// Deduct Balance using economy service (passing tx ensures atomicity)
|
// Deduct Balance using economy service (passing tx ensures atomicity)
|
||||||
await economyService.modifyUserBalance(userId, -totalPrice, 'PURCHASE', `Bought ${quantity}x ${item.name}`, null, txFn);
|
await economyService.modifyUserBalance(userId, -totalPrice, TransactionType.PURCHASE, `Bought ${quantity}x ${item.name}`, null, txFn);
|
||||||
|
|
||||||
await inventoryService.addItem(userId, itemId, quantity, txFn);
|
await inventoryService.addItem(userId, itemId, quantity, txFn);
|
||||||
|
|
||||||
@@ -159,7 +160,6 @@ export const inventoryService = {
|
|||||||
|
|
||||||
const results: string[] = [];
|
const results: string[] = [];
|
||||||
|
|
||||||
// 2. Apply Effects
|
|
||||||
// 2. Apply Effects
|
// 2. Apply Effects
|
||||||
const { effectHandlers } = await import("./effects/registry");
|
const { effectHandlers } = await import("./effects/registry");
|
||||||
|
|
||||||
@@ -179,7 +179,31 @@ export const inventoryService = {
|
|||||||
await inventoryService.removeItem(userId, itemId, 1n, txFn);
|
await inventoryService.removeItem(userId, itemId, 1n, txFn);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, results, usageData };
|
return { success: true, results, usageData, item };
|
||||||
}, tx);
|
}, tx);
|
||||||
|
},
|
||||||
|
|
||||||
|
getAutocompleteItems: async (userId: string, query: string) => {
|
||||||
|
const entries = await DrizzleClient.select({
|
||||||
|
quantity: inventory.quantity,
|
||||||
|
item: items
|
||||||
|
})
|
||||||
|
.from(inventory)
|
||||||
|
.innerJoin(items, eq(inventory.itemId, items.id))
|
||||||
|
.where(and(
|
||||||
|
eq(inventory.userId, BigInt(userId)),
|
||||||
|
ilike(items.name, `%${query}%`)
|
||||||
|
))
|
||||||
|
.limit(20);
|
||||||
|
|
||||||
|
const filtered = entries.filter(entry => {
|
||||||
|
const usageData = entry.item.usageData as ItemUsageData | null;
|
||||||
|
return usageData && usageData.effects && usageData.effects.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered.map(entry => ({
|
||||||
|
name: `${entry.item.name} (${entry.quantity}) [${entry.item.rarity || 'Common'}]`,
|
||||||
|
value: entry.item.id
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { EmbedBuilder } from "discord.js";
|
import { EmbedBuilder } from "discord.js";
|
||||||
import type { ItemUsageData } from "@/lib/types";
|
import type { ItemUsageData } from "@/lib/types";
|
||||||
|
import { EffectType } from "@/lib/constants";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inventory entry with item details
|
* 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
|
* 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");
|
const description = results.map(r => `• ${r}`).join("\n");
|
||||||
|
|
||||||
return new EmbedBuilder()
|
// Check if it was a lootbox
|
||||||
.setTitle("✅ Item Used!")
|
const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === EffectType.LOOTBOX);
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
.setDescription(description)
|
.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
|
// base 100, exp 1.5
|
||||||
// lvl 1: 100 * 1^1.5 = 100
|
// lvl 1: 100 * 1^1.5 = 100
|
||||||
// lvl 2: 100 * 2^1.5 = 100 * 2.828 = 282
|
// lvl 2: 100 * 2^1.5 = 100 * 2.828 = 282
|
||||||
expect(levelingService.getXpForLevel(1)).toBe(100);
|
expect(levelingService.getXpForNextLevel(1)).toBe(100);
|
||||||
expect(levelingService.getXpForLevel(2)).toBe(282);
|
expect(levelingService.getXpForNextLevel(2)).toBe(282);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ describe("levelingService", () => {
|
|||||||
|
|
||||||
expect(result.levelUp).toBe(true);
|
expect(result.levelUp).toBe(true);
|
||||||
expect(result.currentLevel).toBe(2);
|
expect(result.currentLevel).toBe(2);
|
||||||
expect(mockSet).toHaveBeenCalledWith({ xp: 20n, level: 2 });
|
expect(mockSet).toHaveBeenCalledWith({ xp: 120n, level: 2 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle multiple level ups", async () => {
|
it("should handle multiple level ups", async () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { eq, sql, and } from "drizzle-orm";
|
|||||||
import { withTransaction } from "@/lib/db";
|
import { withTransaction } from "@/lib/db";
|
||||||
import { config } from "@/lib/config";
|
import { config } from "@/lib/config";
|
||||||
import type { Transaction } from "@/lib/types";
|
import type { Transaction } from "@/lib/types";
|
||||||
|
import { TimerType } from "@/lib/constants";
|
||||||
|
|
||||||
export const levelingService = {
|
export const levelingService = {
|
||||||
// Calculate total XP required to REACH a specific level (Cumulative)
|
// Calculate total XP required to REACH a specific level (Cumulative)
|
||||||
@@ -78,7 +79,7 @@ export const levelingService = {
|
|||||||
const cooldown = await txFn.query.userTimers.findFirst({
|
const cooldown = await txFn.query.userTimers.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(userTimers.userId, BigInt(id)),
|
eq(userTimers.userId, BigInt(id)),
|
||||||
eq(userTimers.type, 'COOLDOWN'),
|
eq(userTimers.type, TimerType.COOLDOWN),
|
||||||
eq(userTimers.key, 'chat_xp')
|
eq(userTimers.key, 'chat_xp')
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -95,7 +96,7 @@ export const levelingService = {
|
|||||||
const xpBoost = await txFn.query.userTimers.findFirst({
|
const xpBoost = await txFn.query.userTimers.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(userTimers.userId, BigInt(id)),
|
eq(userTimers.userId, BigInt(id)),
|
||||||
eq(userTimers.type, 'EFFECT'),
|
eq(userTimers.type, TimerType.EFFECT),
|
||||||
eq(userTimers.key, 'xp_boost')
|
eq(userTimers.key, 'xp_boost')
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
@@ -114,7 +115,7 @@ export const levelingService = {
|
|||||||
await txFn.insert(userTimers)
|
await txFn.insert(userTimers)
|
||||||
.values({
|
.values({
|
||||||
userId: BigInt(id),
|
userId: BigInt(id),
|
||||||
type: 'COOLDOWN',
|
type: TimerType.COOLDOWN,
|
||||||
key: 'chat_xp',
|
key: 'chat_xp',
|
||||||
expiresAt: nextReadyAt,
|
expiresAt: nextReadyAt,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface LeaderboardUser {
|
|||||||
level: number | null;
|
level: number | null;
|
||||||
xp: bigint | null;
|
xp: bigint | null;
|
||||||
balance: 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
|
* 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 medal = getMedalEmoji(index);
|
||||||
const value = type === 'xp'
|
let value = '';
|
||||||
? `Lvl ${user.level ?? 1} (${user.xp ?? 0n} XP)`
|
|
||||||
: `${user.balance ?? 0n} 🪙`;
|
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}`;
|
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) =>
|
const description = leaders.map((user, index) =>
|
||||||
formatLeaderEntry(user, index, type)
|
formatLeaderEntry(user, index, type)
|
||||||
).join("\n");
|
).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()
|
return new EmbedBuilder()
|
||||||
.setTitle(title)
|
.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 { moderationCases } from "@/db/schema";
|
||||||
import { eq, and, desc } from "drizzle-orm";
|
import { eq, and, desc } from "drizzle-orm";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||||
import type { CreateCaseOptions, ClearCaseOptions, SearchCasesFilter, CaseType } from "./moderation.types";
|
import type { CreateCaseOptions, ClearCaseOptions, SearchCasesFilter } from "./moderation.types";
|
||||||
|
import { config } from "@/lib/config";
|
||||||
|
import { getUserWarningEmbed } from "./moderation.view";
|
||||||
|
import { CaseType } from "@/lib/constants";
|
||||||
|
|
||||||
export class ModerationService {
|
export class ModerationService {
|
||||||
/**
|
/**
|
||||||
@@ -41,12 +44,85 @@ export class ModerationService {
|
|||||||
moderatorName: options.moderatorName,
|
moderatorName: options.moderatorName,
|
||||||
reason: options.reason,
|
reason: options.reason,
|
||||||
metadata: options.metadata || {},
|
metadata: options.metadata || {},
|
||||||
active: options.type === 'warn' ? true : false, // Only warnings are "active" by default
|
active: options.type === CaseType.WARN ? true : false, // Only warnings are "active" by default
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
return newCase;
|
return newCase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue a warning with DM and threshold logic
|
||||||
|
*/
|
||||||
|
static async issueWarning(options: {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
moderatorId: string;
|
||||||
|
moderatorName: string;
|
||||||
|
reason: string;
|
||||||
|
guildName?: string;
|
||||||
|
dmTarget?: { send: (options: any) => Promise<any> };
|
||||||
|
timeoutTarget?: { timeout: (duration: number, reason: string) => Promise<any> };
|
||||||
|
}) {
|
||||||
|
const moderationCase = await this.createCase({
|
||||||
|
type: CaseType.WARN,
|
||||||
|
userId: options.userId,
|
||||||
|
username: options.username,
|
||||||
|
moderatorId: options.moderatorId,
|
||||||
|
moderatorName: options.moderatorName,
|
||||||
|
reason: options.reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!moderationCase) {
|
||||||
|
throw new Error("Failed to create moderation case");
|
||||||
|
}
|
||||||
|
|
||||||
|
const warningCount = await this.getActiveWarningCount(options.userId);
|
||||||
|
|
||||||
|
// Try to DM the user if configured
|
||||||
|
if (config.moderation.cases.dmOnWarn && options.dmTarget) {
|
||||||
|
try {
|
||||||
|
await options.dmTarget.send({
|
||||||
|
embeds: [getUserWarningEmbed(
|
||||||
|
options.guildName || 'this server',
|
||||||
|
options.reason,
|
||||||
|
moderationCase.caseId,
|
||||||
|
warningCount
|
||||||
|
)]
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Could not DM warning to ${options.username}: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for auto-timeout threshold
|
||||||
|
let autoTimeoutIssued = false;
|
||||||
|
if (config.moderation.cases.autoTimeoutThreshold &&
|
||||||
|
warningCount >= config.moderation.cases.autoTimeoutThreshold &&
|
||||||
|
options.timeoutTarget) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Auto-timeout for 24 hours (86400000 ms)
|
||||||
|
await options.timeoutTarget.timeout(86400000, `Automatic timeout: ${warningCount} warnings`);
|
||||||
|
|
||||||
|
// Create a timeout case
|
||||||
|
await this.createCase({
|
||||||
|
type: CaseType.TIMEOUT,
|
||||||
|
userId: options.userId,
|
||||||
|
username: options.username,
|
||||||
|
moderatorId: "0", // System/Bot
|
||||||
|
moderatorName: "System",
|
||||||
|
reason: `Automatic timeout: reached ${warningCount} warnings`,
|
||||||
|
metadata: { duration: '24h', automatic: true }
|
||||||
|
});
|
||||||
|
autoTimeoutIssued = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to auto-timeout user:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { moderationCase, warningCount, autoTimeoutIssued };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a case by its case ID
|
* Get a case by its case ID
|
||||||
*/
|
*/
|
||||||
@@ -79,7 +155,7 @@ export class ModerationService {
|
|||||||
return await DrizzleClient.query.moderationCases.findMany({
|
return await DrizzleClient.query.moderationCases.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(moderationCases.userId, BigInt(userId)),
|
eq(moderationCases.userId, BigInt(userId)),
|
||||||
eq(moderationCases.type, 'warn'),
|
eq(moderationCases.type, CaseType.WARN),
|
||||||
eq(moderationCases.active, true)
|
eq(moderationCases.active, true)
|
||||||
),
|
),
|
||||||
orderBy: [desc(moderationCases.createdAt)],
|
orderBy: [desc(moderationCases.createdAt)],
|
||||||
@@ -93,7 +169,7 @@ export class ModerationService {
|
|||||||
return await DrizzleClient.query.moderationCases.findMany({
|
return await DrizzleClient.query.moderationCases.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(moderationCases.userId, BigInt(userId)),
|
eq(moderationCases.userId, BigInt(userId)),
|
||||||
eq(moderationCases.type, 'note')
|
eq(moderationCases.type, CaseType.NOTE)
|
||||||
),
|
),
|
||||||
orderBy: [desc(moderationCases.createdAt)],
|
orderBy: [desc(moderationCases.createdAt)],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export type CaseType = 'warn' | 'timeout' | 'kick' | 'ban' | 'note' | 'prune';
|
import { CaseType } from "@/lib/constants";
|
||||||
|
|
||||||
|
export { CaseType };
|
||||||
|
|
||||||
export interface CreateCaseOptions {
|
export interface CreateCaseOptions {
|
||||||
type: CaseType;
|
type: CaseType;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { economyService } from "@/modules/economy/economy.service";
|
|||||||
import { levelingService } from "@/modules/leveling/leveling.service";
|
import { levelingService } from "@/modules/leveling/leveling.service";
|
||||||
import { withTransaction } from "@/lib/db";
|
import { withTransaction } from "@/lib/db";
|
||||||
import type { Transaction } from "@/lib/types";
|
import type { Transaction } from "@/lib/types";
|
||||||
|
import { TransactionType } from "@/lib/constants";
|
||||||
|
|
||||||
export const questService = {
|
export const questService = {
|
||||||
assignQuest: async (userId: string, questId: number, tx?: Transaction) => {
|
assignQuest: async (userId: string, questId: number, tx?: Transaction) => {
|
||||||
@@ -62,7 +63,7 @@ export const questService = {
|
|||||||
|
|
||||||
if (rewards?.balance) {
|
if (rewards?.balance) {
|
||||||
const bal = BigInt(rewards.balance);
|
const bal = BigInt(rewards.balance);
|
||||||
await economyService.modifyUserBalance(userId, bal, 'QUEST_REWARD', `Reward for quest ${questId}`, null, txFn);
|
await economyService.modifyUserBalance(userId, bal, TransactionType.QUEST_REWARD, `Reward for quest ${questId}`, null, txFn);
|
||||||
results.balance = bal;
|
results.balance = bal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,86 +1,21 @@
|
|||||||
import { userTimers } from "@/db/schema";
|
import { temporaryRoleService } from "./temp-role.service";
|
||||||
import { eq, and, lt } from "drizzle-orm";
|
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
|
||||||
import { env } from "@/lib/env";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Janitor responsible for cleaning up expired ACCESS timers
|
|
||||||
* and revoking privileges.
|
|
||||||
*/
|
|
||||||
export const schedulerService = {
|
export const schedulerService = {
|
||||||
start: () => {
|
start: () => {
|
||||||
console.log("🕒 Scheduler started: Janitor loop running every 60s");
|
console.log("🕒 Scheduler started: Maintenance loops initialized.");
|
||||||
// Run immediately on start
|
|
||||||
schedulerService.runJanitor();
|
|
||||||
|
|
||||||
// Loop every 60 seconds
|
// 1. Temporary Role Revocation (every 60s)
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
schedulerService.runJanitor();
|
temporaryRoleService.processExpiredRoles();
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|
||||||
// Terminal Update Loop (every 60s)
|
// 2. Terminal Update Loop (every 60s)
|
||||||
const { terminalService } = require("@/modules/terminal/terminal.service");
|
const { terminalService } = require("@/modules/terminal/terminal.service");
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
terminalService.update();
|
terminalService.update();
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
},
|
|
||||||
|
|
||||||
runJanitor: async () => {
|
// Run an initial check on start
|
||||||
try {
|
temporaryRoleService.processExpiredRoles();
|
||||||
// Find all expired ACCESS timers
|
|
||||||
// We do this in a transaction to ensure we read and delete atomically if possible,
|
|
||||||
// though for this specific case, fetching then deleting is fine as long as we handle race conditions gracefully.
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const expiredAccess = await DrizzleClient.query.userTimers.findMany({
|
|
||||||
where: and(
|
|
||||||
eq(userTimers.type, 'ACCESS'),
|
|
||||||
lt(userTimers.expiresAt, now)
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (expiredAccess.length === 0) return;
|
|
||||||
|
|
||||||
console.log(`🧹 Janitor: Found ${expiredAccess.length} expired access timers.`);
|
|
||||||
|
|
||||||
for (const timer of expiredAccess) {
|
|
||||||
const meta = timer.metadata as any;
|
|
||||||
const userIdStr = timer.userId.toString();
|
|
||||||
|
|
||||||
// Specific Handling for Roles
|
|
||||||
if (timer.key.startsWith('role_')) {
|
|
||||||
try {
|
|
||||||
const roleId = meta?.roleId || timer.key.replace('role_', '');
|
|
||||||
const guildId = env.DISCORD_GUILD_ID;
|
|
||||||
|
|
||||||
if (guildId) {
|
|
||||||
// We try to fetch, if bot is not in guild or lacks perms, it will catch
|
|
||||||
const guild = await AuroraClient.guilds.fetch(guildId);
|
|
||||||
const member = await guild.members.fetch(userIdStr);
|
|
||||||
await member.roles.remove(roleId);
|
|
||||||
console.log(`👋 Removed temporary role ${roleId} from ${member.user.tag}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to remove role for user ${userIdStr}:`, err);
|
|
||||||
// We still delete the timer so we don't loop forever on a left user
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log(`🚫 Revoking access for User ${timer.userId}: Key=${timer.key} (Channel: ${meta?.channelId || 'N/A'})`);
|
|
||||||
// TODO: Generic channel permission removal if needed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the timer row
|
|
||||||
await DrizzleClient.delete(userTimers)
|
|
||||||
.where(and(
|
|
||||||
eq(userTimers.userId, timer.userId),
|
|
||||||
eq(userTimers.type, timer.type),
|
|
||||||
eq(userTimers.key, timer.key)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Janitor Error:", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
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 { AuroraClient } from "@/lib/BotClient";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||||
import { users, transactions, lootdrops } from "@/db/schema";
|
import { users, transactions, lootdrops, inventory } from "@/db/schema";
|
||||||
import { desc } from "drizzle-orm";
|
import { desc, sql } from "drizzle-orm";
|
||||||
import { config, saveConfig } from "@/lib/config";
|
import { config, saveConfig } from "@/lib/config";
|
||||||
|
|
||||||
|
// Color palette for containers (hex as decimal)
|
||||||
|
const COLORS = {
|
||||||
|
HEADER: 0x9B59B6, // Purple - mystical
|
||||||
|
LEADERS: 0xF1C40F, // Gold - achievement
|
||||||
|
ACTIVITY: 0x3498DB, // Blue - activity
|
||||||
|
ALERT: 0xE74C3C // Red - active events
|
||||||
|
};
|
||||||
|
|
||||||
export const terminalService = {
|
export const terminalService = {
|
||||||
init: async (channel: TextChannel) => {
|
init: async (channel: TextChannel) => {
|
||||||
// limit to one terminal for now
|
// Limit to one terminal for now
|
||||||
if (config.terminal) {
|
if (config.terminal) {
|
||||||
try {
|
try {
|
||||||
const oldChannel = await AuroraClient.channels.fetch(config.terminal.channelId) as TextChannel;
|
const oldChannel = await AuroraClient.channels.fetch(config.terminal.channelId) as TextChannel;
|
||||||
@@ -16,11 +33,11 @@ export const terminalService = {
|
|||||||
if (oldMsg) await oldMsg.delete();
|
if (oldMsg) await oldMsg.delete();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore if old message doesn't exist
|
// Ignore if old message doesn't exist
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const msg = await channel.send({ content: "🔄 Initializing Aurora Observatory..." });
|
const msg = await channel.send({ content: "🔄 Initializing Aurora Station..." });
|
||||||
|
|
||||||
config.terminal = {
|
config.terminal = {
|
||||||
channelId: channel.id,
|
channelId: channel.id,
|
||||||
@@ -48,8 +65,6 @@ export const terminalService = {
|
|||||||
|
|
||||||
const containers = await terminalService.buildMessage();
|
const containers = await terminalService.buildMessage();
|
||||||
|
|
||||||
// Components V2 requires the IsComponentsV2 flag and no content/embeds
|
|
||||||
// Disable allowedMentions to prevent pings from the dashboard
|
|
||||||
await message.edit({
|
await message.edit({
|
||||||
content: null,
|
content: null,
|
||||||
embeds: null as any,
|
embeds: null as any,
|
||||||
@@ -64,24 +79,51 @@ export const terminalService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
buildMessage: async () => {
|
buildMessage: async () => {
|
||||||
// 1. Data Fetching
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// DATA FETCHING
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const allUsers = await DrizzleClient.select().from(users);
|
const allUsers = await DrizzleClient.select().from(users);
|
||||||
const totalUsers = allUsers.length;
|
const totalUsers = allUsers.length;
|
||||||
const totalWealth = allUsers.reduce((acc, u) => acc + (u.balance || 0n), 0n);
|
const totalWealth = allUsers.reduce((acc, u) => acc + (u.balance || 0n), 0n);
|
||||||
|
|
||||||
// 2. Leaderboards Calculation
|
// System stats
|
||||||
const topLevels = [...allUsers].sort((a, b) => (b.level || 0) - (a.level || 0)).slice(0, 3);
|
const uptime = process.uptime();
|
||||||
const topWealth = [...allUsers].sort((a, b) => Number(b.balance || 0n) - Number(a.balance || 0n)).slice(0, 3);
|
const uptimeHours = Math.floor(uptime / 3600);
|
||||||
|
const uptimeMinutes = Math.floor((uptime % 3600) / 60);
|
||||||
|
const ping = AuroraClient.ws.ping;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
const formatUser = (u: typeof users.$inferSelect, i: number) => {
|
// Guild member count (if available)
|
||||||
const star = i === 0 ? "🌟" : i === 1 ? "⭐" : "✨";
|
const guild = AuroraClient.guilds.cache.first();
|
||||||
return `${star} <@${u.id}>`;
|
const memberCount = guild?.memberCount ?? totalUsers;
|
||||||
};
|
|
||||||
|
|
||||||
const levelText = topLevels.map((u, i) => `> ${formatUser(u, i)} • Lvl ${u.level}`).join("\n") || "> *The sky is empty...*";
|
// Additional metrics
|
||||||
const wealthText = topWealth.map((u, i) => `> ${formatUser(u, i)} • ${u.balance} AU`).join("\n") || "> *The sky is empty...*";
|
const avgLevel = totalUsers > 0
|
||||||
|
? Math.round(allUsers.reduce((acc, u) => acc + (u.level || 1), 0) / totalUsers)
|
||||||
|
: 1;
|
||||||
|
const topStreak = allUsers.reduce((max, u) => Math.max(max, u.dailyStreak || 0), 0);
|
||||||
|
|
||||||
// 3. Lootdrops Data
|
// Items in circulation
|
||||||
|
const itemsResult = await DrizzleClient
|
||||||
|
.select({ total: sql<string>`COALESCE(SUM(${inventory.quantity}), 0)` })
|
||||||
|
.from(inventory);
|
||||||
|
const totalItems = Number(itemsResult[0]?.total || 0);
|
||||||
|
|
||||||
|
// Last command timestamp
|
||||||
|
const lastCmd = AuroraClient.lastCommandTimestamp
|
||||||
|
? `<t:${Math.floor(AuroraClient.lastCommandTimestamp / 1000)}:R>`
|
||||||
|
: "*Never*";
|
||||||
|
|
||||||
|
// Leaderboards
|
||||||
|
const topLevels = [...allUsers]
|
||||||
|
.sort((a, b) => (b.level || 0) - (a.level || 0))
|
||||||
|
.slice(0, 3);
|
||||||
|
const topWealth = [...allUsers]
|
||||||
|
.sort((a, b) => Number(b.balance || 0n) - Number(a.balance || 0n))
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
// Lootdrops
|
||||||
const activeDrops = await DrizzleClient.query.lootdrops.findMany({
|
const activeDrops = await DrizzleClient.query.lootdrops.findMany({
|
||||||
where: (lootdrops, { isNull }) => isNull(lootdrops.claimedBy),
|
where: (lootdrops, { isNull }) => isNull(lootdrops.claimedBy),
|
||||||
limit: 1,
|
limit: 1,
|
||||||
@@ -94,65 +136,166 @@ export const terminalService = {
|
|||||||
orderBy: desc(lootdrops.createdAt)
|
orderBy: desc(lootdrops.createdAt)
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- CONTAINER 1: Header ---
|
// Recent transactions
|
||||||
const headerContainer = new ContainerBuilder()
|
|
||||||
.addTextDisplayComponents(
|
|
||||||
new TextDisplayBuilder().setContent("# 🌌 AURORA OBSERVATORY"),
|
|
||||||
new TextDisplayBuilder().setContent("*Current Moon Phase: Waxing Crescent 🌒*")
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- CONTAINER 2: Observation Log ---
|
|
||||||
let phenomenaContent = "";
|
|
||||||
|
|
||||||
if (activeDrops.length > 0 && activeDrops[0]) {
|
|
||||||
const drop = activeDrops[0];
|
|
||||||
phenomenaContent = `\n**SHOOTING STAR DETECTED**\nRadiance: \`${drop.rewardAmount} ${drop.currency}\`\nCoordinates: <#${drop.channelId}>\nImpact: <t:${Math.floor(drop.expiresAt!.getTime() / 1000)}:R>`;
|
|
||||||
} else if (recentDrops.length > 0 && recentDrops[0]) {
|
|
||||||
const drop = recentDrops[0];
|
|
||||||
const claimer = allUsers.find(u => u.id === drop.claimedBy);
|
|
||||||
phenomenaContent = `\n**RECENT EVENT**\nStar yielded \`${drop.rewardAmount} ${drop.currency}\` to ${claimer ? `<@${claimer.id}>` : '**Unknown**'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const logContainer = new ContainerBuilder()
|
|
||||||
.addTextDisplayComponents(
|
|
||||||
new TextDisplayBuilder().setContent("## 🔭 OBSERVATION LOG"),
|
|
||||||
new TextDisplayBuilder().setContent(`> **Stargazers**: \`${totalUsers}\`\n> **Astral Wealth**: \`${totalWealth.toLocaleString()} AU\`${phenomenaContent}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- CONTAINER 3: Leaders ---
|
|
||||||
const leaderContainer = new ContainerBuilder()
|
|
||||||
.addTextDisplayComponents(
|
|
||||||
new TextDisplayBuilder().setContent("## ✨ CONSTELLATION LEADERS"),
|
|
||||||
new TextDisplayBuilder().setContent(`**Brightest Stars**\n${levelText}`),
|
|
||||||
new TextDisplayBuilder().setContent(`**Gilded Nebulas**\n${wealthText}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- CONTAINER 4: Echoes ---
|
|
||||||
const recentTx = await DrizzleClient.query.transactions.findMany({
|
const recentTx = await DrizzleClient.query.transactions.findMany({
|
||||||
limit: 5,
|
limit: 3,
|
||||||
orderBy: [desc(transactions.createdAt)]
|
orderBy: [desc(transactions.createdAt)]
|
||||||
});
|
});
|
||||||
|
|
||||||
const activityLines = recentTx.map(tx => {
|
// ═══════════════════════════════════════════════════════════
|
||||||
const time = Math.floor(tx.createdAt!.getTime() / 1000);
|
// HELPER FORMATTERS
|
||||||
let icon = "💫";
|
// ═══════════════════════════════════════════════════════════
|
||||||
if (tx.type.includes("LOOT")) icon = "🌠";
|
|
||||||
if (tx.type.includes("GIFT")) icon = "🌕";
|
|
||||||
const user = allUsers.find(u => u.id === tx.userId);
|
|
||||||
|
|
||||||
// the description might contain a channel id all the way at the end
|
const getMedal = (i: number) => i === 0 ? "🥇" : i === 1 ? "🥈" : "🥉";
|
||||||
const channelId = tx.description?.split(" ").pop() || "";
|
|
||||||
const text = tx.description?.replace(channelId, "<#" + channelId + ">") || "";
|
|
||||||
return `<t:${time}:F> ${icon} ${user ? `<@${user.id}>` : '**Unknown**'}: ${text}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const echoesContainer = new ContainerBuilder()
|
const formatLeaderEntry = (u: typeof users.$inferSelect, i: number, type: 'level' | 'wealth') => {
|
||||||
|
const medal = getMedal(i);
|
||||||
|
const value = type === 'level'
|
||||||
|
? `Lvl ${u.level ?? 1}`
|
||||||
|
: `${Number(u.balance ?? 0).toLocaleString()} AU`;
|
||||||
|
return `${medal} **${u.username}** — ${value}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActivityIcon = (type: string) => {
|
||||||
|
if (type.includes("LOOT")) return "🌠";
|
||||||
|
if (type.includes("GIFT")) return "🎁";
|
||||||
|
if (type.includes("SHOP")) return "🛒";
|
||||||
|
if (type.includes("DAILY")) return "☀️";
|
||||||
|
if (type.includes("QUEST")) return "📜";
|
||||||
|
return "💫";
|
||||||
|
};
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// CONTAINER 1: HEADER - Station Overview
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const botAvatar = AuroraClient.user?.displayAvatarURL({ size: 64 }) ?? "";
|
||||||
|
|
||||||
|
const headerSection = new SectionBuilder()
|
||||||
.addTextDisplayComponents(
|
.addTextDisplayComponents(
|
||||||
new TextDisplayBuilder().setContent("## 📡 COSMIC ECHOES"),
|
new TextDisplayBuilder().setContent("# 🔮 AURORA STATION"),
|
||||||
new TextDisplayBuilder().setContent(activityLines.join("\n") || "Silence...")
|
new TextDisplayBuilder().setContent("-# Real-time server observatory")
|
||||||
|
)
|
||||||
|
.setThumbnailAccessory(
|
||||||
|
new ThumbnailBuilder().setURL(botAvatar)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const statsText = [
|
||||||
|
`📡 **Uptime** ${uptimeHours}h ${uptimeMinutes}m`,
|
||||||
|
`🏓 **Ping** ${ping}ms`,
|
||||||
|
`👥 **Students** ${totalUsers}`,
|
||||||
|
`🪙 **Economy** ${totalWealth.toLocaleString()} AU`
|
||||||
|
].join(" • ");
|
||||||
|
|
||||||
return [headerContainer, logContainer, leaderContainer, echoesContainer];
|
const secondaryStats = [
|
||||||
|
`📦 **Items** ${totalItems.toLocaleString()}`,
|
||||||
|
`📈 **Avg Lvl** ${avgLevel}`,
|
||||||
|
`🔥 **Top Streak** ${topStreak}d`,
|
||||||
|
`⚡ **Last Cmd** ${lastCmd}`
|
||||||
|
].join(" • ");
|
||||||
|
|
||||||
|
const headerContainer = new ContainerBuilder()
|
||||||
|
.setAccentColor(COLORS.HEADER)
|
||||||
|
.addSectionComponents(headerSection)
|
||||||
|
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small))
|
||||||
|
.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(statsText),
|
||||||
|
new TextDisplayBuilder().setContent(secondaryStats),
|
||||||
|
new TextDisplayBuilder().setContent(`-# Updated <t:${now}:R>`)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// CONTAINER 2: LEADERBOARDS
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const levelLeaderText = topLevels.length > 0
|
||||||
|
? topLevels.map((u, i) => formatLeaderEntry(u, i, 'level')).join("\n")
|
||||||
|
: "*No data yet*";
|
||||||
|
|
||||||
|
const wealthLeaderText = topWealth.length > 0
|
||||||
|
? topWealth.map((u, i) => formatLeaderEntry(u, i, 'wealth')).join("\n")
|
||||||
|
: "*No data yet*";
|
||||||
|
|
||||||
|
const leadersContainer = new ContainerBuilder()
|
||||||
|
.setAccentColor(COLORS.LEADERS)
|
||||||
|
.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent("## ✨ CONSTELLATION LEADERS")
|
||||||
|
)
|
||||||
|
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small))
|
||||||
|
.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(`**Brightest Stars**\n${levelLeaderText}`),
|
||||||
|
new TextDisplayBuilder().setContent(`**Gilded Nebulas**\n${wealthLeaderText}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// CONTAINER 3: LIVE ACTIVITY
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// Determine if there's an active lootdrop
|
||||||
|
const hasActiveDrop = activeDrops.length > 0 && activeDrops[0];
|
||||||
|
const activityColor = hasActiveDrop ? COLORS.ALERT : COLORS.ACTIVITY;
|
||||||
|
|
||||||
|
const activityContainer = new ContainerBuilder()
|
||||||
|
.setAccentColor(activityColor)
|
||||||
|
.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent("## 🌠 LIVE ACTIVITY")
|
||||||
|
)
|
||||||
|
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||||
|
|
||||||
|
// Active lootdrop or recent event
|
||||||
|
if (hasActiveDrop) {
|
||||||
|
const drop = activeDrops[0]!;
|
||||||
|
const expiresTimestamp = Math.floor(drop.expiresAt!.getTime() / 1000);
|
||||||
|
|
||||||
|
activityContainer.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(
|
||||||
|
`🚨 **SHOOTING STAR ACTIVE**\n` +
|
||||||
|
`> **Reward:** \`${drop.rewardAmount} ${drop.currency}\`\n` +
|
||||||
|
`> **Location:** <#${drop.channelId}>\n` +
|
||||||
|
`> **Expires:** <t:${expiresTimestamp}:R>`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else if (recentDrops.length > 0 && recentDrops[0]) {
|
||||||
|
const drop = recentDrops[0];
|
||||||
|
const claimer = allUsers.find(u => u.id === drop.claimedBy);
|
||||||
|
const claimedTimestamp = drop.createdAt ? Math.floor(drop.createdAt.getTime() / 1000) : now;
|
||||||
|
|
||||||
|
activityContainer.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(
|
||||||
|
`✅ **Last Star Claimed**\n` +
|
||||||
|
`> **${claimer?.username ?? 'Unknown'}** collected \`${drop.rewardAmount} ${drop.currency}\` <t:${claimedTimestamp}:R>`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
activityContainer.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(`-# The sky is quiet... waiting for the next star.`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent transactions
|
||||||
|
if (recentTx.length > 0) {
|
||||||
|
activityContainer.addSeparatorComponents(
|
||||||
|
new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)
|
||||||
|
);
|
||||||
|
|
||||||
|
const txLines = recentTx.map(tx => {
|
||||||
|
const time = Math.floor(tx.createdAt!.getTime() / 1000);
|
||||||
|
const icon = getActivityIcon(tx.type);
|
||||||
|
const user = allUsers.find(u => u.id === tx.userId);
|
||||||
|
|
||||||
|
// Clean description (remove trailing channel IDs)
|
||||||
|
let desc = tx.description || "Unknown";
|
||||||
|
desc = desc.replace(/\s*\d{17,19}\s*$/, "").trim();
|
||||||
|
|
||||||
|
return `${icon} **${user?.username ?? 'Unknown'}**: ${desc} · <t:${time}:R>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
activityContainer.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent("**Recent Echoes**"),
|
||||||
|
new TextDisplayBuilder().setContent(txLines.join("\n"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [headerContainer, leadersContainer, activityContainer];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { tradeService } from "./trade.service";
|
import { tradeService } from "./trade.service";
|
||||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||||
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
|
||||||
|
import { UserError } from "@lib/errors";
|
||||||
import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view";
|
import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view";
|
||||||
|
|
||||||
|
|
||||||
@@ -22,7 +23,6 @@ export async function handleTradeInteraction(interaction: Interaction) {
|
|||||||
|
|
||||||
if (!threadId) return;
|
if (!threadId) return;
|
||||||
|
|
||||||
try {
|
|
||||||
if (customId === 'trade_cancel') {
|
if (customId === 'trade_cancel') {
|
||||||
await handleCancel(interaction, threadId);
|
await handleCancel(interaction, threadId);
|
||||||
} else if (customId === 'trade_lock') {
|
} else if (customId === 'trade_lock') {
|
||||||
@@ -44,14 +44,6 @@ export async function handleTradeInteraction(interaction: Interaction) {
|
|||||||
} else if (customId === 'trade_remove_item_select') {
|
} else if (customId === 'trade_remove_item_select') {
|
||||||
await handleRemoveItemSelect(interaction as StringSelectMenuInteraction, threadId);
|
await handleRemoveItemSelect(interaction as StringSelectMenuInteraction, threadId);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
|
||||||
const errorEmbed = createErrorEmbed(error.message);
|
|
||||||
if (interaction.replied || interaction.deferred) {
|
|
||||||
await interaction.followUp({ embeds: [errorEmbed], ephemeral: true });
|
|
||||||
} else {
|
|
||||||
await interaction.reply({ embeds: [errorEmbed], ephemeral: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
|
async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
|
||||||
@@ -93,7 +85,7 @@ async function handleMoneySubmit(interaction: ModalSubmitInteraction, threadId:
|
|||||||
const amountStr = interaction.fields.getTextInputValue('amount');
|
const amountStr = interaction.fields.getTextInputValue('amount');
|
||||||
const amount = BigInt(amountStr);
|
const amount = BigInt(amountStr);
|
||||||
|
|
||||||
if (amount < 0n) throw new Error("Amount must be positive");
|
if (amount < 0n) throw new UserError("Amount must be positive");
|
||||||
|
|
||||||
tradeService.updateMoney(threadId, interaction.user.id, amount);
|
tradeService.updateMoney(threadId, interaction.user.id, amount);
|
||||||
await interaction.deferUpdate(); // Acknowledge modal
|
await interaction.deferUpdate(); // Acknowledge modal
|
||||||
@@ -126,7 +118,7 @@ async function handleItemSelect(interaction: StringSelectMenuInteraction, thread
|
|||||||
|
|
||||||
// Assuming implementation implies adding 1 item for now
|
// Assuming implementation implies adding 1 item for now
|
||||||
const item = await inventoryService.getItem(itemId);
|
const item = await inventoryService.getItem(itemId);
|
||||||
if (!item) throw new Error("Item not found");
|
if (!item) throw new UserError("Item not found");
|
||||||
|
|
||||||
tradeService.addItem(threadId, interaction.user.id, { id: item.id, name: item.name }, 1n);
|
tradeService.addItem(threadId, interaction.user.id, { id: item.id, name: item.name }, 1n);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
|
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
|
||||||
import { TradeService } from "./trade.service";
|
import { tradeService } from "./trade.service";
|
||||||
import { itemTransactions } from "@/db/schema";
|
import { itemTransactions } from "@/db/schema";
|
||||||
import { economyService } from "@/modules/economy/economy.service";
|
import { economyService } from "@/modules/economy/economy.service";
|
||||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||||
@@ -37,7 +37,7 @@ describe("TradeService", () => {
|
|||||||
mockValues.mockClear();
|
mockValues.mockClear();
|
||||||
|
|
||||||
// Clear sessions
|
// Clear sessions
|
||||||
(TradeService as any).sessions.clear();
|
(tradeService as any)._sessions.clear();
|
||||||
|
|
||||||
// Spies
|
// Spies
|
||||||
mockModifyUserBalance = spyOn(economyService, 'modifyUserBalance').mockResolvedValue({} as any);
|
mockModifyUserBalance = spyOn(economyService, 'modifyUserBalance').mockResolvedValue({} as any);
|
||||||
@@ -54,68 +54,68 @@ describe("TradeService", () => {
|
|||||||
|
|
||||||
describe("createSession", () => {
|
describe("createSession", () => {
|
||||||
it("should create a new session", () => {
|
it("should create a new session", () => {
|
||||||
const session = TradeService.createSession("thread1", userA, userB);
|
const session = tradeService.createSession("thread1", userA, userB);
|
||||||
|
|
||||||
expect(session.threadId).toBe("thread1");
|
expect(session.threadId).toBe("thread1");
|
||||||
expect(session.state).toBe("NEGOTIATING");
|
expect(session.state).toBe("NEGOTIATING");
|
||||||
expect(session.userA.id).toBe("1");
|
expect(session.userA.id).toBe("1");
|
||||||
expect(session.userB.id).toBe("2");
|
expect(session.userB.id).toBe("2");
|
||||||
expect(TradeService.getSession("thread1")).toBe(session);
|
expect(tradeService.getSession("thread1")).toBe(session);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("updateMoney", () => {
|
describe("updateMoney", () => {
|
||||||
it("should update money offer", () => {
|
it("should update money offer", () => {
|
||||||
TradeService.createSession("thread1", userA, userB);
|
tradeService.createSession("thread1", userA, userB);
|
||||||
TradeService.updateMoney("thread1", "1", 100n);
|
tradeService.updateMoney("thread1", "1", 100n);
|
||||||
|
|
||||||
const session = TradeService.getSession("thread1");
|
const session = tradeService.getSession("thread1");
|
||||||
expect(session?.userA.offer.money).toBe(100n);
|
expect(session?.userA.offer.money).toBe(100n);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should unlock participants when offer changes", () => {
|
it("should unlock participants when offer changes", () => {
|
||||||
const session = TradeService.createSession("thread1", userA, userB);
|
const session = tradeService.createSession("thread1", userA, userB);
|
||||||
session.userA.locked = true;
|
session.userA.locked = true;
|
||||||
session.userB.locked = true;
|
session.userB.locked = true;
|
||||||
|
|
||||||
TradeService.updateMoney("thread1", "1", 100n);
|
tradeService.updateMoney("thread1", "1", 100n);
|
||||||
|
|
||||||
expect(session.userA.locked).toBe(false);
|
expect(session.userA.locked).toBe(false);
|
||||||
expect(session.userB.locked).toBe(false);
|
expect(session.userB.locked).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw if not in trade", () => {
|
it("should throw if not in trade", () => {
|
||||||
TradeService.createSession("thread1", userA, userB);
|
tradeService.createSession("thread1", userA, userB);
|
||||||
expect(() => TradeService.updateMoney("thread1", "3", 100n)).toThrow("User not in trade");
|
expect(() => tradeService.updateMoney("thread1", "3", 100n)).toThrow("User not in trade");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("addItem", () => {
|
describe("addItem", () => {
|
||||||
it("should add item to offer", () => {
|
it("should add item to offer", () => {
|
||||||
TradeService.createSession("thread1", userA, userB);
|
tradeService.createSession("thread1", userA, userB);
|
||||||
TradeService.addItem("thread1", "1", { id: 10, name: "Sword" }, 1n);
|
tradeService.addItem("thread1", "1", { id: 10, name: "Sword" }, 1n);
|
||||||
|
|
||||||
const session = TradeService.getSession("thread1");
|
const session = tradeService.getSession("thread1");
|
||||||
expect(session?.userA.offer.items).toHaveLength(1);
|
expect(session?.userA.offer.items).toHaveLength(1);
|
||||||
expect(session?.userA.offer.items[0]).toEqual({ id: 10, name: "Sword", quantity: 1n });
|
expect(session?.userA.offer.items[0]).toEqual({ id: 10, name: "Sword", quantity: 1n });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should stack items if already offered", () => {
|
it("should stack items if already offered", () => {
|
||||||
TradeService.createSession("thread1", userA, userB);
|
tradeService.createSession("thread1", userA, userB);
|
||||||
TradeService.addItem("thread1", "1", { id: 10, name: "Sword" }, 1n);
|
tradeService.addItem("thread1", "1", { id: 10, name: "Sword" }, 1n);
|
||||||
TradeService.addItem("thread1", "1", { id: 10, name: "Sword" }, 2n);
|
tradeService.addItem("thread1", "1", { id: 10, name: "Sword" }, 2n);
|
||||||
|
|
||||||
const session = TradeService.getSession("thread1");
|
const session = tradeService.getSession("thread1");
|
||||||
expect(session?.userA.offer.items[0]!.quantity).toBe(3n);
|
expect(session?.userA.offer.items[0]!.quantity).toBe(3n);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("removeItem", () => {
|
describe("removeItem", () => {
|
||||||
it("should remove item from offer", () => {
|
it("should remove item from offer", () => {
|
||||||
const session = TradeService.createSession("thread1", userA, userB);
|
const session = tradeService.createSession("thread1", userA, userB);
|
||||||
session.userA.offer.items.push({ id: 10, name: "Sword", quantity: 1n });
|
session.userA.offer.items.push({ id: 10, name: "Sword", quantity: 1n });
|
||||||
|
|
||||||
TradeService.removeItem("thread1", "1", 10);
|
tradeService.removeItem("thread1", "1", 10);
|
||||||
|
|
||||||
expect(session.userA.offer.items).toHaveLength(0);
|
expect(session.userA.offer.items).toHaveLength(0);
|
||||||
});
|
});
|
||||||
@@ -123,19 +123,19 @@ describe("TradeService", () => {
|
|||||||
|
|
||||||
describe("toggleLock", () => {
|
describe("toggleLock", () => {
|
||||||
it("should toggle lock status", () => {
|
it("should toggle lock status", () => {
|
||||||
TradeService.createSession("thread1", userA, userB);
|
tradeService.createSession("thread1", userA, userB);
|
||||||
|
|
||||||
const locked1 = TradeService.toggleLock("thread1", "1");
|
const locked1 = tradeService.toggleLock("thread1", "1");
|
||||||
expect(locked1).toBe(true);
|
expect(locked1).toBe(true);
|
||||||
|
|
||||||
const locked2 = TradeService.toggleLock("thread1", "1");
|
const locked2 = tradeService.toggleLock("thread1", "1");
|
||||||
expect(locked2).toBe(false);
|
expect(locked2).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("executeTrade", () => {
|
describe("executeTrade", () => {
|
||||||
it("should execute trade successfully", async () => {
|
it("should execute trade successfully", async () => {
|
||||||
const session = TradeService.createSession("thread1", userA, userB);
|
const session = tradeService.createSession("thread1", userA, userB);
|
||||||
|
|
||||||
// Setup offers
|
// Setup offers
|
||||||
session.userA.offer.money = 100n;
|
session.userA.offer.money = 100n;
|
||||||
@@ -148,7 +148,7 @@ describe("TradeService", () => {
|
|||||||
session.userA.locked = true;
|
session.userA.locked = true;
|
||||||
session.userB.locked = true;
|
session.userB.locked = true;
|
||||||
|
|
||||||
await TradeService.executeTrade("thread1");
|
await tradeService.executeTrade("thread1");
|
||||||
|
|
||||||
expect(session.state).toBe("COMPLETED");
|
expect(session.state).toBe("COMPLETED");
|
||||||
|
|
||||||
@@ -171,11 +171,11 @@ describe("TradeService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should throw if not locked", async () => {
|
it("should throw if not locked", async () => {
|
||||||
const session = TradeService.createSession("thread1", userA, userB);
|
const session = tradeService.createSession("thread1", userA, userB);
|
||||||
session.userA.locked = true;
|
session.userA.locked = true;
|
||||||
// B not locked
|
// B not locked
|
||||||
|
|
||||||
expect(TradeService.executeTrade("thread1")).rejects.toThrow("Both players must accept");
|
expect(tradeService.executeTrade("thread1")).rejects.toThrow("Both players must accept");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { inventoryService } from "@/modules/inventory/inventory.service";
|
|||||||
import { itemTransactions } from "@/db/schema";
|
import { itemTransactions } from "@/db/schema";
|
||||||
import { withTransaction } from "@/lib/db";
|
import { withTransaction } from "@/lib/db";
|
||||||
import type { Transaction } from "@/lib/types";
|
import type { Transaction } from "@/lib/types";
|
||||||
|
import { TransactionType, ItemTransactionType } from "@/lib/constants";
|
||||||
|
|
||||||
// Module-level session storage
|
// Module-level session storage
|
||||||
const sessions = new Map<string, TradeSession>();
|
const sessions = new Map<string, TradeSession>();
|
||||||
@@ -25,7 +26,7 @@ const processTransfer = async (tx: Transaction, from: TradeParticipant, to: Trad
|
|||||||
await economyService.modifyUserBalance(
|
await economyService.modifyUserBalance(
|
||||||
from.id,
|
from.id,
|
||||||
-from.offer.money,
|
-from.offer.money,
|
||||||
'TRADE_OUT',
|
TransactionType.TRADE_OUT,
|
||||||
`Trade with ${to.username} (Thread: ${threadId})`,
|
`Trade with ${to.username} (Thread: ${threadId})`,
|
||||||
to.id,
|
to.id,
|
||||||
tx
|
tx
|
||||||
@@ -33,7 +34,7 @@ const processTransfer = async (tx: Transaction, from: TradeParticipant, to: Trad
|
|||||||
await economyService.modifyUserBalance(
|
await economyService.modifyUserBalance(
|
||||||
to.id,
|
to.id,
|
||||||
from.offer.money,
|
from.offer.money,
|
||||||
'TRADE_IN',
|
TransactionType.TRADE_IN,
|
||||||
`Trade with ${from.username} (Thread: ${threadId})`,
|
`Trade with ${from.username} (Thread: ${threadId})`,
|
||||||
from.id,
|
from.id,
|
||||||
tx
|
tx
|
||||||
@@ -54,7 +55,7 @@ const processTransfer = async (tx: Transaction, from: TradeParticipant, to: Trad
|
|||||||
relatedUserId: BigInt(to.id),
|
relatedUserId: BigInt(to.id),
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
quantity: -item.quantity,
|
quantity: -item.quantity,
|
||||||
type: 'TRADE_OUT',
|
type: ItemTransactionType.TRADE_OUT,
|
||||||
description: `Traded to ${to.username}`,
|
description: `Traded to ${to.username}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,13 +65,15 @@ const processTransfer = async (tx: Transaction, from: TradeParticipant, to: Trad
|
|||||||
relatedUserId: BigInt(from.id),
|
relatedUserId: BigInt(from.id),
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
type: 'TRADE_IN',
|
type: ItemTransactionType.TRADE_IN,
|
||||||
description: `Received from ${from.username}`,
|
description: `Received from ${from.username}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tradeService = {
|
export const tradeService = {
|
||||||
|
// Expose for testing
|
||||||
|
_sessions: sessions,
|
||||||
/**
|
/**
|
||||||
* Creates a new trade session
|
* Creates a new trade session
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,48 +1,38 @@
|
|||||||
import { ButtonInteraction, MessageFlags } from "discord.js";
|
import { ButtonInteraction, MessageFlags } from "discord.js";
|
||||||
import { config } from "@/lib/config";
|
import { config } from "@/lib/config";
|
||||||
import { getEnrollmentErrorEmbed, getEnrollmentSuccessMessage } from "./enrollment.view";
|
import { getEnrollmentSuccessMessage } from "./enrollment.view";
|
||||||
import { classService } from "@modules/class/class.service";
|
import { classService } from "@modules/class/class.service";
|
||||||
import { userService } from "@modules/user/user.service";
|
import { userService } from "@modules/user/user.service";
|
||||||
|
import { UserError } from "@/lib/errors";
|
||||||
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
||||||
|
|
||||||
export async function handleEnrollmentInteraction(interaction: ButtonInteraction) {
|
export async function handleEnrollmentInteraction(interaction: ButtonInteraction) {
|
||||||
if (!interaction.inCachedGuild()) {
|
if (!interaction.inCachedGuild()) {
|
||||||
await interaction.reply({ content: "This action can only be performed in a server.", flags: MessageFlags.Ephemeral });
|
throw new UserError("This action can only be performed in a server.");
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { studentRole, visitorRole } = config;
|
const { studentRole, visitorRole } = config;
|
||||||
|
|
||||||
if (!studentRole || !visitorRole) {
|
if (!studentRole || !visitorRole) {
|
||||||
await interaction.reply({
|
throw new UserError("No student or visitor role configured for enrollment.");
|
||||||
...getEnrollmentErrorEmbed("No student or visitor role configured for enrollment.", "Configuration Error"),
|
|
||||||
flags: MessageFlags.Ephemeral
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Ensure user exists in DB and check current enrollment status
|
// 1. Ensure user exists in DB and check current enrollment status
|
||||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||||
|
if (!user) {
|
||||||
|
throw new UserError("User profiles could not be loaded. Please try again later.");
|
||||||
|
}
|
||||||
|
|
||||||
// Check DB enrollment
|
// Check DB enrollment
|
||||||
if (user.class) {
|
if (user.class) {
|
||||||
await interaction.reply({
|
throw new UserError("You are already enrolled in a class.");
|
||||||
...getEnrollmentErrorEmbed("You are already enrolled in a class.", "Enrollment Failed"),
|
|
||||||
flags: MessageFlags.Ephemeral
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const member = interaction.member;
|
const member = interaction.member;
|
||||||
|
|
||||||
// Check Discord role enrollment (Double safety)
|
// Check Discord role enrollment (Double safety)
|
||||||
if (member.roles.cache.has(studentRole)) {
|
if (member.roles.cache.has(studentRole)) {
|
||||||
await interaction.reply({
|
throw new UserError("You already have the student role.");
|
||||||
...getEnrollmentErrorEmbed("You already have the student role.", "Enrollment Failed"),
|
|
||||||
flags: MessageFlags.Ephemeral
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Get available classes
|
// 2. Get available classes
|
||||||
@@ -50,11 +40,7 @@ export async function handleEnrollmentInteraction(interaction: ButtonInteraction
|
|||||||
const validClasses = allClasses.filter(c => c.roleId);
|
const validClasses = allClasses.filter(c => c.roleId);
|
||||||
|
|
||||||
if (validClasses.length === 0) {
|
if (validClasses.length === 0) {
|
||||||
await interaction.reply({
|
throw new UserError("No classes with specified roles found in database.");
|
||||||
...getEnrollmentErrorEmbed("No classes with specified roles found in database.", "Configuration Error"),
|
|
||||||
flags: MessageFlags.Ephemeral
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Pick random class
|
// 3. Pick random class
|
||||||
@@ -64,15 +50,10 @@ export async function handleEnrollmentInteraction(interaction: ButtonInteraction
|
|||||||
// Check if the role exists in the guild
|
// Check if the role exists in the guild
|
||||||
const classRole = interaction.guild.roles.cache.get(classRoleId);
|
const classRole = interaction.guild.roles.cache.get(classRoleId);
|
||||||
if (!classRole) {
|
if (!classRole) {
|
||||||
await interaction.reply({
|
throw new UserError(`The configured role ID \`${classRoleId}\` for class **${selectedClass.name}** does not exist in this server.`);
|
||||||
...getEnrollmentErrorEmbed(`The configured role ID \`${classRoleId}\` for class **${selectedClass.name}** does not exist in this server.`, "Configuration Error"),
|
|
||||||
flags: MessageFlags.Ephemeral
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Perform Enrollment Actions
|
// 4. Perform Enrollment Actions
|
||||||
|
|
||||||
await member.roles.remove(visitorRole);
|
await member.roles.remove(visitorRole);
|
||||||
await member.roles.add(studentRole);
|
await member.roles.add(studentRole);
|
||||||
await member.roles.add(classRole);
|
await member.roles.add(classRole);
|
||||||
@@ -109,12 +90,4 @@ export async function handleEnrollmentInteraction(interaction: ButtonInteraction
|
|||||||
.catch((err: any) => console.error("Failed to send welcome message:", err));
|
.catch((err: any) => console.error("Failed to send welcome message:", err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Enrollment error:", error);
|
|
||||||
await interaction.reply({
|
|
||||||
...getEnrollmentErrorEmbed("An unexpected error occurred during enrollment. Please contact an administrator.", "System Error"),
|
|
||||||
flags: MessageFlags.Ephemeral
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { userTimers } from "@/db/schema";
|
import { userTimers } from "@/db/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||||
|
import { TimerType } from "@/lib/constants";
|
||||||
|
|
||||||
export type TimerType = 'COOLDOWN' | 'EFFECT' | 'ACCESS';
|
export { TimerType };
|
||||||
|
|
||||||
export const userTimerService = {
|
export const userTimerService = {
|
||||||
/**
|
/**
|
||||||
|
|||||||
108
src/web/public/script.js
Normal file
108
src/web/public/script.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
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 el = document.getElementById("uptime-display");
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
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", () => {
|
||||||
|
// 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") {
|
||||||
|
console.log("Heartbeat:", msg.data);
|
||||||
|
// Sync uptime?
|
||||||
|
// We can optionally verify if client clock is drifting, but let's keep it simple.
|
||||||
|
} 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 appendToActivityFeed(log) {
|
||||||
|
const list = document.querySelector(".activity-feed");
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
const item = document.createElement("li");
|
||||||
|
item.className = `activity-item ${log.type}`;
|
||||||
|
|
||||||
|
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 to top
|
||||||
|
list.insertBefore(item, list.firstChild);
|
||||||
|
|
||||||
|
// Limit history
|
||||||
|
if (list.children.length > 50) {
|
||||||
|
list.removeChild(list.lastChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
connectWs();
|
||||||
|
});
|
||||||
607
src/web/public/style.css
Normal file
607
src/web/public/style.css
Normal file
@@ -0,0 +1,607 @@
|
|||||||
|
:root {
|
||||||
|
/* Color Palette - HSL (Hue, Saturation, Lightness) */
|
||||||
|
/* Primary (Aurora Cyan) */
|
||||||
|
--primary-h: 180;
|
||||||
|
--primary-s: 100%;
|
||||||
|
--primary-l: 50%;
|
||||||
|
--primary: hsl(var(--primary-h), var(--primary-s), var(--primary-l));
|
||||||
|
|
||||||
|
/* Secondary (Aurora Purple) */
|
||||||
|
--secondary-h: 270;
|
||||||
|
--secondary-s: 100%;
|
||||||
|
--secondary-l: 65%;
|
||||||
|
--secondary: hsl(var(--secondary-h), var(--secondary-s), var(--secondary-l));
|
||||||
|
|
||||||
|
/* Backgrounds (Dark Slate) */
|
||||||
|
--bg-h: 222;
|
||||||
|
--bg-s: 47%;
|
||||||
|
--bg-l: 7%;
|
||||||
|
/* Very Dark */
|
||||||
|
--bg-color: hsl(var(--bg-h), var(--bg-s), var(--bg-l));
|
||||||
|
|
||||||
|
--card-bg-h: 217;
|
||||||
|
--card-bg-s: 33%;
|
||||||
|
--card-bg-l: 15%;
|
||||||
|
--card-bg: hsl(var(--card-bg-h), var(--card-bg-s), var(--card-bg-l));
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text-main: hsl(210, 40%, 98%);
|
||||||
|
--text-muted: hsl(215, 20%, 65%);
|
||||||
|
--text-accent: var(--primary);
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border-color: hsl(215, 25%, 25%);
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-heading: 'Outfit', system-ui, sans-serif;
|
||||||
|
--font-body: 'Inter', system-ui, sans-serif;
|
||||||
|
|
||||||
|
/* Spacing & Radii */
|
||||||
|
--radius-md: 0.75rem;
|
||||||
|
--radius-lg: 1rem;
|
||||||
|
--header-height: 4rem;
|
||||||
|
|
||||||
|
/* Effects */
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.2), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
--shadow-glow: 0 0 15px hsla(var(--primary-h), var(--primary-s), 50%, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-main);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
margin-top: 0;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header {
|
||||||
|
background: rgba(15, 23, 42, 0.8);
|
||||||
|
/* Semi-transparent */
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
height: var(--header-height);
|
||||||
|
padding: 0 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header nav a {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header nav a:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Layout */
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Component */
|
||||||
|
.card {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 2rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-glow), var(--shadow-md);
|
||||||
|
border-color: hsla(var(--primary-h), var(--primary-s), 50%, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
a {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons (Future Proofing) */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--primary), hsl(var(--primary-h), 90%, 45%));
|
||||||
|
color: #000;
|
||||||
|
/* Contrast text on Cyan */
|
||||||
|
box-shadow: 0 4px 6px -1px hsla(var(--primary-h), var(--primary-s), 50%, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
box-shadow: 0 6px 8px -1px hsla(var(--primary-h), var(--primary-s), 50%, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms & Inputs */
|
||||||
|
input[type="text"],
|
||||||
|
input[type="email"],
|
||||||
|
input[type="password"],
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background-color: rgba(15, 23, 42, 0.5);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-main);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 2px hsla(var(--primary-h), var(--primary-s), 50%, 0.2);
|
||||||
|
background-color: rgba(15, 23, 42, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: rgba(15, 23, 42, 0.5);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
/* Fallback or specific border */
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover td {
|
||||||
|
background-color: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
footer {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utilities */
|
||||||
|
.text-gradient {
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations & Micro-Interactions */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Entry Animations */
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.4s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stagger animations for children using nth-child */
|
||||||
|
main>* {
|
||||||
|
opacity: 0;
|
||||||
|
/* Initially hidden */
|
||||||
|
animation: slideUp 0.5s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
main>*:nth-child(1) {
|
||||||
|
animation-delay: 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
main>*:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
main>*:nth-child(3) {
|
||||||
|
animation-delay: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
main>*:nth-child(4) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dynamic Background */
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 15% 50%, hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.08), transparent 25%),
|
||||||
|
radial-gradient(circle at 85% 30%, hsla(var(--secondary-h), var(--secondary-s), var(--secondary-l), 0.08), transparent 25%);
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Link Interactions */
|
||||||
|
a {
|
||||||
|
position: relative;
|
||||||
|
transition: color 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
header nav a::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -4px;
|
||||||
|
left: 0;
|
||||||
|
width: 0%;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--primary);
|
||||||
|
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
header nav a:hover::after {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accessibility: Reduced Motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Responsiveness */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
:root {
|
||||||
|
--header-height: 3.5rem;
|
||||||
|
/* Compact header on mobile */
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-size: 14px;
|
||||||
|
/* Slightly smaller base font */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout Adjustments */
|
||||||
|
header {
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header nav a {
|
||||||
|
margin-left: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typography Scaling */
|
||||||
|
h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Adjustments */
|
||||||
|
.card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
/* Slightly smaller radius */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stack flex containers if needed (general util) */
|
||||||
|
.flex-col-mobile {
|
||||||
|
flex-direction: column !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch Targets */
|
||||||
|
.btn,
|
||||||
|
a,
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
min-height: 44px;
|
||||||
|
/* Compliance with touch target guidelines */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Horizontal scroll for wide tables */
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
margin-left: -1rem;
|
||||||
|
margin-right: -1rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Dashboard Layout */
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card h3 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .stat-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-main {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.control-panel {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Activity Feed */
|
||||||
|
.activity-feed {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-item .time {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-item.info .message { color: var(--text-main); }
|
||||||
|
.activity-item.success .message { color: hsl(150, 60%, 45%); }
|
||||||
|
.activity-item.warning .message { color: hsl(35, 90%, 60%); }
|
||||||
|
.activity-item.error .message { color: hsl(0, 80%, 60%); }
|
||||||
|
|
||||||
|
.badge.live {
|
||||||
|
background: hsla(0, 100%, 50%, 0.2);
|
||||||
|
color: hsl(0, 100%, 60%);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mock Chart */
|
||||||
|
.mock-chart-container {
|
||||||
|
height: 200px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mock-chart-bar {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--primary);
|
||||||
|
opacity: 0.5;
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
transition: height 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mock-chart-bar:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-legend {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Dashboard */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: 1fr 1fr; /* 2 columns on tablet/mobile */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-main {
|
||||||
|
grid-template-columns: 1fr; /* Stack panels */
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
src/web/router.ts
Normal file
49
src/web/router.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("Not Found", { status: 404 });
|
||||||
|
}
|
||||||
105
src/web/routes/dashboard.ts
Normal file
105
src/web/routes/dashboard.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
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 memoryUsage = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(2);
|
||||||
|
const uptimeSeconds = process.uptime();
|
||||||
|
const uptime = new Date(uptimeSeconds * 1000).toISOString().substr(11, 8); // HH:MM:SS
|
||||||
|
|
||||||
|
// Real activity logs
|
||||||
|
const activityLogs = getRecentLogs();
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
<!-- Top Stats Row -->
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Servers</h3>
|
||||||
|
<div class="stat-value">${guildCount}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Users</h3>
|
||||||
|
<div class="stat-value">${userCount}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Commands</h3>
|
||||||
|
<div class="stat-value">${commandCount}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Ping</h3>
|
||||||
|
<div class="stat-value">${ping < 0 ? "?" : ping}ms</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Area -->
|
||||||
|
<div class="dashboard-main">
|
||||||
|
<div class="panel activity-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>Live Activity</h2>
|
||||||
|
<span class="badge live">LIVE</span>
|
||||||
|
</div>
|
||||||
|
<ul class="activity-feed">
|
||||||
|
${activityLogs.length > 0 ? activityLogs.map(log => `
|
||||||
|
<li class="activity-item ${log.type}">
|
||||||
|
<span class="time">${log.time}</span>
|
||||||
|
<span class="message">${log.message}</span>
|
||||||
|
</li>
|
||||||
|
`).join('') : `
|
||||||
|
<li class="activity-item info"><span class="time">--:--:--</span> <span class="message">No recent activity.</span></li>
|
||||||
|
`}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel metrics-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>System Health</h2>
|
||||||
|
</div>
|
||||||
|
<div class="metrics-grid">
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">Uptime</span>
|
||||||
|
<span class="metric-value">${uptime}</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">Memory (Heap)</span>
|
||||||
|
<span class="metric-value">${memoryUsage} MB</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">Node Version</span>
|
||||||
|
<span class="metric-value">${process.version}</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">Platform</span>
|
||||||
|
<span class="metric-value">${process.platform}</span>
|
||||||
|
</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 btn-secondary" disabled>Clear Cache</button>
|
||||||
|
<button class="btn btn-secondary" disabled>Reload Commands</button>
|
||||||
|
<button class="btn btn-danger" disabled>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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
85
src/web/server.ts
Normal file
85
src/web/server.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { env } from "@/lib/env";
|
||||||
|
import { router } from "./router";
|
||||||
|
import type { Server } from "bun";
|
||||||
|
|
||||||
|
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 ?? (typeof env.PORT === "string" ? parseInt(env.PORT) : 3000),
|
||||||
|
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://localhost:${this.server.port}`);
|
||||||
|
|
||||||
|
// Start a heartbeat loop
|
||||||
|
this.heartbeatInterval = setInterval(() => {
|
||||||
|
if (this.server) {
|
||||||
|
const uptime = process.uptime();
|
||||||
|
this.server.publish("status-updates", JSON.stringify({
|
||||||
|
type: "HEARTBEAT",
|
||||||
|
data: {
|
||||||
|
uptime,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "'");
|
||||||
|
}
|
||||||
54
src/web/views/layout.ts
Normal file
54
src/web/views/layout.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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&family=Outfit:wght@500;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Aurora Web</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<a href="/dashboard">Dashboard</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
${content}
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<div class="footer-content">
|
||||||
|
<p>© ${new Date().getFullYear()} Aurora Bot</p>
|
||||||
|
<div class="footer-status">
|
||||||
|
<span class="status-indicator online">●</span>
|
||||||
|
<span>System Operational</span>
|
||||||
|
<span class="separator">|</span>
|
||||||
|
<span>Uptime: <span id="uptime-display" data-start-timestamp="${Math.floor(startTimestamp)}">${initialUptimeString}</span></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