Compare commits
91 Commits
refactor/m
...
2b60883173
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b60883173 | ||
|
|
c2d67d7435 | ||
|
|
e252d6e00a | ||
|
|
95f1b4e04a | ||
|
|
62c6ca5e87 | ||
|
|
aac9be19f2 | ||
|
|
bb823c86c1 | ||
|
|
119301f1c3 | ||
|
|
9a2fc101da | ||
|
|
7049cbfd9d | ||
|
|
db859e8f12 | ||
|
|
5ff3fa9ab5 | ||
|
|
c8bf69a969 | ||
|
|
fee4969910 | ||
|
|
dabcb4cab3 | ||
|
|
1a3f5c6654 | ||
|
|
422db6479b | ||
|
|
35ecea16f7 | ||
|
|
9ff679ee5c | ||
|
|
ebefd8c0df | ||
|
|
73531f38ae | ||
|
|
5a6356d271 | ||
|
|
f9dafeac3b | ||
|
|
1a2bbb011c | ||
|
|
2ead35789d | ||
|
|
c1da71227d | ||
|
|
17e636c4e5 | ||
|
|
d7543d9f48 | ||
|
|
afe82c449b | ||
|
|
3c1334b30e | ||
|
|
58f261562a | ||
|
|
4ecbffd617 | ||
|
|
5491551544 | ||
|
|
7d658bbef9 | ||
|
|
d117bcb697 | ||
|
|
94e332ba57 | ||
|
|
3ef9773990 | ||
|
|
d243a11bd3 | ||
|
|
47ce0f12e6 | ||
|
|
f2caa1a3ee | ||
|
|
2a72beb0ef | ||
|
|
2f73f38877 | ||
|
|
9e5c6b5ac3 | ||
|
|
eb108695d3 | ||
|
|
7d541825d8 | ||
|
|
52f8ab11f0 | ||
|
|
f8436e9755 | ||
|
|
194a032c7f | ||
|
|
94a5a183d0 | ||
|
|
c7730b9355 | ||
|
|
1e20a5a7a0 | ||
|
|
54944283a3 | ||
|
|
f79ee6fbc7 | ||
|
|
915f1bc4ad | ||
|
|
4af2690bab | ||
|
|
6e57ab07e4 | ||
|
|
3a620a84c5 | ||
|
|
7d68652ea5 | ||
|
|
35bd1f58dd | ||
|
|
1cd3dbcd72 | ||
|
|
c97249f2ca | ||
|
|
0d923491b5 | ||
|
|
d870ef69d5 | ||
|
|
682e9d208e | ||
|
|
4a691ac71d | ||
|
|
1b84dbd36d | ||
|
|
a5b8d922e3 | ||
|
|
238d9a8803 | ||
|
|
713ea07040 | ||
|
|
bea6c33024 | ||
|
|
8fe300c8a2 | ||
|
|
9caa95a0d8 | ||
|
|
c6fd23b5fa | ||
|
|
d46434de18 | ||
|
|
cf4c28e1df | ||
|
|
39e405afde | ||
|
|
6763e3c543 | ||
|
|
11e07a0068 | ||
|
|
5d2d4bb0c6 | ||
|
|
19206b5cc7 | ||
|
|
0f6cce9b6e | ||
|
|
3f3a6c88e8 | ||
|
|
8253de9f73 | ||
|
|
1251df286e | ||
|
|
fff90804c0 | ||
|
|
8ebaf7b4ee | ||
|
|
17cb70ec00 | ||
|
|
a207d511be | ||
|
|
cf4f180124 | ||
|
|
5df1396b3f | ||
|
|
daad7be01c |
@@ -1,57 +0,0 @@
|
||||
---
|
||||
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: ...
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
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: ...
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
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.
|
||||
39
.dockerignore
Normal file
39
.dockerignore
Normal file
@@ -0,0 +1,39 @@
|
||||
# Dependencies - handled inside container
|
||||
node_modules
|
||||
web/node_modules
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Logs and data
|
||||
logs
|
||||
*.log
|
||||
shared/db/data
|
||||
shared/db/log
|
||||
|
||||
# Development tools
|
||||
.env
|
||||
.env.example
|
||||
.opencode
|
||||
.agent
|
||||
|
||||
# Documentation
|
||||
docs
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Build artifacts
|
||||
dist
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
18
.env.example
18
.env.example
@@ -1,12 +1,26 @@
|
||||
# =============================================================================
|
||||
# Aurora Environment Configuration
|
||||
# =============================================================================
|
||||
# Copy this file to .env and update with your values
|
||||
# For production, see .env.prod.example with security recommendations
|
||||
# =============================================================================
|
||||
|
||||
# Database
|
||||
# For production: use a strong password (openssl rand -base64 32)
|
||||
DB_USER=aurora
|
||||
DB_PASSWORD=aurora
|
||||
DB_NAME=aurora
|
||||
DB_PORT=5432
|
||||
DB_HOST=db
|
||||
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
|
||||
|
||||
# Discord
|
||||
# Get from: https://discord.com/developers/applications
|
||||
DISCORD_BOT_TOKEN=your-discord-bot-token
|
||||
DISCORD_CLIENT_ID=your-discord-client-id
|
||||
DISCORD_GUILD_ID=your-discord-guild-id
|
||||
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
|
||||
|
||||
VPS_USER=your-vps-user
|
||||
# Server (for remote access scripts)
|
||||
# Use a non-root user (see shared/scripts/setup-server.sh)
|
||||
VPS_USER=deploy
|
||||
VPS_HOST=your-vps-ip
|
||||
|
||||
38
.env.prod.example
Normal file
38
.env.prod.example
Normal file
@@ -0,0 +1,38 @@
|
||||
# =============================================================================
|
||||
# Aurora Production Environment Template
|
||||
# =============================================================================
|
||||
# Copy this file to .env and fill in the values
|
||||
# IMPORTANT: Use strong, unique passwords in production!
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
# Generate strong password: openssl rand -base64 32
|
||||
DB_USER=aurora_prod
|
||||
DB_PASSWORD=CHANGE_ME_USE_STRONG_PASSWORD
|
||||
DB_NAME=aurora_prod
|
||||
DB_PORT=5432
|
||||
DB_HOST=localhost
|
||||
|
||||
# Constructed database URL (used by Drizzle)
|
||||
DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_NAME}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Discord Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
# Get these from Discord Developer Portal: https://discord.com/developers
|
||||
DISCORD_BOT_TOKEN=your_bot_token_here
|
||||
DISCORD_CLIENT_ID=your_client_id_here
|
||||
DISCORD_GUILD_ID=your_guild_id_here
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Server Configuration (for SSH deployment scripts)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Use a non-root user for security!
|
||||
VPS_USER=deploy
|
||||
VPS_HOST=your_server_ip_here
|
||||
|
||||
# Optional: Custom ports for remote access
|
||||
# DASHBOARD_PORT=3000
|
||||
# STUDIO_PORT=4983
|
||||
6
.env.test
Normal file
6
.env.test
Normal file
@@ -0,0 +1,6 @@
|
||||
DATABASE_URL="postgresql://auroradev:auroradev123@localhost:5432/aurora_test"
|
||||
DISCORD_BOT_TOKEN="test_token"
|
||||
DISCORD_CLIENT_ID="123456789"
|
||||
DISCORD_GUILD_ID="123456789"
|
||||
ADMIN_TOKEN="admin_token_123"
|
||||
LOG_LEVEL="error"
|
||||
102
.github/workflows/deploy.yml
vendored
Normal file
102
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
# Aurora CI/CD Pipeline
|
||||
# Builds, tests, and deploys to production server
|
||||
|
||||
name: Deploy to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
# ==========================================================================
|
||||
# Test Job
|
||||
# ==========================================================================
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: aurora_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
cd web && bun install --frozen-lockfile
|
||||
|
||||
- name: Create Config File
|
||||
run: |
|
||||
mkdir -p shared/config
|
||||
cat <<EOF > shared/config/config.json
|
||||
{
|
||||
"leveling": { "base": 100, "exponent": 2.5, "chat": { "cooldownMs": 60000, "minXp": 15, "maxXp": 25 } },
|
||||
"economy": {
|
||||
"daily": { "amount": "100", "streakBonus": "10", "weeklyBonus": "50", "cooldownMs": 86400000 },
|
||||
"transfers": { "allowSelfTransfer": false, "minAmount": "1" },
|
||||
"exam": { "multMin": 0.05, "multMax": 0.03 }
|
||||
},
|
||||
"inventory": { "maxStackSize": "99", "maxSlots": 50 },
|
||||
"commands": {},
|
||||
"lootdrop": {
|
||||
"activityWindowMs": 120000, "minMessages": 1, "spawnChance": 1, "cooldownMs": 3000,
|
||||
"reward": { "min": 40, "max": 150, "currency": "Astral Units" }
|
||||
},
|
||||
"studentRole": "123", "visitorRole": "456", "colorRoles": [],
|
||||
"moderation": {
|
||||
"prune": { "maxAmount": 100, "confirmThreshold": 50, "batchSize": 100, "batchDelayMs": 1000 },
|
||||
"cases": { "dmOnWarn": false }
|
||||
},
|
||||
"trivia": {
|
||||
"entryFee": "50", "rewardMultiplier": 1.5, "timeoutSeconds": 30, "cooldownMs": 60000,
|
||||
"categories": [], "difficulty": "random"
|
||||
},
|
||||
"system": {}
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Setup Test Database
|
||||
run: bun run db:push:local
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/aurora_test
|
||||
# Create .env.test for implicit usage by bun
|
||||
DISCORD_BOT_TOKEN: test_token
|
||||
DISCORD_CLIENT_ID: 123
|
||||
DISCORD_GUILD_ID: 123
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
# Create .env.test for test-sequential.sh / bun test
|
||||
cat <<EOF > .env.test
|
||||
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/aurora_test"
|
||||
DISCORD_BOT_TOKEN="test_token"
|
||||
DISCORD_CLIENT_ID="123456789"
|
||||
DISCORD_GUILD_ID="123456789"
|
||||
ADMIN_TOKEN="admin_token_123"
|
||||
LOG_LEVEL="error"
|
||||
EOF
|
||||
bash shared/scripts/test-sequential.sh
|
||||
env:
|
||||
NODE_ENV: test
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
.env
|
||||
node_modules
|
||||
docker-compose.override.yml
|
||||
shared/db-logs
|
||||
shared/db/data
|
||||
shared/db/loga
|
||||
|
||||
244
AGENTS.md
Normal file
244
AGENTS.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# AGENTS.md - AI Coding Agent Guidelines
|
||||
|
||||
## Project Overview
|
||||
|
||||
AuroraBot is a Discord bot with a web dashboard built using Bun, Discord.js, React, and PostgreSQL with Drizzle ORM.
|
||||
|
||||
## Build/Lint/Test Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
bun --watch bot/index.ts # Run bot with hot reload
|
||||
bun --hot web/src/index.ts # Run web dashboard with hot reload
|
||||
|
||||
# Testing
|
||||
bun test # Run all tests ( expect some tests to fail when running all at once like this due to the nature of the tests )
|
||||
bun test path/to/file.test.ts # Run single test file
|
||||
bun test --watch # Watch mode
|
||||
bun test shared/modules/economy # Run tests in directory
|
||||
|
||||
# Database
|
||||
bun run generate # Generate Drizzle migrations (Docker)
|
||||
bun run migrate # Run migrations (Docker)
|
||||
bun run db:push # Push schema changes (Docker)
|
||||
bun run db:push:local # Push schema changes (local)
|
||||
bun run db:studio # Open Drizzle Studio
|
||||
|
||||
# Web Dashboard
|
||||
cd web && bun run build # Build production web assets
|
||||
cd web && bun run dev # Development server
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
bot/ # Discord bot
|
||||
├── commands/ # Slash commands by category
|
||||
├── events/ # Discord event handlers
|
||||
├── lib/ # Bot core (BotClient, handlers, loaders)
|
||||
├── modules/ # Feature modules (views, interactions)
|
||||
└── graphics/ # Canvas image generation
|
||||
|
||||
shared/ # Shared between bot and web
|
||||
├── db/ # Database schema and migrations
|
||||
├── lib/ # Utils, config, errors, types
|
||||
└── modules/ # Domain services (economy, user, etc.)
|
||||
|
||||
web/ # React dashboard
|
||||
├── src/pages/ # React pages
|
||||
├── src/components/ # UI components (ShadCN/Radix)
|
||||
└── src/hooks/ # React hooks
|
||||
```
|
||||
|
||||
## Import Conventions
|
||||
|
||||
Use path aliases defined in tsconfig.json:
|
||||
|
||||
```typescript
|
||||
// External packages first
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
// Path aliases second
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { users } from "@db/schema";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { handleTradeInteraction } from "@modules/trade/trade.interaction";
|
||||
|
||||
// Relative imports last
|
||||
import { localHelper } from "./helper";
|
||||
```
|
||||
|
||||
**Available Aliases:**
|
||||
|
||||
- `@/*` - bot/
|
||||
- `@shared/*` - shared/
|
||||
- `@db/*` - shared/db/
|
||||
- `@lib/*` - bot/lib/
|
||||
- `@modules/*` - bot/modules/
|
||||
- `@commands/*` - bot/commands/
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
| Element | Convention | Example |
|
||||
| ---------------- | ----------------------- | ---------------------------------------- |
|
||||
| Files | camelCase or kebab-case | `BotClient.ts`, `economy.service.ts` |
|
||||
| Classes | PascalCase | `CommandHandler`, `UserError` |
|
||||
| Functions | camelCase | `createCommand`, `handleShopInteraction` |
|
||||
| Constants | UPPER_SNAKE_CASE | `EVENTS`, `BRANDING` |
|
||||
| Enums | PascalCase | `TimerType`, `TransactionType` |
|
||||
| Services | camelCase singleton | `economyService`, `userService` |
|
||||
| Types/Interfaces | PascalCase | `Command`, `Event`, `GameConfigType` |
|
||||
| DB tables | snake_case | `users`, `moderation_cases` |
|
||||
| Custom IDs | snake_case with prefix | `shop_buy_`, `trade_accept_` |
|
||||
|
||||
## Code Patterns
|
||||
|
||||
### Command Definition
|
||||
|
||||
```typescript
|
||||
export const commandName = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("commandname")
|
||||
.setDescription("Description"),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
// Implementation
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Service Pattern (Singleton Object)
|
||||
|
||||
```typescript
|
||||
export const serviceName = {
|
||||
methodName: async (params: ParamType): Promise<ReturnType> => {
|
||||
return await withTransaction(async (tx) => {
|
||||
// Database operations
|
||||
});
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Module File Organization
|
||||
|
||||
- `*.view.ts` - Creates Discord embeds/components
|
||||
- `*.interaction.ts` - Handles button/select/modal interactions
|
||||
- `*.types.ts` - Module-specific TypeScript types
|
||||
- `*.service.ts` - Business logic (in shared/modules/)
|
||||
- `*.test.ts` - Test files (co-located with source)
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Custom Error Classes
|
||||
|
||||
```typescript
|
||||
import { UserError, SystemError } from "@shared/lib/errors";
|
||||
|
||||
// User-facing errors (shown to user)
|
||||
throw new UserError("You don't have enough coins!");
|
||||
|
||||
// System errors (logged, generic message shown)
|
||||
throw new SystemError("Database connection failed");
|
||||
```
|
||||
|
||||
### Standard Error Pattern
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const result = await service.method();
|
||||
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
||||
} catch (error) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Unexpected error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("An unexpected error occurred.")],
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Database Patterns
|
||||
|
||||
### Transaction Usage
|
||||
|
||||
```typescript
|
||||
import { withTransaction } from "@/lib/db";
|
||||
|
||||
return await withTransaction(async (tx) => {
|
||||
const user = await tx.query.users.findFirst({
|
||||
where: eq(users.id, discordId),
|
||||
});
|
||||
|
||||
await tx
|
||||
.update(users)
|
||||
.set({ coins: newBalance })
|
||||
.where(eq(users.id, discordId));
|
||||
await tx.insert(transactions).values({ userId: discordId, amount, type });
|
||||
|
||||
return user;
|
||||
}, existingTx); // Pass existing tx if in nested transaction
|
||||
```
|
||||
|
||||
### Schema Notes
|
||||
|
||||
- Use `bigint` mode for Discord IDs and currency amounts
|
||||
- Relations defined separately from table definitions
|
||||
- Schema location: `shared/db/schema.ts`
|
||||
|
||||
## Testing
|
||||
|
||||
### Test File Structure
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||
|
||||
// Mock modules BEFORE imports
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: { query: mockQuery },
|
||||
}));
|
||||
|
||||
describe("serviceName", () => {
|
||||
beforeEach(() => {
|
||||
mockFn.mockClear();
|
||||
});
|
||||
|
||||
it("should handle expected case", async () => {
|
||||
// Arrange
|
||||
mockFn.mockResolvedValue(testData);
|
||||
|
||||
// Act
|
||||
const result = await service.method(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(expected);
|
||||
expect(mockFn).toHaveBeenCalledWith(expectedArgs);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime:** Bun 1.0+
|
||||
- **Bot:** Discord.js 14.x
|
||||
- **Web:** React 19 + Bun HTTP Server
|
||||
- **Database:** PostgreSQL 16+ with Drizzle ORM
|
||||
- **UI:** Tailwind CSS v4 + ShadCN/Radix
|
||||
- **Validation:** Zod
|
||||
- **Testing:** Bun Test
|
||||
- **Container:** Docker
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
| Purpose | File |
|
||||
| ------------- | ---------------------- |
|
||||
| Bot entry | `bot/index.ts` |
|
||||
| DB schema | `shared/db/schema.ts` |
|
||||
| Error classes | `shared/lib/errors.ts` |
|
||||
| Config loader | `shared/lib/config.ts` |
|
||||
| Environment | `shared/lib/env.ts` |
|
||||
| Embed helpers | `bot/lib/embeds.ts` |
|
||||
| Command utils | `shared/lib/utils.ts` |
|
||||
54
Dockerfile
54
Dockerfile
@@ -1,21 +1,55 @@
|
||||
# ============================================
|
||||
# Base stage - shared configuration
|
||||
# ============================================
|
||||
FROM oven/bun:latest AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
||||
# Install system dependencies with cleanup in same layer
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends git && \
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# Install root project dependencies
|
||||
# ============================================
|
||||
# Dependencies stage - installs all deps
|
||||
# ============================================
|
||||
FROM base AS deps
|
||||
|
||||
# Copy only package files first (better layer caching)
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Install web project dependencies
|
||||
COPY web/package.json web/bun.lock ./web/
|
||||
RUN cd web && bun install --frozen-lockfile
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
# Install all dependencies in one layer
|
||||
RUN bun install --frozen-lockfile && \
|
||||
cd web && bun install --frozen-lockfile
|
||||
|
||||
# Expose ports (3000 for web dashboard)
|
||||
# ============================================
|
||||
# Development stage - for local dev with volume mounts
|
||||
# ============================================
|
||||
FROM base AS development
|
||||
|
||||
# Copy dependencies from deps stage
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/web/node_modules ./web/node_modules
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 3000
|
||||
|
||||
# Default command
|
||||
CMD ["bun", "run", "dev"]
|
||||
|
||||
# ============================================
|
||||
# Production stage - full app with source code
|
||||
# ============================================
|
||||
FROM base AS production
|
||||
|
||||
# Copy dependencies from deps stage
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/web/node_modules ./web/node_modules
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 3000
|
||||
|
||||
# Default command
|
||||
|
||||
57
Dockerfile.prod
Normal file
57
Dockerfile.prod
Normal file
@@ -0,0 +1,57 @@
|
||||
# =============================================================================
|
||||
# Stage 1: Dependencies & Build
|
||||
# =============================================================================
|
||||
FROM oven/bun:latest AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies needed for build
|
||||
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install root project dependencies
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Install web project dependencies
|
||||
COPY web/package.json web/bun.lock ./web/
|
||||
RUN cd web && bun install --frozen-lockfile
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build web assets for production
|
||||
RUN cd web && bun run build
|
||||
|
||||
# =============================================================================
|
||||
# Stage 2: Production Runtime
|
||||
# =============================================================================
|
||||
FROM oven/bun:latest AS production
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user for security (bun user already exists with 1000:1000)
|
||||
# No need to create user/group
|
||||
|
||||
|
||||
|
||||
# Copy only what's needed for production
|
||||
COPY --from=builder --chown=bun:bun /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=bun:bun /app/web/node_modules ./web/node_modules
|
||||
COPY --from=builder --chown=bun:bun /app/web/dist ./web/dist
|
||||
COPY --from=builder --chown=bun:bun /app/web/src ./web/src
|
||||
COPY --from=builder --chown=bun:bun /app/bot ./bot
|
||||
COPY --from=builder --chown=bun:bun /app/shared ./shared
|
||||
COPY --from=builder --chown=bun:bun /app/package.json .
|
||||
COPY --from=builder --chown=bun:bun /app/drizzle.config.ts .
|
||||
COPY --from=builder --chown=bun:bun /app/tsconfig.json .
|
||||
|
||||
# Switch to non-root user
|
||||
USER bun
|
||||
|
||||
# Expose web dashboard port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD bun -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
|
||||
|
||||
# Run in production mode
|
||||
CMD ["bun", "run", "bot/index.ts"]
|
||||
67
README.md
67
README.md
@@ -7,24 +7,44 @@
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
Aurora is a powerful Discord bot designed to facilitate RPG-like elements within a Discord server. It features a robust economy, class system, inventory management, quests, and more, all built on top of a high-performance stack using Bun and Drizzle ORM.
|
||||
|
||||
**New in v1.0:** Aurora now includes a fully integrated **Web Dashboard** for managing the bot, viewing statistics, and configuring settings, running alongside the bot in a single process.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### Discord Bot
|
||||
* **Class System**: Users can join different classes.
|
||||
* **Economy**: Complete economy system with balance, transactions, and daily rewards.
|
||||
* **Inventory & Items**: sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management.
|
||||
* **Inventory & Items**: Sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management.
|
||||
* **Leveling**: XP-based leveling system to track user activity and progress.
|
||||
* **Quests**: Quest system with requirements and rewards.
|
||||
* **Trading**: Secure trading system between users.
|
||||
* **Lootdrops**: Random loot drops in channels to engage users.
|
||||
* **Admin Tools**: Administrative commands for server management.
|
||||
|
||||
### Web Dashboard
|
||||
* **Live Analytics**: View real-time activity charts (commands, transactions).
|
||||
* **Configuration Management**: Update bot settings without restarting.
|
||||
* **Database Inspection**: Integrated Drizzle Studio access.
|
||||
* **State Monitoring**: View internal bot state (Lootdrops, etc.).
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
Aurora uses a **Single Process Monolith** architecture to maximize performance and simplify resource sharing.
|
||||
|
||||
* **Unified Runtime**: Both the Discord Client and the Web Dashboard run within the same Bun process.
|
||||
* **Shared State**: This allows the Dashboard to access live bot memory (caches, gateways) directly without complex inter-process communication (IPC).
|
||||
* **Simplified Deployment**: You only need to deploy a single Docker container.
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
* **Runtime**: [Bun](https://bun.sh/)
|
||||
* **Framework**: [Discord.js](https://discord.js.org/)
|
||||
* **Bot Framework**: [Discord.js](https://discord.js.org/)
|
||||
* **Web Framework**: [React 19](https://react.dev/) + [Vite](https://vitejs.dev/) (served via Bun)
|
||||
* **Styling**: [Tailwind CSS v4](https://tailwindcss.com/) + [Radix UI](https://www.radix-ui.com/)
|
||||
* **Database**: [PostgreSQL](https://www.postgresql.org/)
|
||||
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
||||
* **Validation**: [Zod](https://zod.dev/)
|
||||
@@ -74,12 +94,14 @@ Aurora is a powerful Discord bot designed to facilitate RPG-like elements within
|
||||
bun run db:push
|
||||
```
|
||||
|
||||
### Running the Bot
|
||||
### Running the Bot & Dashboard
|
||||
|
||||
**Development Mode** (with hot reload):
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
* Bot: Online in Discord
|
||||
* Dashboard: http://localhost:3000
|
||||
|
||||
**Production Mode**:
|
||||
Build and run with Docker (recommended):
|
||||
@@ -87,27 +109,46 @@ Build and run with Docker (recommended):
|
||||
docker compose up -d app
|
||||
```
|
||||
|
||||
### 🔐 Accessing Production Services (SSH Tunnel)
|
||||
|
||||
For security, the Production Database and Dashboard are **not exposed** to the public internet by default. They are only accessible via localhost on the server.
|
||||
|
||||
To access them from your local machine, use the included SSH tunnel script.
|
||||
|
||||
1. Add your VPS details to your local `.env` file:
|
||||
```env
|
||||
VPS_USER=root
|
||||
VPS_HOST=123.45.67.89
|
||||
```
|
||||
|
||||
2. Run the remote connection script:
|
||||
```bash
|
||||
bun run remote
|
||||
```
|
||||
|
||||
This will establish secure tunnels for:
|
||||
* **Dashboard**: http://localhost:3000
|
||||
* **Drizzle Studio**: http://localhost:4983
|
||||
|
||||
## 📜 Scripts
|
||||
|
||||
* `bun run dev`: Start the bot in watch mode.
|
||||
* `bun run dev`: Start the bot and dashboard in watch mode.
|
||||
* `bun run remote`: Open SSH tunnel to production services.
|
||||
* `bun run generate`: Generate Drizzle migrations.
|
||||
* `bun run migrate`: Apply migrations (via Docker).
|
||||
* `bun run db:push`: Push, schema to DB (via Docker).
|
||||
* `bun run db:studio`: Open Drizzle Studio to inspect the database.
|
||||
* `bun test`: Run tests.
|
||||
|
||||
## 📂 Project Structure
|
||||
|
||||
```
|
||||
├── src
|
||||
│ ├── commands # Slash commands
|
||||
│ ├── events # Discord event handlers
|
||||
│ ├── modules # Feature modules (Economy, Inventory, etc.)
|
||||
│ ├── db # Database schema and connection
|
||||
│ └── lib # Shared utilities
|
||||
├── bot # Discord Bot logic & entry point
|
||||
├── web # React Web Dashboard (Frontend + Server)
|
||||
├── shared # Shared code (Database, Config, Types)
|
||||
├── drizzle # Drizzle migration files
|
||||
├── config # Configuration files
|
||||
└── scripts # Utility scripts
|
||||
├── scripts # Utility scripts
|
||||
├── docker-compose.yml
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { items } from "@db/schema";
|
||||
import { ilike, isNotNull, and } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
@@ -65,10 +65,10 @@ export const listing = createCommand({
|
||||
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error creating listing:", error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||
import { UpdateService } from "@shared/modules/admin/update.service";
|
||||
import {
|
||||
getCheckingEmbed,
|
||||
getNoUpdatesEmbed,
|
||||
getUpdatesAvailableMessage,
|
||||
getPreparingEmbed,
|
||||
getUpdatingEmbed,
|
||||
getCancelledEmbed,
|
||||
getTimeoutEmbed,
|
||||
getErrorEmbed,
|
||||
getRollbackSuccessEmbed,
|
||||
getRollbackFailedEmbed
|
||||
} from "@/modules/admin/update.view";
|
||||
|
||||
export const update = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("update")
|
||||
.setDescription("Check for updates and restart the bot")
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("check")
|
||||
.setDescription("Check for and apply available updates")
|
||||
.addBooleanOption(option =>
|
||||
option.setName("force")
|
||||
.setDescription("Force update even if no changes detected")
|
||||
.setRequired(false)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("rollback")
|
||||
.setDescription("Rollback to the previous version")
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
if (subcommand === "rollback") {
|
||||
await handleRollback(interaction);
|
||||
} else {
|
||||
await handleUpdate(interaction);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function handleUpdate(interaction: any) {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
const force = interaction.options.getBoolean("force") || false;
|
||||
|
||||
try {
|
||||
// 1. Check for updates
|
||||
await interaction.editReply({ embeds: [getCheckingEmbed()] });
|
||||
const updateInfo = await UpdateService.checkForUpdates();
|
||||
|
||||
if (!updateInfo.hasUpdates && !force) {
|
||||
await interaction.editReply({
|
||||
embeds: [getNoUpdatesEmbed(updateInfo.currentCommit)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Analyze requirements
|
||||
const requirements = await UpdateService.checkUpdateRequirements(updateInfo.branch);
|
||||
const categories = UpdateService.categorizeChanges(requirements.changedFiles);
|
||||
|
||||
// 3. Show confirmation with details
|
||||
const { embeds, components } = getUpdatesAvailableMessage(
|
||||
updateInfo,
|
||||
requirements,
|
||||
categories,
|
||||
force
|
||||
);
|
||||
const response = await interaction.editReply({ embeds, components });
|
||||
|
||||
// 4. Wait for confirmation
|
||||
try {
|
||||
const confirmation = await response.awaitMessageComponent({
|
||||
filter: (i: any) => i.user.id === interaction.user.id,
|
||||
componentType: ComponentType.Button,
|
||||
time: 30000
|
||||
});
|
||||
|
||||
if (confirmation.customId === "confirm_update") {
|
||||
await confirmation.update({
|
||||
embeds: [getPreparingEmbed()],
|
||||
components: []
|
||||
});
|
||||
|
||||
// 5. Save rollback point
|
||||
const previousCommit = await UpdateService.saveRollbackPoint();
|
||||
|
||||
// 6. Prepare restart context
|
||||
await UpdateService.prepareRestartContext({
|
||||
channelId: interaction.channelId,
|
||||
userId: interaction.user.id,
|
||||
timestamp: Date.now(),
|
||||
runMigrations: requirements.needsMigrations,
|
||||
installDependencies: requirements.needsRootInstall || requirements.needsWebInstall,
|
||||
previousCommit: previousCommit.substring(0, 7),
|
||||
newCommit: updateInfo.latestCommit
|
||||
});
|
||||
|
||||
// 7. Show updating status
|
||||
await interaction.editReply({
|
||||
embeds: [getUpdatingEmbed(requirements)]
|
||||
});
|
||||
|
||||
// 8. Perform update
|
||||
await UpdateService.performUpdate(updateInfo.branch);
|
||||
|
||||
// 9. Trigger restart
|
||||
await UpdateService.triggerRestart();
|
||||
|
||||
} else {
|
||||
await confirmation.update({
|
||||
embeds: [getCancelledEmbed()],
|
||||
components: []
|
||||
});
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes("time")) {
|
||||
await interaction.editReply({
|
||||
embeds: [getTimeoutEmbed()],
|
||||
components: []
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Update failed:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getErrorEmbed(error)],
|
||||
components: []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRollback(interaction: any) {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const hasRollback = await UpdateService.hasRollbackPoint();
|
||||
|
||||
if (!hasRollback) {
|
||||
await interaction.editReply({
|
||||
embeds: [getRollbackFailedEmbed("No rollback point available. Rollback is only possible after a recent update.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await UpdateService.rollback();
|
||||
|
||||
if (result.success) {
|
||||
await interaction.editReply({
|
||||
embeds: [getRollbackSuccessEmbed(result.message.split(" ").pop() || "unknown")]
|
||||
});
|
||||
|
||||
// Restart after rollback
|
||||
setTimeout(() => UpdateService.triggerRestart(), 1000);
|
||||
} else {
|
||||
await interaction.editReply({
|
||||
embeds: [getRollbackFailedEmbed(result.message)]
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Rollback failed:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getErrorEmbed(error)]
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,14 @@ import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
export const daily = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("daily")
|
||||
.setDescription("Claim your daily reward"),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
try {
|
||||
const result = await economyService.claimDaily(interaction.user.id);
|
||||
|
||||
@@ -21,14 +22,14 @@ export const daily = createCommand({
|
||||
)
|
||||
.setColor("Gold");
|
||||
|
||||
await interaction.reply({ embeds: [embed] });
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error claiming daily:", error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,7 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { userTimers, users } from "@db/schema";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { TimerType } from "@shared/lib/constants";
|
||||
|
||||
const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
|
||||
const EXAM_TIMER_KEY = 'default';
|
||||
|
||||
interface ExamMetadata {
|
||||
examDay: number;
|
||||
lastXp: string;
|
||||
}
|
||||
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
@@ -25,105 +11,42 @@ export const exam = createCommand({
|
||||
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
if (!user) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to retrieve user data.")] });
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
const currentDay = now.getDay();
|
||||
|
||||
try {
|
||||
// 1. Fetch existing timer/exam data
|
||||
const timer = await DrizzleClient.query.userTimers.findFirst({
|
||||
where: and(
|
||||
eq(userTimers.userId, user.id),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
)
|
||||
});
|
||||
// First, try to take the exam or check status
|
||||
const result = await examService.takeExam(interaction.user.id);
|
||||
|
||||
// 2. First Run Logic
|
||||
if (!timer) {
|
||||
// Set exam day to today
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + 7);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||
|
||||
const metadata: ExamMetadata = {
|
||||
examDay: currentDay,
|
||||
lastXp: (user.xp ?? 0n).toString()
|
||||
};
|
||||
|
||||
await DrizzleClient.insert(userTimers).values({
|
||||
userId: user.id,
|
||||
type: EXAM_TIMER_TYPE,
|
||||
key: EXAM_TIMER_KEY,
|
||||
expiresAt: nextExamDate,
|
||||
metadata: metadata
|
||||
});
|
||||
if (result.status === ExamStatus.NOT_REGISTERED) {
|
||||
// Register the user
|
||||
const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username);
|
||||
const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}** (Server Time).\n` +
|
||||
`Come back on <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>) to take your first exam!`,
|
||||
`You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` +
|
||||
`Come back on <t:${nextRegTimestamp}:D> (<t:${nextRegTimestamp}:R>) to take your first exam!`,
|
||||
"Exam Registration Successful"
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = timer.metadata as unknown as ExamMetadata;
|
||||
const examDay = metadata.examDay;
|
||||
|
||||
// 3. Cooldown Check
|
||||
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 nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000);
|
||||
|
||||
if (result.status === ExamStatus.COOLDOWN) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
||||
`Next exam available: <t:${timestamp}:D> (<t:${timestamp}:R>)`
|
||||
`Next exam available: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Day Check
|
||||
if (currentDay !== examDay) {
|
||||
// Calculate next correct exam day to correct the schedule
|
||||
let daysUntil = (examDay - currentDay + 7) % 7;
|
||||
if (daysUntil === 0) daysUntil = 7;
|
||||
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + daysUntil);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||
|
||||
const newMetadata: ExamMetadata = {
|
||||
examDay: examDay,
|
||||
lastXp: (user.xp ?? 0n).toString()
|
||||
};
|
||||
|
||||
await DrizzleClient.update(userTimers)
|
||||
.set({
|
||||
expiresAt: nextExamDate,
|
||||
metadata: newMetadata
|
||||
})
|
||||
.where(and(
|
||||
eq(userTimers.userId, user.id),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
));
|
||||
|
||||
if (result.status === ExamStatus.MISSED) {
|
||||
await interaction.editReply({
|
||||
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[result.examDay!]}** (Server Time).\n` +
|
||||
`You verify your attendance but score a **0**.\n` +
|
||||
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
|
||||
"Exam Failed"
|
||||
@@ -132,74 +55,21 @@ export const exam = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Reward Calculation
|
||||
const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case
|
||||
const currentXp = user.xp ?? 0n;
|
||||
const diff = currentXp - lastXp;
|
||||
|
||||
// Calculate Reward
|
||||
const multMin = config.economy.exam.multMin;
|
||||
const multMax = config.economy.exam.multMax;
|
||||
const multiplier = Math.random() * (multMax - multMin) + multMin;
|
||||
|
||||
// Allow negative reward? existing description implies "difference", usually gain.
|
||||
// If diff is negative (lost XP?), reward might be 0.
|
||||
let reward = 0n;
|
||||
if (diff > 0n) {
|
||||
reward = BigInt(Math.floor(Number(diff) * multiplier));
|
||||
}
|
||||
|
||||
// 6. Update State
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + 7);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||
|
||||
const newMetadata: ExamMetadata = {
|
||||
examDay: examDay,
|
||||
lastXp: currentXp.toString()
|
||||
};
|
||||
|
||||
await DrizzleClient.transaction(async (tx) => {
|
||||
// Update Timer
|
||||
await tx.update(userTimers)
|
||||
.set({
|
||||
expiresAt: nextExamDate,
|
||||
metadata: newMetadata
|
||||
})
|
||||
.where(and(
|
||||
eq(userTimers.userId, user.id),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
));
|
||||
|
||||
// Add Currency
|
||||
if (reward > 0n) {
|
||||
await tx.update(users)
|
||||
.set({
|
||||
balance: sql`${users.balance} + ${reward}`
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
}
|
||||
});
|
||||
|
||||
// If it reached here with AVAILABLE, it means they passed
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`**XP Gained:** ${diff.toString()}\n` +
|
||||
`**Multiplier:** x${multiplier.toFixed(2)}\n` +
|
||||
`**Reward:** ${reward.toString()} Currency\n\n` +
|
||||
`**XP Gained:** ${result.xpDiff?.toString()}\n` +
|
||||
`**Multiplier:** x${result.multiplier?.toFixed(2)}\n` +
|
||||
`**Reward:** ${result.reward?.toString()} Currency\n\n` +
|
||||
`See you next week: <t:${nextExamTimestamp}:D>`,
|
||||
"Exam Passed!"
|
||||
)]
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
||||
} else {
|
||||
console.error("Error in exam command:", error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
||||
}
|
||||
console.error("Error in exam command:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An unexpected error occurred.")] });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
export const pay = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
|
||||
117
bot/commands/economy/trivia.ts
Normal file
117
bot/commands/economy/trivia.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { triviaService } from "@shared/modules/trivia/trivia.service";
|
||||
import { getTriviaQuestionView } from "@/modules/trivia/trivia.view";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { TriviaCategory } from "@shared/lib/constants";
|
||||
|
||||
export const trivia = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("trivia")
|
||||
.setDescription("Play trivia to win currency! Answer correctly within the time limit.")
|
||||
.addStringOption(option =>
|
||||
option.setName('category')
|
||||
.setDescription('Select a specific category')
|
||||
.setRequired(false)
|
||||
.addChoices(
|
||||
{ name: 'General Knowledge', value: String(TriviaCategory.GENERAL_KNOWLEDGE) },
|
||||
{ name: 'Books', value: String(TriviaCategory.BOOKS) },
|
||||
{ name: 'Film', value: String(TriviaCategory.FILM) },
|
||||
{ name: 'Music', value: String(TriviaCategory.MUSIC) },
|
||||
{ name: 'Video Games', value: String(TriviaCategory.VIDEO_GAMES) },
|
||||
{ name: 'Science & Nature', value: String(TriviaCategory.SCIENCE_NATURE) },
|
||||
{ name: 'Computers', value: String(TriviaCategory.COMPUTERS) },
|
||||
{ name: 'Mathematics', value: String(TriviaCategory.MATHEMATICS) },
|
||||
{ name: 'Mythology', value: String(TriviaCategory.MYTHOLOGY) },
|
||||
{ name: 'Sports', value: String(TriviaCategory.SPORTS) },
|
||||
{ name: 'Geography', value: String(TriviaCategory.GEOGRAPHY) },
|
||||
{ name: 'History', value: String(TriviaCategory.HISTORY) },
|
||||
{ name: 'Politics', value: String(TriviaCategory.POLITICS) },
|
||||
{ name: 'Art', value: String(TriviaCategory.ART) },
|
||||
{ name: 'Animals', value: String(TriviaCategory.ANIMALS) },
|
||||
{ name: 'Anime & Manga', value: String(TriviaCategory.ANIME_MANGA) },
|
||||
)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
try {
|
||||
const categoryId = interaction.options.getString('category');
|
||||
|
||||
// Check if user can play BEFORE deferring
|
||||
const canPlay = await triviaService.canPlayTrivia(interaction.user.id);
|
||||
|
||||
if (!canPlay.canPlay) {
|
||||
// Cooldown error - ephemeral
|
||||
const timestamp = Math.floor(canPlay.nextAvailable!.getTime() / 1000);
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You're on cooldown! Try again <t:${timestamp}:R>.`
|
||||
)],
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// User can play - defer publicly for trivia question
|
||||
await interaction.deferReply();
|
||||
|
||||
// Start trivia session (deducts entry fee)
|
||||
const session = await triviaService.startTrivia(
|
||||
interaction.user.id,
|
||||
interaction.user.username,
|
||||
categoryId ? parseInt(categoryId) : undefined
|
||||
);
|
||||
|
||||
// Generate Components v2 message
|
||||
const { components, flags } = getTriviaQuestionView(session, interaction.user.username);
|
||||
|
||||
// Reply with Components v2 question
|
||||
await interaction.editReply({
|
||||
components,
|
||||
flags
|
||||
});
|
||||
|
||||
// Set up automatic timeout cleanup
|
||||
setTimeout(async () => {
|
||||
const stillActive = triviaService.getSession(session.sessionId);
|
||||
if (stillActive) {
|
||||
// User didn't answer - clean up session with no reward
|
||||
try {
|
||||
await triviaService.submitAnswer(session.sessionId, interaction.user.id, false);
|
||||
} catch (error) {
|
||||
// Session already cleaned up, ignore
|
||||
}
|
||||
}
|
||||
}, config.trivia.timeoutSeconds * 1000 + 5000); // 5 seconds grace period
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
// Check if we've already deferred
|
||||
if (interaction.deferred) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(error.message)]
|
||||
});
|
||||
} else {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed(error.message)],
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.error("Error in trivia command:", error);
|
||||
// Check if we've already deferred
|
||||
if (interaction.deferred) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")]
|
||||
});
|
||||
} else {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { userService } from "@shared/modules/user/user.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
||||
import type { ItemUsageData } from "@shared/lib/types";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { config } from "@shared/lib/config";
|
||||
|
||||
export const use = createCommand({
|
||||
|
||||
@@ -1,25 +1,83 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
||||
import { questService } from "@shared/modules/quest/quest.service";
|
||||
import { createWarningEmbed } from "@lib/embeds";
|
||||
import { getQuestListEmbed } from "@/modules/quest/quest.view";
|
||||
import { createSuccessEmbed } from "@lib/embeds";
|
||||
import {
|
||||
getQuestListComponents,
|
||||
getAvailableQuestsComponents,
|
||||
getQuestActionRows
|
||||
} from "@/modules/quest/quest.view";
|
||||
|
||||
export const quests = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("quests")
|
||||
.setDescription("View your active quests"),
|
||||
.setDescription("View your active and available quests"),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
const response = await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const userQuests = await questService.getUserQuests(interaction.user.id);
|
||||
const userId = interaction.user.id;
|
||||
|
||||
if (!userQuests || userQuests.length === 0) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("You have no active quests.", "Quest Log")] });
|
||||
return;
|
||||
}
|
||||
const updateView = async (viewType: 'active' | 'available') => {
|
||||
const userQuests = await questService.getUserQuests(userId);
|
||||
const availableQuests = await questService.getAvailableQuests(userId);
|
||||
|
||||
const embed = getQuestListEmbed(userQuests);
|
||||
const containers = viewType === 'active'
|
||||
? getQuestListComponents(userQuests)
|
||||
: getAvailableQuestsComponents(availableQuests);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
const actionRows = getQuestActionRows(viewType);
|
||||
|
||||
await interaction.editReply({
|
||||
content: null,
|
||||
embeds: null as any,
|
||||
components: [...containers, ...actionRows] as any,
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
allowedMentions: { parse: [] }
|
||||
});
|
||||
};
|
||||
|
||||
// Initial view
|
||||
await updateView('active');
|
||||
|
||||
const collector = response.createMessageComponentCollector({
|
||||
time: 120000, // 2 minutes
|
||||
componentType: undefined // Allow buttons
|
||||
});
|
||||
|
||||
collector.on('collect', async (i) => {
|
||||
if (i.user.id !== interaction.user.id) return;
|
||||
|
||||
try {
|
||||
if (i.customId === "quest_view_active") {
|
||||
await i.deferUpdate();
|
||||
await updateView('active');
|
||||
} else if (i.customId === "quest_view_available") {
|
||||
await i.deferUpdate();
|
||||
await updateView('available');
|
||||
} else if (i.customId.startsWith("quest_accept:")) {
|
||||
const questIdStr = i.customId.split(":")[1];
|
||||
if (!questIdStr) return;
|
||||
const questId = parseInt(questIdStr);
|
||||
await questService.assignQuest(userId, questId);
|
||||
|
||||
await i.reply({
|
||||
embeds: [createSuccessEmbed(`You have accepted a new quest!`, "Quest Accepted")],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
|
||||
await updateView('active');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Quest interaction error:", error);
|
||||
await i.followUp({
|
||||
content: "Something went wrong while processing your quest interaction.",
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
collector.on('end', () => {
|
||||
interaction.editReply({ components: [] }).catch(() => {});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,9 +9,7 @@ const event: Event<Events.ClientReady> = {
|
||||
console.log(`Ready! Logged in as ${c.user.tag}`);
|
||||
schedulerService.start();
|
||||
|
||||
// Handle post-update tasks
|
||||
const { UpdateService } = await import("@shared/modules/admin/update.service");
|
||||
await UpdateService.handlePostRestart(c);
|
||||
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { startWebServerFromRoot } from "../web/src/server";
|
||||
await AuroraClient.loadCommands();
|
||||
await AuroraClient.loadEvents();
|
||||
await AuroraClient.deployCommands();
|
||||
await AuroraClient.setupSystemEvents();
|
||||
|
||||
console.log("🌐 Starting web server...");
|
||||
|
||||
|
||||
112
bot/lib/BotClient.test.ts
Normal file
112
bot/lib/BotClient.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, test, mock, beforeEach, spyOn } from "bun:test";
|
||||
import { systemEvents, EVENTS } from "@shared/lib/events";
|
||||
|
||||
// Mock Discord.js Client and related classes
|
||||
mock.module("discord.js", () => ({
|
||||
Client: class {
|
||||
constructor() { }
|
||||
on() { }
|
||||
once() { }
|
||||
login() { }
|
||||
destroy() { }
|
||||
removeAllListeners() { }
|
||||
},
|
||||
Collection: Map,
|
||||
GatewayIntentBits: { Guilds: 1, MessageContent: 1, GuildMessages: 1, GuildMembers: 1 },
|
||||
REST: class {
|
||||
setToken() { return this; }
|
||||
put() { return Promise.resolve([]); }
|
||||
},
|
||||
Routes: {
|
||||
applicationGuildCommands: () => 'guild_route',
|
||||
applicationCommands: () => 'global_route'
|
||||
},
|
||||
MessageFlags: {}
|
||||
}));
|
||||
|
||||
// Mock loaders to avoid filesystem access during client init
|
||||
mock.module("../lib/loaders/CommandLoader", () => ({
|
||||
CommandLoader: class {
|
||||
constructor() { }
|
||||
loadFromDirectory() { return Promise.resolve({ loaded: 0, skipped: 0, errors: [] }); }
|
||||
}
|
||||
}));
|
||||
mock.module("../lib/loaders/EventLoader", () => ({
|
||||
EventLoader: class {
|
||||
constructor() { }
|
||||
loadFromDirectory() { return Promise.resolve({ loaded: 0, skipped: 0, errors: [] }); }
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock dashboard service to prevent network/db calls during event handling
|
||||
mock.module("@shared/modules/economy/lootdrop.service", () => ({
|
||||
lootdropService: { clearCaches: mock(async () => { }) }
|
||||
}));
|
||||
mock.module("@shared/modules/trade/trade.service", () => ({
|
||||
tradeService: { clearSessions: mock(() => { }) }
|
||||
}));
|
||||
mock.module("@/modules/admin/item_wizard", () => ({
|
||||
clearDraftSessions: mock(() => { })
|
||||
}));
|
||||
mock.module("@shared/modules/dashboard/dashboard.service", () => ({
|
||||
dashboardService: {
|
||||
recordEvent: mock(() => Promise.resolve())
|
||||
}
|
||||
}));
|
||||
|
||||
describe("AuroraClient System Events", () => {
|
||||
let AuroraClient: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
systemEvents.removeAllListeners();
|
||||
const module = await import("./BotClient");
|
||||
AuroraClient = module.AuroraClient;
|
||||
AuroraClient.maintenanceMode = false;
|
||||
// MUST call explicitly now
|
||||
await AuroraClient.setupSystemEvents();
|
||||
});
|
||||
|
||||
/**
|
||||
* Test Case: Maintenance Mode Toggle
|
||||
* Requirement: Client state should update when event is received
|
||||
*/
|
||||
test("should toggle maintenanceMode when MAINTENANCE_MODE event is received", async () => {
|
||||
systemEvents.emit(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled: true, reason: "Testing" });
|
||||
await new Promise(resolve => setTimeout(resolve, 30));
|
||||
expect(AuroraClient.maintenanceMode).toBe(true);
|
||||
|
||||
systemEvents.emit(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled: false });
|
||||
await new Promise(resolve => setTimeout(resolve, 30));
|
||||
expect(AuroraClient.maintenanceMode).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Test Case: Command Reload
|
||||
* Requirement: loadCommands and deployCommands should be called
|
||||
*/
|
||||
test("should reload commands when RELOAD_COMMANDS event is received", async () => {
|
||||
const loadSpy = spyOn(AuroraClient, "loadCommands").mockImplementation(() => Promise.resolve());
|
||||
const deploySpy = spyOn(AuroraClient, "deployCommands").mockImplementation(() => Promise.resolve());
|
||||
|
||||
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
expect(loadSpy).toHaveBeenCalled();
|
||||
expect(deploySpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* Test Case: Cache Clearance
|
||||
* Requirement: Service clear methods should be triggered
|
||||
*/
|
||||
test("should trigger service cache clearance when CLEAR_CACHE is received", async () => {
|
||||
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||
const { tradeService } = await import("@shared/modules/trade/trade.service");
|
||||
|
||||
systemEvents.emit(EVENTS.ACTIONS.CLEAR_CACHE);
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
expect(lootdropService.clearCaches).toHaveBeenCalled();
|
||||
expect(tradeService.clearSessions).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js";
|
||||
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes, MessageFlags } from "discord.js";
|
||||
import { join } from "node:path";
|
||||
import type { Command } from "@shared/lib/types";
|
||||
import { env } from "@shared/lib/env";
|
||||
@@ -8,20 +8,99 @@ import { EventLoader } from "@lib/loaders/EventLoader";
|
||||
export class Client extends DiscordClient {
|
||||
|
||||
commands: Collection<string, Command>;
|
||||
knownCommands: Map<string, string>;
|
||||
lastCommandTimestamp: number | null = null;
|
||||
maintenanceMode: boolean = false;
|
||||
private commandLoader: CommandLoader;
|
||||
private eventLoader: EventLoader;
|
||||
|
||||
constructor({ intents }: { intents: number[] }) {
|
||||
super({ intents });
|
||||
this.commands = new Collection<string, Command>();
|
||||
this.knownCommands = new Map<string, string>();
|
||||
this.commandLoader = new CommandLoader(this);
|
||||
this.eventLoader = new EventLoader(this);
|
||||
}
|
||||
|
||||
public async setupSystemEvents() {
|
||||
const { systemEvents, EVENTS } = await import("@shared/lib/events");
|
||||
|
||||
systemEvents.on(EVENTS.ACTIONS.RELOAD_COMMANDS, async () => {
|
||||
console.log("🔄 System Action: Reloading commands...");
|
||||
try {
|
||||
await this.loadCommands(true);
|
||||
await this.deployCommands();
|
||||
|
||||
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||
await dashboardService.recordEvent({
|
||||
type: "success",
|
||||
message: "Bot: Commands reloaded and redeployed",
|
||||
icon: "✅"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to reload commands:", error);
|
||||
}
|
||||
});
|
||||
|
||||
systemEvents.on(EVENTS.ACTIONS.CLEAR_CACHE, async () => {
|
||||
console.log("<22> System Action: Clearing all internal caches...");
|
||||
|
||||
try {
|
||||
// 1. Lootdrop Service
|
||||
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||
await lootdropService.clearCaches();
|
||||
|
||||
// 2. Trade Service
|
||||
const { tradeService } = await import("@shared/modules/trade/trade.service");
|
||||
tradeService.clearSessions();
|
||||
|
||||
// 3. Item Wizard
|
||||
const { clearDraftSessions } = await import("@/modules/admin/item_wizard");
|
||||
clearDraftSessions();
|
||||
|
||||
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||
await dashboardService.recordEvent({
|
||||
type: "success",
|
||||
message: "Bot: All internal caches and sessions cleared",
|
||||
icon: "🧼"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to clear caches:", error);
|
||||
}
|
||||
});
|
||||
|
||||
systemEvents.on(EVENTS.ACTIONS.MAINTENANCE_MODE, async (data: { enabled: boolean, reason?: string }) => {
|
||||
const { enabled, reason } = data;
|
||||
console.log(`🛠️ System Action: Maintenance mode ${enabled ? "ON" : "OFF"}${reason ? ` (${reason})` : ""}`);
|
||||
this.maintenanceMode = enabled;
|
||||
});
|
||||
|
||||
systemEvents.on(EVENTS.QUEST.COMPLETED, async (data: { userId: string, quest: any, rewards: any }) => {
|
||||
const { userId, quest, rewards } = data;
|
||||
try {
|
||||
const user = await this.users.fetch(userId);
|
||||
if (!user) return;
|
||||
|
||||
const { getQuestCompletionComponents } = await import("@/modules/quest/quest.view");
|
||||
const components = getQuestCompletionComponents(quest, rewards);
|
||||
|
||||
// Try to send to the user's DM
|
||||
await user.send({
|
||||
components: components as any,
|
||||
flags: [MessageFlags.IsComponentsV2]
|
||||
}).catch(async () => {
|
||||
console.warn(`Could not DM user ${userId} quest completion message. User might have DMs disabled.`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send quest completion notification:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadCommands(reload: boolean = false) {
|
||||
if (reload) {
|
||||
this.commands.clear();
|
||||
this.knownCommands.clear();
|
||||
console.log("♻️ Reloading commands...");
|
||||
}
|
||||
|
||||
@@ -118,4 +197,4 @@ export class Client extends DiscordClient {
|
||||
}
|
||||
}
|
||||
|
||||
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, GatewayIntentBits.DirectMessages] });
|
||||
77
bot/lib/clientStats.test.ts
Normal file
77
bot/lib/clientStats.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, test, expect, beforeEach, mock, afterEach } from "bun:test";
|
||||
import { getClientStats, clearStatsCache } from "./clientStats";
|
||||
|
||||
// Mock AuroraClient
|
||||
mock.module("./BotClient", () => ({
|
||||
AuroraClient: {
|
||||
guilds: {
|
||||
cache: {
|
||||
size: 5,
|
||||
},
|
||||
},
|
||||
ws: {
|
||||
ping: 42,
|
||||
},
|
||||
users: {
|
||||
cache: {
|
||||
size: 100,
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
size: 20,
|
||||
},
|
||||
knownCommands: {
|
||||
size: 20,
|
||||
},
|
||||
lastCommandTimestamp: 1641481200000,
|
||||
},
|
||||
}));
|
||||
|
||||
describe("clientStats", () => {
|
||||
beforeEach(() => {
|
||||
clearStatsCache();
|
||||
});
|
||||
|
||||
test("should return client stats", () => {
|
||||
const stats = getClientStats();
|
||||
|
||||
expect(stats.guilds).toBe(5);
|
||||
expect(stats.ping).toBe(42);
|
||||
expect(stats.cachedUsers).toBe(100);
|
||||
expect(stats.commandsRegistered).toBe(20);
|
||||
expect(typeof stats.uptime).toBe("number"); // Can't mock process.uptime easily
|
||||
expect(stats.lastCommandTimestamp).toBe(1641481200000);
|
||||
});
|
||||
|
||||
test("should cache stats for 30 seconds", () => {
|
||||
const stats1 = getClientStats();
|
||||
const stats2 = getClientStats();
|
||||
|
||||
// Should return same object (cached)
|
||||
expect(stats1).toBe(stats2);
|
||||
});
|
||||
|
||||
test("should refresh cache after TTL expires", async () => {
|
||||
const stats1 = getClientStats();
|
||||
|
||||
// Wait for cache to expire (simulate by clearing and waiting)
|
||||
await new Promise(resolve => setTimeout(resolve, 35));
|
||||
clearStatsCache();
|
||||
|
||||
const stats2 = getClientStats();
|
||||
|
||||
// Should be different objects (new fetch)
|
||||
expect(stats1).not.toBe(stats2);
|
||||
// But values should be the same (mocked client)
|
||||
expect(stats1.guilds).toBe(stats2.guilds);
|
||||
});
|
||||
|
||||
test("clearStatsCache should invalidate cache", () => {
|
||||
const stats1 = getClientStats();
|
||||
clearStatsCache();
|
||||
const stats2 = getClientStats();
|
||||
|
||||
// Should be different objects
|
||||
expect(stats1).not.toBe(stats2);
|
||||
});
|
||||
});
|
||||
50
bot/lib/clientStats.ts
Normal file
50
bot/lib/clientStats.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { AuroraClient } from "./BotClient";
|
||||
import type { ClientStats } from "@shared/modules/dashboard/dashboard.types";
|
||||
|
||||
// Cache for client stats (30 second TTL)
|
||||
let cachedStats: ClientStats | null = null;
|
||||
let lastFetchTime: number = 0;
|
||||
const CACHE_TTL_MS = 30 * 1000; // 30 seconds
|
||||
|
||||
/**
|
||||
* Get Discord client statistics with caching
|
||||
* Respects rate limits by caching for 30 seconds
|
||||
*/
|
||||
export function getClientStats(): ClientStats {
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached stats if still valid
|
||||
if (cachedStats && (now - lastFetchTime) < CACHE_TTL_MS) {
|
||||
return cachedStats;
|
||||
}
|
||||
|
||||
// Fetch fresh stats
|
||||
const stats: ClientStats = {
|
||||
bot: {
|
||||
name: AuroraClient.user?.username || "Aurora",
|
||||
avatarUrl: AuroraClient.user?.displayAvatarURL() || null,
|
||||
status: AuroraClient.user?.presence.activities[0]?.state || AuroraClient.user?.presence.activities[0]?.name || null,
|
||||
},
|
||||
guilds: AuroraClient.guilds.cache.size,
|
||||
ping: AuroraClient.ws.ping,
|
||||
cachedUsers: AuroraClient.users.cache.size,
|
||||
commandsRegistered: AuroraClient.commands.size,
|
||||
commandsKnown: AuroraClient.knownCommands.size,
|
||||
uptime: process.uptime(),
|
||||
lastCommandTimestamp: AuroraClient.lastCommandTimestamp,
|
||||
};
|
||||
|
||||
// Update cache
|
||||
cachedStats = stats;
|
||||
lastFetchTime = now;
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the stats cache (useful for testing)
|
||||
*/
|
||||
export function clearStatsCache(): void {
|
||||
cachedStats = null;
|
||||
lastFetchTime = 0;
|
||||
}
|
||||
@@ -1,4 +1,15 @@
|
||||
import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js";
|
||||
import { BRANDING } from "@shared/lib/constants";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
/**
|
||||
* Applies standard branding to an embed.
|
||||
*/
|
||||
function applyBranding(embed: EmbedBuilder): EmbedBuilder {
|
||||
return embed.setFooter({
|
||||
text: `${BRANDING.FOOTER_TEXT} v${pkg.version}`
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a standardized error embed.
|
||||
@@ -7,11 +18,13 @@ import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js";
|
||||
* @returns An EmbedBuilder instance configured as an error.
|
||||
*/
|
||||
export function createErrorEmbed(message: string, title: string = "Error"): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`❌ ${title}`)
|
||||
.setDescription(message)
|
||||
.setColor(Colors.Red)
|
||||
.setTimestamp();
|
||||
|
||||
return applyBranding(embed);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,11 +34,13 @@ export function createErrorEmbed(message: string, title: string = "Error"): Embe
|
||||
* @returns An EmbedBuilder instance configured as a warning.
|
||||
*/
|
||||
export function createWarningEmbed(message: string, title: string = "Warning"): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`⚠️ ${title}`)
|
||||
.setDescription(message)
|
||||
.setColor(Colors.Yellow)
|
||||
.setTimestamp();
|
||||
|
||||
return applyBranding(embed);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,11 +50,13 @@ export function createWarningEmbed(message: string, title: string = "Warning"):
|
||||
* @returns An EmbedBuilder instance configured as a success.
|
||||
*/
|
||||
export function createSuccessEmbed(message: string, title: string = "Success"): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`✅ ${title}`)
|
||||
.setDescription(message)
|
||||
.setColor(Colors.Green)
|
||||
.setTimestamp();
|
||||
|
||||
return applyBranding(embed);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,11 +66,13 @@ export function createSuccessEmbed(message: string, title: string = "Success"):
|
||||
* @returns An EmbedBuilder instance configured as info.
|
||||
*/
|
||||
export function createInfoEmbed(message: string, title: string = "Info"): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`ℹ️ ${title}`)
|
||||
.setDescription(message)
|
||||
.setColor(Colors.Blue)
|
||||
.setTimestamp();
|
||||
|
||||
return applyBranding(embed);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,11 +84,12 @@ export function createInfoEmbed(message: string, title: string = "Info"): EmbedB
|
||||
*/
|
||||
export function createBaseEmbed(title?: string, description?: string, color?: ColorResolvable): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTimestamp();
|
||||
.setTimestamp()
|
||||
.setColor(color ?? BRANDING.COLOR);
|
||||
|
||||
if (title) embed.setTitle(title);
|
||||
if (description) embed.setDescription(description);
|
||||
if (color) embed.setColor(color);
|
||||
|
||||
return embed;
|
||||
return applyBranding(embed);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
export class ApplicationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
}
|
||||
}
|
||||
|
||||
export class UserError extends ApplicationError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class SystemError extends ApplicationError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AutocompleteInteraction } from "discord.js";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
|
||||
|
||||
/**
|
||||
@@ -16,7 +17,7 @@ export class AutocompleteHandler {
|
||||
try {
|
||||
await command.autocomplete(interaction);
|
||||
} catch (error) {
|
||||
console.error(`Error handling autocomplete for ${interaction.commandName}:`, error);
|
||||
logger.error("bot", `Error handling autocomplete for ${interaction.commandName}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,4 +56,28 @@ describe("CommandHandler", () => {
|
||||
expect(executeError).toHaveBeenCalled();
|
||||
expect(AuroraClient.lastCommandTimestamp).toBeNull();
|
||||
});
|
||||
|
||||
test("should block execution when maintenance mode is active", async () => {
|
||||
AuroraClient.maintenanceMode = true;
|
||||
const executeSpy = mock(() => Promise.resolve());
|
||||
AuroraClient.commands.set("maint-test", {
|
||||
data: { name: "maint-test" } as any,
|
||||
execute: executeSpy
|
||||
} as any);
|
||||
|
||||
const interaction = {
|
||||
commandName: "maint-test",
|
||||
user: { id: "123", username: "testuser" },
|
||||
reply: mock(() => Promise.resolve())
|
||||
} as unknown as ChatInputCommandInteraction;
|
||||
|
||||
await CommandHandler.handle(interaction);
|
||||
|
||||
expect(executeSpy).not.toHaveBeenCalled();
|
||||
expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({
|
||||
flags: expect.anything()
|
||||
}));
|
||||
|
||||
AuroraClient.maintenanceMode = false; // Reset for other tests
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,8 @@ import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
|
||||
import { logger } from "@shared/lib/logger";
|
||||
|
||||
|
||||
/**
|
||||
* Handles slash command execution
|
||||
@@ -13,7 +14,14 @@ export class CommandHandler {
|
||||
const command = AuroraClient.commands.get(interaction.commandName);
|
||||
|
||||
if (!command) {
|
||||
console.error(`No command matching ${interaction.commandName} was found.`);
|
||||
logger.error("bot", `No command matching ${interaction.commandName} was found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check maintenance mode
|
||||
if (AuroraClient.maintenanceMode) {
|
||||
const errorEmbed = createErrorEmbed('The bot is currently undergoing maintenance. Please try again later.');
|
||||
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -21,14 +29,14 @@ export class CommandHandler {
|
||||
try {
|
||||
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
} catch (error) {
|
||||
console.error("Failed to ensure user exists:", error);
|
||||
logger.error("bot", "Failed to ensure user exists", error);
|
||||
}
|
||||
|
||||
try {
|
||||
await command.execute(interaction);
|
||||
AuroraClient.lastCommandTimestamp = Date.now();
|
||||
} catch (error) {
|
||||
console.error(String(error));
|
||||
logger.error("bot", `Error executing command ${interaction.commandName}`, error);
|
||||
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
|
||||
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js";
|
||||
|
||||
import { UserError } from "@lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
|
||||
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
||||
|
||||
@@ -28,7 +29,7 @@ export class ComponentInteractionHandler {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.error(`Handler method ${route.method} not found in module`);
|
||||
logger.error("bot", `Handler method ${route.method} not found in module`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,7 +53,7 @@ export class ComponentInteractionHandler {
|
||||
|
||||
// Log system errors (non-user errors) for debugging
|
||||
if (!isUserError) {
|
||||
console.error(`Error in ${handlerName}:`, error);
|
||||
logger.error("bot", `Error in ${handlerName}`, error);
|
||||
}
|
||||
|
||||
const errorEmbed = createErrorEmbed(errorMessage);
|
||||
@@ -72,7 +73,7 @@ export class ComponentInteractionHandler {
|
||||
}
|
||||
} catch (replyError) {
|
||||
// If we can't send a reply, log it
|
||||
console.error(`Failed to send error response in ${handlerName}:`, replyError);
|
||||
logger.error("bot", `Failed to send error response in ${handlerName}`, replyError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,11 @@ export const interactionRoutes: InteractionRoute[] = [
|
||||
handler: () => import("@/modules/economy/lootdrop.interaction"),
|
||||
method: 'handleLootdropInteraction'
|
||||
},
|
||||
{
|
||||
predicate: (i) => i.isButton() && i.customId.startsWith("trivia_"),
|
||||
handler: () => import("@/modules/trivia/trivia.interaction"),
|
||||
method: 'handleTriviaInteraction'
|
||||
},
|
||||
|
||||
// --- ADMIN MODULE ---
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Command } from "@shared/lib/types";
|
||||
import { config } from "@shared/lib/config";
|
||||
import type { LoadResult, LoadError } from "./types";
|
||||
import type { Client } from "../BotClient";
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Handles loading commands from the file system
|
||||
@@ -71,6 +71,9 @@ export class CommandLoader {
|
||||
if (this.isValidCommand(command)) {
|
||||
command.category = category;
|
||||
|
||||
// Track all known commands regardless of enabled status
|
||||
this.client.knownCommands.set(command.data.name, category);
|
||||
|
||||
const isEnabled = config.commands[command.data.name] !== false;
|
||||
|
||||
if (!isEnabled) {
|
||||
|
||||
@@ -241,3 +241,8 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export const clearDraftSessions = () => {
|
||||
draftSession.clear();
|
||||
console.log("[ItemWizard] All draft item creation sessions cleared.");
|
||||
};
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
|
||||
export interface RestartContext {
|
||||
channelId: string;
|
||||
userId: string;
|
||||
timestamp: number;
|
||||
runMigrations: boolean;
|
||||
installDependencies: boolean;
|
||||
previousCommit: string;
|
||||
newCommit: string;
|
||||
}
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
needsRootInstall: boolean;
|
||||
needsWebInstall: boolean;
|
||||
needsMigrations: boolean;
|
||||
changedFiles: string[];
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
hasUpdates: boolean;
|
||||
branch: string;
|
||||
currentCommit: string;
|
||||
latestCommit: string;
|
||||
commitCount: number;
|
||||
commits: CommitInfo[];
|
||||
}
|
||||
|
||||
export interface CommitInfo {
|
||||
hash: string;
|
||||
message: string;
|
||||
author: string;
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js";
|
||||
import { createInfoEmbed, createSuccessEmbed, createWarningEmbed, createErrorEmbed } from "@lib/embeds";
|
||||
import type { UpdateInfo, UpdateCheckResult } from "./update.types";
|
||||
|
||||
// Constants for UI
|
||||
const LOG_TRUNCATE_LENGTH = 800;
|
||||
const OUTPUT_TRUNCATE_LENGTH = 400;
|
||||
|
||||
function truncate(text: string, maxLength: number): string {
|
||||
if (!text) return "";
|
||||
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
|
||||
}
|
||||
|
||||
// ============ Pre-Update Embeds ============
|
||||
|
||||
export function getCheckingEmbed() {
|
||||
return createInfoEmbed("🔍 Fetching latest changes from remote...", "Checking for Updates");
|
||||
}
|
||||
|
||||
export function getNoUpdatesEmbed(currentCommit: string) {
|
||||
return createSuccessEmbed(
|
||||
`You're running the latest version.\n\n**Current:** \`${currentCommit}\``,
|
||||
"✅ Already Up to Date"
|
||||
);
|
||||
}
|
||||
|
||||
export function getUpdatesAvailableMessage(
|
||||
updateInfo: UpdateInfo,
|
||||
requirements: UpdateCheckResult,
|
||||
changeCategories: Record<string, number>,
|
||||
force: boolean
|
||||
) {
|
||||
const { branch, currentCommit, latestCommit, commitCount, commits } = updateInfo;
|
||||
const { needsRootInstall, needsWebInstall, needsMigrations } = requirements;
|
||||
|
||||
// Build commit list (max 5)
|
||||
const commitList = commits
|
||||
.slice(0, 5)
|
||||
.map(c => `\`${c.hash}\` ${truncate(c.message, 50)}`)
|
||||
.join("\n");
|
||||
|
||||
const moreCommits = commitCount > 5 ? `\n*...and ${commitCount - 5} more*` : "";
|
||||
|
||||
// Build change categories
|
||||
const categoryList = Object.entries(changeCategories)
|
||||
.map(([cat, count]) => `• ${cat}: ${count} file${count > 1 ? "s" : ""}`)
|
||||
.join("\n");
|
||||
|
||||
// Build requirements list
|
||||
const reqs: string[] = [];
|
||||
if (needsRootInstall) reqs.push("📦 Install root dependencies");
|
||||
if (needsWebInstall) reqs.push("🌐 Install web dependencies");
|
||||
if (needsMigrations) reqs.push("🗃️ Run database migrations");
|
||||
if (reqs.length === 0) reqs.push("⚡ Quick update (no extra steps)");
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("📥 Updates Available")
|
||||
.setColor(force ? 0xFF6B6B : 0x5865F2)
|
||||
.addFields(
|
||||
{
|
||||
name: "Version",
|
||||
value: `\`${currentCommit}\` → \`${latestCommit}\``,
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: "Branch",
|
||||
value: `\`${branch}\``,
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: "Commits",
|
||||
value: `${commitCount} new commit${commitCount > 1 ? "s" : ""}`,
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: "Recent Changes",
|
||||
value: commitList + moreCommits || "No commits",
|
||||
inline: false
|
||||
},
|
||||
{
|
||||
name: "Files Changed",
|
||||
value: categoryList || "Unknown",
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: "Update Actions",
|
||||
value: reqs.join("\n"),
|
||||
inline: true
|
||||
}
|
||||
)
|
||||
.setFooter({ text: force ? "⚠️ Force mode enabled" : "This will restart the bot" })
|
||||
.setTimestamp();
|
||||
|
||||
const confirmButton = new ButtonBuilder()
|
||||
.setCustomId("confirm_update")
|
||||
.setLabel(force ? "Force Update" : "Update Now")
|
||||
.setEmoji(force ? "⚠️" : "🚀")
|
||||
.setStyle(force ? ButtonStyle.Danger : ButtonStyle.Success);
|
||||
|
||||
const cancelButton = new ButtonBuilder()
|
||||
.setCustomId("cancel_update")
|
||||
.setLabel("Cancel")
|
||||
.setStyle(ButtonStyle.Secondary);
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(confirmButton, cancelButton);
|
||||
|
||||
return { embeds: [embed], components: [row] };
|
||||
}
|
||||
|
||||
// ============ Update Progress Embeds ============
|
||||
|
||||
export function getPreparingEmbed() {
|
||||
return createInfoEmbed(
|
||||
"🔒 Saving rollback point...\n📥 Preparing to download updates...",
|
||||
"⏳ Preparing Update"
|
||||
);
|
||||
}
|
||||
|
||||
export function getUpdatingEmbed(requirements: UpdateCheckResult) {
|
||||
const steps: string[] = ["✅ Rollback point saved"];
|
||||
|
||||
steps.push("📥 Downloading updates...");
|
||||
|
||||
if (requirements.needsRootInstall || requirements.needsWebInstall) {
|
||||
steps.push("📦 Dependencies will be installed after restart");
|
||||
}
|
||||
if (requirements.needsMigrations) {
|
||||
steps.push("🗃️ Migrations will run after restart");
|
||||
}
|
||||
|
||||
steps.push("\n🔄 **Restarting now...**");
|
||||
|
||||
return createWarningEmbed(steps.join("\n"), "🚀 Updating");
|
||||
}
|
||||
|
||||
export function getCancelledEmbed() {
|
||||
return createInfoEmbed("Update cancelled. No changes were made.", "❌ Cancelled");
|
||||
}
|
||||
|
||||
export function getTimeoutEmbed() {
|
||||
return createWarningEmbed(
|
||||
"No response received within 30 seconds.\nRun `/update` again when ready.",
|
||||
"⏰ Timed Out"
|
||||
);
|
||||
}
|
||||
|
||||
export function getErrorEmbed(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return createErrorEmbed(
|
||||
`The update could not be completed:\n\`\`\`\n${truncate(message, 500)}\n\`\`\``,
|
||||
"❌ Update Failed"
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Post-Restart Embeds ============
|
||||
|
||||
export interface PostRestartResult {
|
||||
installSuccess: boolean;
|
||||
installOutput: string;
|
||||
migrationSuccess: boolean;
|
||||
migrationOutput: string;
|
||||
ranInstall: boolean;
|
||||
ranMigrations: boolean;
|
||||
previousCommit?: string;
|
||||
newCommit?: string;
|
||||
}
|
||||
|
||||
export function getPostRestartEmbed(result: PostRestartResult, hasRollback: boolean) {
|
||||
const isSuccess = result.installSuccess && result.migrationSuccess;
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(isSuccess ? "✅ Update Complete" : "⚠️ Update Completed with Issues")
|
||||
.setColor(isSuccess ? 0x57F287 : 0xFEE75C)
|
||||
.setTimestamp();
|
||||
|
||||
// Version info
|
||||
if (result.previousCommit && result.newCommit) {
|
||||
embed.addFields({
|
||||
name: "Version",
|
||||
value: `\`${result.previousCommit}\` → \`${result.newCommit}\``,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
// Results summary
|
||||
const results: string[] = [];
|
||||
|
||||
if (result.ranInstall) {
|
||||
results.push(result.installSuccess
|
||||
? "✅ Dependencies installed"
|
||||
: "❌ Dependency installation failed"
|
||||
);
|
||||
}
|
||||
|
||||
if (result.ranMigrations) {
|
||||
results.push(result.migrationSuccess
|
||||
? "✅ Migrations applied"
|
||||
: "❌ Migration failed"
|
||||
);
|
||||
}
|
||||
|
||||
if (results.length > 0) {
|
||||
embed.addFields({
|
||||
name: "Actions Performed",
|
||||
value: results.join("\n"),
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
// Output details (collapsed if too long)
|
||||
if (result.installOutput && !result.installSuccess) {
|
||||
embed.addFields({
|
||||
name: "Install Output",
|
||||
value: `\`\`\`\n${truncate(result.installOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
if (result.migrationOutput && !result.migrationSuccess) {
|
||||
embed.addFields({
|
||||
name: "Migration Output",
|
||||
value: `\`\`\`\n${truncate(result.migrationOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
// Footer with rollback hint
|
||||
if (!isSuccess && hasRollback) {
|
||||
embed.setFooter({ text: "💡 Use /update rollback to revert if needed" });
|
||||
}
|
||||
|
||||
// Build components
|
||||
const components: ActionRowBuilder<ButtonBuilder>[] = [];
|
||||
|
||||
if (!isSuccess && hasRollback) {
|
||||
const rollbackButton = new ButtonBuilder()
|
||||
.setCustomId("rollback_update")
|
||||
.setLabel("Rollback")
|
||||
.setEmoji("↩️")
|
||||
.setStyle(ButtonStyle.Danger);
|
||||
|
||||
components.push(new ActionRowBuilder<ButtonBuilder>().addComponents(rollbackButton));
|
||||
}
|
||||
|
||||
return { embeds: [embed], components };
|
||||
}
|
||||
|
||||
export function getInstallingDependenciesEmbed() {
|
||||
return createInfoEmbed(
|
||||
"📦 Installing dependencies for root and web projects...\nThis may take a moment.",
|
||||
"⏳ Installing Dependencies"
|
||||
);
|
||||
}
|
||||
|
||||
export function getRunningMigrationsEmbed() {
|
||||
return createInfoEmbed(
|
||||
"🗃️ Applying database migrations...",
|
||||
"⏳ Running Migrations"
|
||||
);
|
||||
}
|
||||
|
||||
export function getRollbackSuccessEmbed(commit: string) {
|
||||
return createSuccessEmbed(
|
||||
`Successfully rolled back to commit \`${commit}\`.\nThe bot will restart now.`,
|
||||
"↩️ Rollback Complete"
|
||||
);
|
||||
}
|
||||
|
||||
export function getRollbackFailedEmbed(error: string) {
|
||||
return createErrorEmbed(
|
||||
`Could not rollback:\n\`\`\`\n${error}\n\`\`\``,
|
||||
"❌ Rollback Failed"
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ButtonInteraction } from "discord.js";
|
||||
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { getLootdropClaimedMessage } from "./lootdrop.view";
|
||||
|
||||
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ButtonInteraction, MessageFlags } from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
export async function handleShopInteraction(interaction: ButtonInteraction) {
|
||||
if (!interaction.customId.startsWith("shop_buy_")) return;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { config } from "@shared/lib/config";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
|
||||
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
||||
// Handle select menu for choosing feedback type
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { EmbedBuilder } from "discord.js";
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
ContainerBuilder,
|
||||
TextDisplayBuilder,
|
||||
SeparatorBuilder,
|
||||
SeparatorSpacingSize,
|
||||
MessageFlags
|
||||
} from "discord.js";
|
||||
|
||||
/**
|
||||
* Quest entry with quest details and progress
|
||||
@@ -7,12 +16,33 @@ interface QuestEntry {
|
||||
progress: number | null;
|
||||
completedAt: Date | null;
|
||||
quest: {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
triggerEvent: string;
|
||||
requirements: any;
|
||||
rewards: any;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Available quest interface
|
||||
*/
|
||||
interface AvailableQuest {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
rewards: any;
|
||||
requirements: any;
|
||||
}
|
||||
|
||||
// Color palette for containers
|
||||
const COLORS = {
|
||||
ACTIVE: 0x3498db, // Blue - in progress
|
||||
AVAILABLE: 0x2ecc71, // Green - available
|
||||
COMPLETED: 0xf1c40f // Gold - completed
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats quest rewards object into a human-readable string
|
||||
*/
|
||||
@@ -20,35 +50,169 @@ function formatQuestRewards(rewards: { xp?: number, balance?: number }): string
|
||||
const rewardStr: string[] = [];
|
||||
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
|
||||
if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`);
|
||||
return rewardStr.join(", ");
|
||||
return rewardStr.join(" • ") || "None";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the quest status display string
|
||||
* Renders a simple progress bar
|
||||
*/
|
||||
function getQuestStatus(completedAt: Date | null): string {
|
||||
return completedAt ? "✅ Completed" : "📝 In Progress";
|
||||
function renderProgressBar(current: number, total: number, size: number = 10): string {
|
||||
const percentage = Math.min(current / total, 1);
|
||||
const progress = Math.round(size * percentage);
|
||||
const empty = size - progress;
|
||||
|
||||
const progressText = "▰".repeat(progress);
|
||||
const emptyText = "▱".repeat(empty);
|
||||
|
||||
return `${progressText}${emptyText} ${Math.round(percentage * 100)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an embed displaying a user's quest log
|
||||
* Creates Components v2 containers for the quest list (active quests only)
|
||||
*/
|
||||
export function getQuestListEmbed(userQuests: QuestEntry[]): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("📜 Quest Log")
|
||||
.setColor(0x3498db); // Blue
|
||||
export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuilder[] {
|
||||
// Filter to only show in-progress quests (not completed)
|
||||
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
|
||||
|
||||
const container = new ContainerBuilder()
|
||||
.setAccentColor(COLORS.ACTIVE)
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("# 📜 Quest Log"),
|
||||
new TextDisplayBuilder().setContent("-# Your active quests")
|
||||
);
|
||||
|
||||
if (activeQuests.length === 0) {
|
||||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||
container.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("*You have no active quests. Check available quests!*")
|
||||
);
|
||||
return [container];
|
||||
}
|
||||
|
||||
activeQuests.forEach((entry) => {
|
||||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||
|
||||
userQuests.forEach(entry => {
|
||||
const status = getQuestStatus(entry.completedAt);
|
||||
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
||||
const rewardsText = formatQuestRewards(rewards);
|
||||
|
||||
embed.addFields({
|
||||
name: `${entry.quest.name} (${status})`,
|
||||
value: `${entry.quest.description}\n**Rewards:** ${rewardsText}\n**Progress:** ${entry.progress}%`,
|
||||
inline: false
|
||||
});
|
||||
const requirements = entry.quest.requirements as { target?: number };
|
||||
const target = requirements?.target || 1;
|
||||
const progress = entry.progress || 0;
|
||||
const progressBar = renderProgressBar(progress, target);
|
||||
|
||||
container.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`**${entry.quest.name}**`),
|
||||
new TextDisplayBuilder().setContent(entry.quest.description || "*No description*"),
|
||||
new TextDisplayBuilder().setContent(`📊 ${progressBar} \`${progress}/${target}\` • 🎁 ${rewardsText}`)
|
||||
);
|
||||
});
|
||||
|
||||
return embed;
|
||||
return [container];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates Components v2 containers for available quests with inline accept buttons
|
||||
*/
|
||||
export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]): ContainerBuilder[] {
|
||||
const container = new ContainerBuilder()
|
||||
.setAccentColor(COLORS.AVAILABLE)
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("# 🗺️ Available Quests"),
|
||||
new TextDisplayBuilder().setContent("-# Quests you can accept")
|
||||
);
|
||||
|
||||
if (availableQuests.length === 0) {
|
||||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||
container.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("*No new quests available at the moment.*")
|
||||
);
|
||||
return [container];
|
||||
}
|
||||
|
||||
// Limit to 10 quests (5 action rows max with 2 added for navigation)
|
||||
const questsToShow = availableQuests.slice(0, 10);
|
||||
|
||||
questsToShow.forEach((quest) => {
|
||||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||
|
||||
const rewards = quest.rewards as { xp?: number, balance?: number };
|
||||
const rewardsText = formatQuestRewards(rewards);
|
||||
|
||||
const requirements = quest.requirements as { target?: number };
|
||||
const target = requirements?.target || 1;
|
||||
|
||||
container.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`**${quest.name}**`),
|
||||
new TextDisplayBuilder().setContent(quest.description || "*No description*"),
|
||||
new TextDisplayBuilder().setContent(`🎯 Goal: \`${target}\` • 🎁 ${rewardsText}`)
|
||||
);
|
||||
|
||||
// Add accept button inline within the container
|
||||
container.addActionRowComponents(
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(`quest_accept:${quest.id}`)
|
||||
.setLabel("Accept Quest")
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setEmoji("✅")
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return [container];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns action rows for navigation only
|
||||
*/
|
||||
export function getQuestActionRows(viewType: 'active' | 'available'): ActionRowBuilder<ButtonBuilder>[] {
|
||||
// Navigation row
|
||||
const navRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId("quest_view_active")
|
||||
.setLabel("📜 Active")
|
||||
.setStyle(viewType === 'active' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
||||
.setDisabled(viewType === 'active'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId("quest_view_available")
|
||||
.setLabel("🗺️ Available")
|
||||
.setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
||||
.setDisabled(viewType === 'available')
|
||||
);
|
||||
|
||||
return [navRow];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates Components v2 celebratory message for quest completion
|
||||
*/
|
||||
export function getQuestCompletionComponents(quest: any, rewards: { xp: bigint, balance: bigint }): ContainerBuilder[] {
|
||||
const rewardsText = formatQuestRewards({
|
||||
xp: Number(rewards.xp),
|
||||
balance: Number(rewards.balance)
|
||||
});
|
||||
|
||||
const container = new ContainerBuilder()
|
||||
.setAccentColor(COLORS.COMPLETED)
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("# 🎉 Quest Completed!"),
|
||||
new TextDisplayBuilder().setContent(`Congratulations! You've completed **${quest.name}**`)
|
||||
)
|
||||
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small))
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`📝 ${quest.description || "No description provided."}`),
|
||||
new TextDisplayBuilder().setContent(`🎁 **Rewards Earned:** ${rewardsText}`)
|
||||
);
|
||||
|
||||
return [container];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets MessageFlags and allowedMentions for Components v2 messages
|
||||
*/
|
||||
export function getComponentsV2MessageFlags() {
|
||||
return {
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
allowedMentions: { parse: [] as const }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { tradeService } from "@shared/modules/trade/trade.service";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view";
|
||||
|
||||
|
||||
|
||||
116
bot/modules/trivia/README.md
Normal file
116
bot/modules/trivia/README.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Trivia - Components v2 Implementation
|
||||
|
||||
This trivia feature uses **Discord Components v2** for a premium visual experience.
|
||||
|
||||
## 🎨 Visual Features
|
||||
|
||||
### **Container with Accent Colors**
|
||||
Each trivia question is displayed in a Container with a colored accent bar that changes based on difficulty:
|
||||
- **🟢 Easy**: Green accent bar (`0x57F287`)
|
||||
- **🟡 Medium**: Yellow accent bar (`0xFEE75C`)
|
||||
- **🔴 Hard**: Red accent bar (`0xED4245`)
|
||||
|
||||
### **Modern Layout Components**
|
||||
- **TextDisplay** - Rich markdown formatting for question text
|
||||
- **Separator** - Visual spacing between sections
|
||||
- **Container** - Groups all content with difficulty-based styling
|
||||
|
||||
### **Interactive Features**
|
||||
✅ **Give Up Button** - Players can forfeit if they're unsure
|
||||
✅ **Disabled Answer Buttons** - After answering, buttons show:
|
||||
- ✅ Green for correct answer
|
||||
- ❌ Red for user's incorrect answer
|
||||
- Gray for other options
|
||||
|
||||
✅ **Time Display** - Shows both relative time (`in 30s`) and seconds remaining
|
||||
✅ **Stakes Preview** - Clear display: `50 AU ➜ 100 AU`
|
||||
|
||||
## 📁 File Structure
|
||||
|
||||
```
|
||||
bot/modules/trivia/
|
||||
├── trivia.view.ts # Components v2 view functions
|
||||
├── trivia.interaction.ts # Button interaction handler
|
||||
└── README.md # This file
|
||||
|
||||
bot/commands/economy/
|
||||
└── trivia.ts # /trivia slash command
|
||||
```
|
||||
|
||||
## 🔧 Technical Details
|
||||
|
||||
### Components v2 Requirements
|
||||
- Uses `MessageFlags.IsComponentsV2` flag
|
||||
- No `embeds` or `content` fields (uses TextDisplay instead)
|
||||
- Numeric component types:
|
||||
- `1` - Action Row
|
||||
- `2` - Button
|
||||
- `10` - Text Display
|
||||
- `14` - Separator
|
||||
- `17` - Container
|
||||
- Max 40 components per message (vs 5 for legacy)
|
||||
|
||||
### Button Styles
|
||||
- **Secondary (2)**: Gray - Used for answer buttons
|
||||
- **Success (3)**: Green - Used for "True" and correct answers
|
||||
- **Danger (4)**: Red - Used for "False", incorrect answers, and "Give Up"
|
||||
|
||||
## 🎮 User Experience Flow
|
||||
|
||||
1. User runs `/trivia`
|
||||
2. Sees question in a Container with difficulty-based accent color
|
||||
3. Can choose to:
|
||||
- Select an answer (A/B/C/D or True/False)
|
||||
- Give up using the 🏳️ button
|
||||
4. After answering, sees result with:
|
||||
- Disabled buttons showing correct/incorrect answers
|
||||
- Container with result-based accent color (green/red/yellow)
|
||||
- Reward or penalty information
|
||||
|
||||
## 🌟 Visual Examples
|
||||
|
||||
### Question Display
|
||||
```
|
||||
┌─[GREEN]─────────────────────────┐
|
||||
│ # 🎯 Trivia Challenge │
|
||||
│ 🟢 Easy • 📚 Geography │
|
||||
│ ─────────────────────────── │
|
||||
│ ### What is the capital of │
|
||||
│ France? │
|
||||
│ │
|
||||
│ ⏱️ Time: in 30s (30s) │
|
||||
│ 💰 Stakes: 50 AU ➜ 100 AU │
|
||||
│ 👤 Player: Username │
|
||||
└─────────────────────────────────┘
|
||||
[🇦 A: Paris] [🇧 B: London]
|
||||
[🇨 C: Berlin] [🇩 D: Madrid]
|
||||
[🏳️ Give Up]
|
||||
```
|
||||
|
||||
### Result Display (Correct)
|
||||
```
|
||||
┌─[GREEN]─────────────────────────┐
|
||||
│ # 🎉 Correct Answer! │
|
||||
│ ### What is the capital of │
|
||||
│ France? │
|
||||
│ ─────────────────────────── │
|
||||
│ ✅ Your answer: Paris │
|
||||
│ │
|
||||
│ 💰 Reward: +100 AU │
|
||||
│ │
|
||||
│ 🏆 Great job! Keep it up! │
|
||||
└─────────────────────────────────┘
|
||||
[✅ A: Paris] [❌ B: London]
|
||||
[❌ C: Berlin] [❌ D: Madrid]
|
||||
(all buttons disabled)
|
||||
```
|
||||
|
||||
## 🚀 Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
- [ ] Thumbnail images based on trivia category
|
||||
- [ ] Progress bar for time remaining
|
||||
- [ ] Streak counter display
|
||||
- [ ] Category-specific accent colors
|
||||
- [ ] Media Gallery for image-based questions
|
||||
- [ ] Leaderboard integration in results
|
||||
129
bot/modules/trivia/trivia.interaction.ts
Normal file
129
bot/modules/trivia/trivia.interaction.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { ButtonInteraction } from "discord.js";
|
||||
import { triviaService } from "@shared/modules/trivia/trivia.service";
|
||||
import { getTriviaResultView, getTriviaTimeoutView } from "./trivia.view";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
export async function handleTriviaInteraction(interaction: ButtonInteraction) {
|
||||
const parts = interaction.customId.split('_');
|
||||
|
||||
// Check for "Give Up" button
|
||||
if (parts.length >= 3 && parts[0] === 'trivia' && parts[1] === 'giveup') {
|
||||
const sessionId = `${parts[2]}_${parts[3]}`;
|
||||
const session = triviaService.getSession(sessionId);
|
||||
|
||||
if (!session) {
|
||||
await interaction.reply({
|
||||
content: '❌ This trivia question has expired or already been answered.',
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate ownership
|
||||
if (session.userId !== interaction.user.id) {
|
||||
await interaction.reply({
|
||||
content: '❌ This isn\'t your trivia question!',
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.deferUpdate();
|
||||
|
||||
// Process as incorrect (user gave up)
|
||||
const result = await triviaService.submitAnswer(sessionId, interaction.user.id, false);
|
||||
|
||||
// Show timeout view (since they gave up)
|
||||
const { components, flags } = getTriviaTimeoutView(
|
||||
session.question.question,
|
||||
session.question.correctAnswer,
|
||||
session.allAnswers,
|
||||
session.entryFee
|
||||
);
|
||||
|
||||
await interaction.editReply({
|
||||
components,
|
||||
flags
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle answer button
|
||||
if (parts.length < 5 || parts[0] !== 'trivia' || parts[1] !== 'answer') {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = `${parts[2]}_${parts[3]}`;
|
||||
const answerIndexStr = parts[4];
|
||||
|
||||
if (!answerIndexStr) {
|
||||
throw new UserError('Invalid answer format.');
|
||||
}
|
||||
|
||||
const answerIndex = parseInt(answerIndexStr);
|
||||
|
||||
// Get session BEFORE deferring to check ownership
|
||||
const session = triviaService.getSession(sessionId);
|
||||
|
||||
if (!session) {
|
||||
// Session doesn't exist or expired
|
||||
await interaction.reply({
|
||||
content: '❌ This trivia question has expired or already been answered.',
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate ownership BEFORE deferring
|
||||
if (session.userId !== interaction.user.id) {
|
||||
// Wrong user trying to answer - send ephemeral error
|
||||
await interaction.reply({
|
||||
content: '❌ This isn\'t your trivia question! Use `/trivia` to start your own game.',
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Only defer if ownership is valid
|
||||
await interaction.deferUpdate();
|
||||
|
||||
// Check timeout
|
||||
if (new Date() > session.expiresAt) {
|
||||
const { components, flags } = getTriviaTimeoutView(
|
||||
session.question.question,
|
||||
session.question.correctAnswer,
|
||||
session.allAnswers,
|
||||
session.entryFee
|
||||
);
|
||||
|
||||
await interaction.editReply({
|
||||
components,
|
||||
flags
|
||||
});
|
||||
|
||||
// Clean up session
|
||||
await triviaService.submitAnswer(sessionId, interaction.user.id, false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if correct
|
||||
const isCorrect = answerIndex === session.correctIndex;
|
||||
const userAnswer = session.allAnswers[answerIndex];
|
||||
|
||||
// Process result
|
||||
const result = await triviaService.submitAnswer(sessionId, interaction.user.id, isCorrect);
|
||||
|
||||
// Update message with enhanced visual feedback
|
||||
const { components, flags } = getTriviaResultView(
|
||||
result,
|
||||
session.question.question,
|
||||
userAnswer,
|
||||
session.allAnswers,
|
||||
session.entryFee
|
||||
);
|
||||
|
||||
await interaction.editReply({
|
||||
components,
|
||||
flags
|
||||
});
|
||||
}
|
||||
336
bot/modules/trivia/trivia.view.ts
Normal file
336
bot/modules/trivia/trivia.view.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import { MessageFlags } from "discord.js";
|
||||
import type { TriviaSession, TriviaResult } from "@shared/modules/trivia/trivia.service";
|
||||
|
||||
/**
|
||||
* Get color based on difficulty level
|
||||
*/
|
||||
function getDifficultyColor(difficulty: string): number {
|
||||
switch (difficulty.toLowerCase()) {
|
||||
case 'easy':
|
||||
return 0x57F287; // Green
|
||||
case 'medium':
|
||||
return 0xFEE75C; // Yellow
|
||||
case 'hard':
|
||||
return 0xED4245; // Red
|
||||
default:
|
||||
return 0x5865F2; // Blurple
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get emoji for difficulty level
|
||||
*/
|
||||
function getDifficultyEmoji(difficulty: string): string {
|
||||
switch (difficulty.toLowerCase()) {
|
||||
case 'easy':
|
||||
return '🟢';
|
||||
case 'medium':
|
||||
return '🟡';
|
||||
case 'hard':
|
||||
return '🔴';
|
||||
default:
|
||||
return '⭐';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Components v2 message for a trivia question
|
||||
*/
|
||||
export function getTriviaQuestionView(session: TriviaSession, username: string): {
|
||||
components: any[];
|
||||
flags: number;
|
||||
} {
|
||||
const { question, allAnswers, entryFee, potentialReward, expiresAt, sessionId } = session;
|
||||
|
||||
// Calculate time remaining
|
||||
const now = Date.now();
|
||||
const timeLeft = Math.max(0, expiresAt.getTime() - now);
|
||||
const secondsLeft = Math.floor(timeLeft / 1000);
|
||||
|
||||
const difficultyEmoji = getDifficultyEmoji(question.difficulty);
|
||||
const difficultyText = question.difficulty.charAt(0).toUpperCase() + question.difficulty.slice(1);
|
||||
const accentColor = getDifficultyColor(question.difficulty);
|
||||
|
||||
const components: any[] = [];
|
||||
|
||||
// Main Container with difficulty accent color
|
||||
components.push({
|
||||
type: 17, // Container
|
||||
accent_color: accentColor,
|
||||
components: [
|
||||
// Title and metadata section
|
||||
{
|
||||
type: 10, // Text Display
|
||||
content: `# 🎯 Trivia Challenge\n**${difficultyEmoji} ${difficultyText}** • 📚 ${question.category}`
|
||||
},
|
||||
// Separator
|
||||
{
|
||||
type: 14, // Separator
|
||||
spacing: 1,
|
||||
divider: true
|
||||
},
|
||||
// Question
|
||||
{
|
||||
type: 10, // Text Display
|
||||
content: `### ${question.question}`
|
||||
},
|
||||
// Stats section
|
||||
{
|
||||
type: 14, // Separator
|
||||
spacing: 1,
|
||||
divider: false
|
||||
},
|
||||
{
|
||||
type: 10, // Text Display
|
||||
content: `⏱️ **Time:** <t:${Math.floor(expiresAt.getTime() / 1000)}:R> (${secondsLeft}s)\n💰 **Stakes:** ${entryFee} AU ➜ ${potentialReward} AU\n👤 **Player:** ${username}`
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Answer buttons
|
||||
if (question.type === 'boolean') {
|
||||
const trueIndex = allAnswers.indexOf('True');
|
||||
const falseIndex = allAnswers.indexOf('False');
|
||||
|
||||
components.push({
|
||||
type: 1, // Action Row
|
||||
components: [
|
||||
{
|
||||
type: 2, // Button
|
||||
custom_id: `trivia_answer_${sessionId}_${trueIndex}`,
|
||||
label: 'True',
|
||||
style: 3, // Success
|
||||
emoji: { name: '✅' }
|
||||
},
|
||||
{
|
||||
type: 2, // Button
|
||||
custom_id: `trivia_answer_${sessionId}_${falseIndex}`,
|
||||
label: 'False',
|
||||
style: 4, // Danger
|
||||
emoji: { name: '❌' }
|
||||
}
|
||||
]
|
||||
});
|
||||
} else {
|
||||
const labels = ['A', 'B', 'C', 'D'];
|
||||
const emojis = ['🇦', '🇧', '🇨', '🇩'];
|
||||
|
||||
const buttonRow: any = {
|
||||
type: 1, // Action Row
|
||||
components: []
|
||||
};
|
||||
|
||||
for (let i = 0; i < allAnswers.length && i < 4; i++) {
|
||||
const label = labels[i];
|
||||
const emoji = emojis[i];
|
||||
const answer = allAnswers[i];
|
||||
|
||||
if (!label || !emoji || !answer) continue;
|
||||
|
||||
buttonRow.components.push({
|
||||
type: 2, // Button
|
||||
custom_id: `trivia_answer_${sessionId}_${i}`,
|
||||
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
|
||||
style: 2, // Secondary
|
||||
emoji: { name: emoji }
|
||||
});
|
||||
}
|
||||
|
||||
components.push(buttonRow);
|
||||
}
|
||||
|
||||
// Give Up button in separate row
|
||||
components.push({
|
||||
type: 1, // Action Row
|
||||
components: [
|
||||
{
|
||||
type: 2, // Button
|
||||
custom_id: `trivia_giveup_${sessionId}`,
|
||||
label: 'Give Up',
|
||||
style: 4, // Danger
|
||||
emoji: { name: '🏳️' }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return {
|
||||
components,
|
||||
flags: MessageFlags.IsComponentsV2
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Components v2 result message
|
||||
*/
|
||||
export function getTriviaResultView(
|
||||
result: TriviaResult,
|
||||
question: string,
|
||||
userAnswer?: string,
|
||||
allAnswers?: string[],
|
||||
entryFee: bigint = 0n
|
||||
): {
|
||||
components: any[];
|
||||
flags: number;
|
||||
} {
|
||||
const { correct, reward, correctAnswer } = result;
|
||||
const components: any[] = [];
|
||||
|
||||
if (correct) {
|
||||
// Success container
|
||||
components.push({
|
||||
type: 17, // Container
|
||||
accent_color: 0x57F287, // Green
|
||||
components: [
|
||||
{
|
||||
type: 10, // Text Display
|
||||
content: `# 🎉 Correct Answer!\n### ${question}`
|
||||
},
|
||||
{
|
||||
type: 14, // Separator
|
||||
spacing: 1,
|
||||
divider: true
|
||||
},
|
||||
{
|
||||
type: 10, // Text Display
|
||||
content: `✅ **Your answer:** ${correctAnswer}\n\n💰 **Reward:** +${reward} AU\n\n🏆 Great job! Keep it up!`
|
||||
}
|
||||
]
|
||||
});
|
||||
} else {
|
||||
const answerDisplay = userAnswer
|
||||
? `❌ **Your answer:** ${userAnswer}\n✅ **Correct answer:** ${correctAnswer}`
|
||||
: `✅ **Correct answer:** ${correctAnswer}`;
|
||||
|
||||
// Error container
|
||||
components.push({
|
||||
type: 17, // Container
|
||||
accent_color: 0xED4245, // Red
|
||||
components: [
|
||||
{
|
||||
type: 10, // Text Display
|
||||
content: `# ❌ Incorrect Answer\n### ${question}`
|
||||
},
|
||||
{
|
||||
type: 14, // Separator
|
||||
spacing: 1,
|
||||
divider: true
|
||||
},
|
||||
{
|
||||
type: 10, // Text Display
|
||||
content: `${answerDisplay}\n\n💸 **Entry fee lost:** ${entryFee} AU\n\n📚 Better luck next time!`
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Show disabled buttons with visual feedback
|
||||
if (allAnswers && allAnswers.length > 0) {
|
||||
const buttonRow: any = {
|
||||
type: 1, // Action Row
|
||||
components: []
|
||||
};
|
||||
|
||||
const labels = ['A', 'B', 'C', 'D'];
|
||||
const emojis = ['🇦', '🇧', '🇨', '🇩'];
|
||||
|
||||
for (let i = 0; i < allAnswers.length && i < 4; i++) {
|
||||
const label = labels[i];
|
||||
const emoji = emojis[i];
|
||||
const answer = allAnswers[i];
|
||||
|
||||
if (!label || !emoji || !answer) continue;
|
||||
|
||||
const isCorrect = answer === correctAnswer;
|
||||
const wasUserAnswer = answer === userAnswer;
|
||||
|
||||
buttonRow.components.push({
|
||||
type: 2, // Button
|
||||
custom_id: `trivia_result_${i}`,
|
||||
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
|
||||
style: isCorrect ? 3 : wasUserAnswer ? 4 : 2, // Success : Danger : Secondary
|
||||
emoji: { name: isCorrect ? '✅' : wasUserAnswer ? '❌' : emoji },
|
||||
disabled: true
|
||||
});
|
||||
}
|
||||
|
||||
components.push(buttonRow);
|
||||
}
|
||||
|
||||
return {
|
||||
components,
|
||||
flags: MessageFlags.IsComponentsV2
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Components v2 timeout message
|
||||
*/
|
||||
export function getTriviaTimeoutView(
|
||||
question: string,
|
||||
correctAnswer: string,
|
||||
allAnswers?: string[],
|
||||
entryFee: bigint = 0n
|
||||
): {
|
||||
components: any[];
|
||||
flags: number;
|
||||
} {
|
||||
const components: any[] = [];
|
||||
|
||||
// Timeout container
|
||||
components.push({
|
||||
type: 17, // Container
|
||||
accent_color: 0xFEE75C, // Yellow
|
||||
components: [
|
||||
{
|
||||
type: 10, // Text Display
|
||||
content: `# ⏱️ Time's Up!\n### ${question}`
|
||||
},
|
||||
{
|
||||
type: 14, // Separator
|
||||
spacing: 1,
|
||||
divider: true
|
||||
},
|
||||
{
|
||||
type: 10, // Text Display
|
||||
content: `⏰ **You ran out of time!**\n✅ **Correct answer:** ${correctAnswer}\n\n💸 **Entry fee lost:** ${entryFee} AU\n\n⚡ Be faster next time!`
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Show disabled buttons with correct answer highlighted
|
||||
if (allAnswers && allAnswers.length > 0) {
|
||||
const buttonRow: any = {
|
||||
type: 1, // Action Row
|
||||
components: []
|
||||
};
|
||||
|
||||
const labels = ['A', 'B', 'C', 'D'];
|
||||
const emojis = ['🇦', '🇧', '🇨', '🇩'];
|
||||
|
||||
for (let i = 0; i < allAnswers.length && i < 4; i++) {
|
||||
const label = labels[i];
|
||||
const emoji = emojis[i];
|
||||
const answer = allAnswers[i];
|
||||
|
||||
if (!label || !emoji || !answer) continue;
|
||||
|
||||
const isCorrect = answer === correctAnswer;
|
||||
|
||||
buttonRow.components.push({
|
||||
type: 2, // Button
|
||||
custom_id: `trivia_timeout_${i}`,
|
||||
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
|
||||
style: isCorrect ? 3 : 2, // Success : Secondary
|
||||
emoji: { name: isCorrect ? '✅' : emoji },
|
||||
disabled: true
|
||||
});
|
||||
}
|
||||
|
||||
components.push(buttonRow);
|
||||
}
|
||||
|
||||
return {
|
||||
components,
|
||||
flags: MessageFlags.IsComponentsV2
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { config } from "@shared/lib/config";
|
||||
import { getEnrollmentSuccessMessage } from "./enrollment.view";
|
||||
import { classService } from "@shared/modules/class/class.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
||||
|
||||
export async function handleEnrollmentInteraction(interaction: ButtonInteraction) {
|
||||
|
||||
36
bun.lock
36
bun.lock
@@ -9,12 +9,12 @@
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"postgres": "^3.4.7",
|
||||
"zod": "^4.1.13",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"drizzle-kit": "^0.31.7",
|
||||
"postgres": "^3.4.7",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
@@ -92,27 +92,29 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.84", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.84", "@napi-rs/canvas-darwin-arm64": "0.1.84", "@napi-rs/canvas-darwin-x64": "0.1.84", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.84", "@napi-rs/canvas-linux-arm64-gnu": "0.1.84", "@napi-rs/canvas-linux-arm64-musl": "0.1.84", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.84", "@napi-rs/canvas-linux-x64-gnu": "0.1.84", "@napi-rs/canvas-linux-x64-musl": "0.1.84", "@napi-rs/canvas-win32-x64-msvc": "0.1.84" } }, "sha512-88FTNFs4uuiFKP0tUrPsEXhpe9dg7za9ILZJE08pGdUveMIDeana1zwfVkqRHJDPJFAmGY3dXmJ99dzsy57YnA=="],
|
||||
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.89", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.89", "@napi-rs/canvas-darwin-arm64": "0.1.89", "@napi-rs/canvas-darwin-x64": "0.1.89", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.89", "@napi-rs/canvas-linux-arm64-gnu": "0.1.89", "@napi-rs/canvas-linux-arm64-musl": "0.1.89", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.89", "@napi-rs/canvas-linux-x64-gnu": "0.1.89", "@napi-rs/canvas-linux-x64-musl": "0.1.89", "@napi-rs/canvas-win32-arm64-msvc": "0.1.89", "@napi-rs/canvas-win32-x64-msvc": "0.1.89" } }, "sha512-7GjmkMirJHejeALCqUnZY3QwID7bbumOiLrqq2LKgxrdjdmxWQBTc6rcASa2u8wuWrH7qo4/4n/VNrOwCoKlKg=="],
|
||||
|
||||
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.84", "", { "os": "android", "cpu": "arm64" }, "sha512-pdvuqvj3qtwVryqgpAGornJLV6Ezpk39V6wT4JCnRVGy8I3Tk1au8qOalFGrx/r0Ig87hWslysPpHBxVpBMIww=="],
|
||||
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.89", "", { "os": "android", "cpu": "arm64" }, "sha512-CXxQTXsjtQqKGENS8Ejv9pZOFJhOPIl2goenS+aU8dY4DygvkyagDhy/I07D1YLqrDtPvLEX5zZHt8qUdnuIpQ=="],
|
||||
|
||||
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.84", "", { "os": "darwin", "cpu": "arm64" }, "sha512-A8IND3Hnv0R6abc6qCcCaOCujTLMmGxtucMTZ5vbQUrEN/scxi378MyTLtyWg+MRr6bwQJ6v/orqMS9datIcww=="],
|
||||
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.89", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k29cR/Zl20WLYM7M8YePevRu2VQRaKcRedYr1V/8FFHkyIQ8kShEV+MPoPGi+znvmd17Eqjy2Pk2F2kpM2umVg=="],
|
||||
|
||||
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.84", "", { "os": "darwin", "cpu": "x64" }, "sha512-AUW45lJhYWwnA74LaNeqhvqYKK/2hNnBBBl03KRdqeCD4tKneUSrxUqIv8d22CBweOvrAASyKN3W87WO2zEr/A=="],
|
||||
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.89", "", { "os": "darwin", "cpu": "x64" }, "sha512-iUragqhBrA5FqU13pkhYBDbUD1WEAIlT8R2+fj6xHICY2nemzwMUI8OENDhRh7zuL06YDcRwENbjAVxOmaX9jg=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.84", "", { "os": "linux", "cpu": "arm" }, "sha512-8zs5ZqOrdgs4FioTxSBrkl/wHZB56bJNBqaIsfPL4ZkEQCinOkrFF7xIcXiHiKp93J3wUtbIzeVrhTIaWwqk+A=="],
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.89", "", { "os": "linux", "cpu": "arm" }, "sha512-y3SM9sfDWasY58ftoaI09YBFm35Ig8tosZqgahLJ2WGqawCusGNPV9P0/4PsrLOCZqGg629WxexQMY25n7zcvA=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.84", "", { "os": "linux", "cpu": "arm64" }, "sha512-i204vtowOglJUpbAFWU5mqsJgH0lVpNk/Ml4mQtB4Lndd86oF+Otr6Mr5KQnZHqYGhlSIKiU2SYnUbhO28zGQA=="],
|
||||
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.89", "", { "os": "linux", "cpu": "arm64" }, "sha512-NEoF9y8xq5fX8HG8aZunBom1ILdTwt7ayBzSBIwrmitk7snj4W6Fz/yN/ZOmlM1iyzHDNX5Xn0n+VgWCF8BEdA=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.84", "", { "os": "linux", "cpu": "arm64" }, "sha512-VyZq0EEw+OILnWk7G3ZgLLPaz1ERaPP++jLjeyLMbFOF+Tr4zHzWKiKDsEV/cT7btLPZbVoR3VX+T9/QubnURQ=="],
|
||||
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.89", "", { "os": "linux", "cpu": "arm64" }, "sha512-UQQkIEzV12/l60j1ziMjZ+mtodICNUbrd205uAhbyTw0t60CrC/EsKb5/aJWGq1wM0agvcgZV72JJCKfLS6+4w=="],
|
||||
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.84", "", { "os": "linux", "cpu": "none" }, "sha512-PSMTh8DiThvLRsbtc/a065I/ceZk17EXAATv9uNvHgkgo7wdEfTh2C3aveNkBMGByVO3tvnvD5v/YFtZL07cIg=="],
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.89", "", { "os": "linux", "cpu": "none" }, "sha512-1/VmEoFaIO6ONeeEMGoWF17wOYZOl5hxDC1ios2Bkz/oQjbJJ8DY/X22vWTmvuUKWWhBVlo63pxLGZbjJU/heA=="],
|
||||
|
||||
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.84", "", { "os": "linux", "cpu": "x64" }, "sha512-N1GY3noO1oqgEo3rYQIwY44kfM11vA0lDbN0orTOHfCSUZTUyiYCY0nZ197QMahZBm1aR/vYgsWpV74MMMDuNA=="],
|
||||
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.89", "", { "os": "linux", "cpu": "x64" }, "sha512-ebLuqkCuaPIkKgKH9q4+pqWi1tkPOfiTk5PM1LKR1tB9iO9sFNVSIgwEp+SJreTSbA2DK5rW8lQXiN78SjtcvA=="],
|
||||
|
||||
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.84", "", { "os": "linux", "cpu": "x64" }, "sha512-vUZmua6ADqTWyHyei81aXIt9wp0yjeNwTH0KdhdeoBb6azHmFR8uKTukZMXfLCC3bnsW0t4lW7K78KNMknmtjg=="],
|
||||
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.89", "", { "os": "linux", "cpu": "x64" }, "sha512-w+5qxHzplvA4BkHhCaizNMLLXiI+CfP84YhpHm/PqMub4u8J0uOAv+aaGv40rYEYra5hHRWr9LUd6cfW32o9/A=="],
|
||||
|
||||
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.84", "", { "os": "win32", "cpu": "x64" }, "sha512-YSs8ncurc1xzegUMNnQUTYrdrAuaXdPMOa+iYYyAxydOtg0ppV386hyYMsy00Yip1NlTgLCseRG4sHSnjQx6og=="],
|
||||
"@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.89", "", { "os": "win32", "cpu": "arm64" }, "sha512-DmyXa5lJHcjOsDC78BM3bnEECqbK3xASVMrKfvtT/7S7Z8NGQOugvu+L7b41V6cexCd34mBWgMOsjoEBceeB1Q=="],
|
||||
|
||||
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.89", "", { "os": "win32", "cpu": "x64" }, "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA=="],
|
||||
|
||||
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
|
||||
|
||||
@@ -120,7 +122,7 @@
|
||||
|
||||
"@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="],
|
||||
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||
|
||||
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
||||
|
||||
@@ -130,7 +132,7 @@
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
|
||||
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
@@ -140,7 +142,7 @@
|
||||
|
||||
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@0.31.7", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-hOzRGSdyKIU4FcTSFYGKdXEjFsncVwHZ43gY3WU5Bz9j5Iadp6Rh6hxLSQ1IWXpKLBKt/d5y1cpSPcV+FcoQ1A=="],
|
||||
"drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="],
|
||||
|
||||
@@ -160,7 +162,7 @@
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
|
||||
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
@@ -180,7 +182,7 @@
|
||||
|
||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="],
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||
|
||||
|
||||
81
docker-compose.prod.yml
Normal file
81
docker-compose.prod.yml
Normal file
@@ -0,0 +1,81 @@
|
||||
# Production Docker Compose Configuration
|
||||
# Usage: docker compose -f docker-compose.prod.yml up -d
|
||||
#
|
||||
# IMPORTANT: Database data is preserved in ./shared/db/data volume
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
container_name: aurora_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=${DB_USER}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- POSTGRES_DB=${DB_NAME}
|
||||
volumes:
|
||||
# Database data - persisted across container rebuilds
|
||||
- ./shared/db/data:/var/lib/postgresql/data
|
||||
- ./shared/db/log:/var/log/postgresql
|
||||
networks:
|
||||
- internal
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
# Security: limit resources
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
|
||||
app:
|
||||
container_name: aurora_app
|
||||
restart: unless-stopped
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.prod
|
||||
target: production
|
||||
image: aurora-app:latest
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
|
||||
working_dir: /app
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- HOST=0.0.0.0
|
||||
- DB_USER=${DB_USER}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- DB_NAME=${DB_NAME}
|
||||
- DB_PORT=5432
|
||||
- DB_HOST=db
|
||||
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN}
|
||||
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
|
||||
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
||||
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
||||
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
networks:
|
||||
- internal
|
||||
- web
|
||||
# Security: limit resources
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
# Logging configuration
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
internal:
|
||||
driver: bridge
|
||||
internal: true # No external access - DB isolated
|
||||
web:
|
||||
driver: bridge # App accessible from host (via reverse proxy)
|
||||
@@ -7,13 +7,14 @@ services:
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- POSTGRES_DB=${DB_NAME}
|
||||
# Uncomment to access DB from host (for debugging/drizzle-kit studio)
|
||||
# ports:
|
||||
# - "127.0.0.1:${DB_PORT}:5432"
|
||||
ports:
|
||||
- "127.0.0.1:${DB_PORT}:5432"
|
||||
volumes:
|
||||
# Host-mounted to preserve existing VPS data
|
||||
- ./shared/db/data:/var/lib/postgresql/data
|
||||
- ./shared/db/log:/var/log/postgresql
|
||||
networks:
|
||||
- internal
|
||||
- web
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}" ]
|
||||
interval: 5s
|
||||
@@ -23,17 +24,19 @@ services:
|
||||
app:
|
||||
container_name: aurora_app
|
||||
restart: unless-stopped
|
||||
image: aurora-app
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: development # Use development stage
|
||||
working_dir: /app
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
volumes:
|
||||
# Mount source code for hot reloading
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
- /app/web/node_modules
|
||||
# Use named volumes for node_modules (prevents host overwrite + caches deps)
|
||||
- app_node_modules:/app/node_modules
|
||||
- web_node_modules:/app/web/node_modules
|
||||
environment:
|
||||
- HOST=0.0.0.0
|
||||
- DB_USER=${DB_USER}
|
||||
@@ -61,30 +64,22 @@ services:
|
||||
|
||||
studio:
|
||||
container_name: aurora_studio
|
||||
image: aurora-app
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
working_dir: /app
|
||||
ports:
|
||||
# Reuse the same built image as app (no duplicate builds!)
|
||||
extends:
|
||||
service: app
|
||||
# Clear inherited ports from app and only expose studio port
|
||||
ports: !override
|
||||
- "127.0.0.1:4983:4983"
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
- /app/web/node_modules
|
||||
environment:
|
||||
- DB_USER=${DB_USER}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- DB_NAME=${DB_NAME}
|
||||
- DB_PORT=5432
|
||||
- DB_HOST=db
|
||||
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- internal
|
||||
command: bun run db:studio
|
||||
# Override healthcheck since studio doesn't serve on port 3000
|
||||
healthcheck:
|
||||
test: [ "CMD", "bun", "-e", "fetch('http://localhost:4983').then(r => process.exit(0)).catch(() => process.exit(1))" ]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
# Disable restart for studio (it's an on-demand tool)
|
||||
restart: "no"
|
||||
command: [ "bun", "x", "drizzle-kit", "studio", "--port", "4983", "--host", "0.0.0.0" ]
|
||||
|
||||
networks:
|
||||
internal:
|
||||
@@ -92,3 +87,10 @@ networks:
|
||||
internal: true # No external access
|
||||
web:
|
||||
driver: bridge # Can be accessed from host
|
||||
|
||||
volumes:
|
||||
# Named volumes for node_modules caching
|
||||
app_node_modules:
|
||||
name: aurora_app_node_modules
|
||||
web_node_modules:
|
||||
name: aurora_web_node_modules
|
||||
|
||||
168
docs/main.md
Normal file
168
docs/main.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# Aurora - Discord RPG Bot
|
||||
|
||||
A comprehensive, feature-rich Discord RPG bot built with modern technologies using a monorepo architecture.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Aurora uses a **Single Process Monolith** architecture that runs both the Discord bot and web dashboard in the same Bun process. This design maximizes performance by eliminating inter-process communication overhead and simplifies deployment to a single Docker container.
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
```
|
||||
aurora-bot-discord/
|
||||
├── bot/ # Discord bot implementation
|
||||
│ ├── commands/ # Slash command implementations
|
||||
│ ├── events/ # Discord event handlers
|
||||
│ ├── lib/ # Bot core logic (BotClient, utilities)
|
||||
│ └── index.ts # Bot entry point
|
||||
├── web/ # React web dashboard
|
||||
│ ├── src/ # React components and pages
|
||||
│ │ ├── pages/ # Dashboard pages (Admin, Settings, Home)
|
||||
│ │ ├── components/ # Reusable UI components
|
||||
│ │ └── server.ts # Web server with API endpoints
|
||||
│ └── build.ts # Vite build configuration
|
||||
├── shared/ # Shared code between bot and web
|
||||
│ ├── db/ # Database schema and Drizzle ORM
|
||||
│ ├── lib/ # Utilities, config, logger, events
|
||||
│ ├── modules/ # Domain services (economy, admin, quest)
|
||||
│ └── config/ # Configuration files
|
||||
├── docker-compose.yml # Docker services (app, db)
|
||||
└── package.json # Root package manifest
|
||||
```
|
||||
|
||||
## Main Application Parts
|
||||
|
||||
### 1. Discord Bot (`bot/`)
|
||||
|
||||
The bot is built with Discord.js v14 and handles all Discord-related functionality.
|
||||
|
||||
**Core Components:**
|
||||
|
||||
- **BotClient** (`bot/lib/BotClient.ts`): Central client that manages commands, events, and Discord interactions
|
||||
- **Commands** (`bot/commands/`): Slash command implementations organized by category:
|
||||
- `admin/`: Server management commands (config, prune, warnings, notes)
|
||||
- `economy/`: Economy commands (balance, daily, pay, trade, trivia)
|
||||
- `inventory/`: Item management commands
|
||||
- `leveling/`: XP and level tracking
|
||||
- `quest/`: Quest commands
|
||||
- `user/`: User profile commands
|
||||
- **Events** (`bot/events/`): Discord event handlers:
|
||||
- `interactionCreate.ts`: Command interactions
|
||||
- `messageCreate.ts`: Message processing
|
||||
- `ready.ts`: Bot ready events
|
||||
- `guildMemberAdd.ts`: New member handling
|
||||
|
||||
### 2. Web Dashboard (`web/`)
|
||||
|
||||
A React 19 + Bun web application for bot administration and monitoring.
|
||||
|
||||
**Key Pages:**
|
||||
|
||||
- **Home** (`/`): Dashboard overview with live statistics
|
||||
- **Admin Overview** (`/admin/overview`): Real-time bot metrics
|
||||
- **Admin Quests** (`/admin/quests`): Quest management interface
|
||||
- **Settings** (`/settings/*`): Configuration pages for:
|
||||
- General settings
|
||||
- Economy settings
|
||||
- Systems settings
|
||||
- Roles settings
|
||||
|
||||
**Web Server Features:**
|
||||
|
||||
- Built with Bun's native HTTP server
|
||||
- WebSocket support for real-time updates
|
||||
- REST API endpoints for dashboard data
|
||||
- SPA fallback for client-side routing
|
||||
- Bun dev server with hot module replacement
|
||||
|
||||
### 3. Shared Core (`shared/`)
|
||||
|
||||
Shared code accessible by both bot and web applications.
|
||||
|
||||
**Database Layer (`shared/db/`):**
|
||||
|
||||
- **schema.ts**: Drizzle ORM schema definitions for:
|
||||
- `users`: User profiles with economy data
|
||||
- `items`: Item catalog with rarities and types
|
||||
- `inventory`: User item holdings
|
||||
- `transactions`: Economy transaction history
|
||||
- `classes`: RPG class system
|
||||
- `moderationCases`: Moderation logs
|
||||
- `quests`: Quest definitions
|
||||
|
||||
**Modules (`shared/modules/`):**
|
||||
|
||||
- **economy/**: Economy service, lootdrops, daily rewards, trading
|
||||
- **admin/**: Administrative actions (maintenance mode, cache clearing)
|
||||
- **quest/**: Quest creation and tracking
|
||||
- **dashboard/**: Dashboard statistics and real-time event bus
|
||||
- **leveling/**: XP and leveling logic
|
||||
|
||||
**Utilities (`shared/lib/`):**
|
||||
|
||||
- `config.ts`: Application configuration management
|
||||
- `logger.ts`: Structured logging system
|
||||
- `env.ts`: Environment variable handling
|
||||
- `events.ts`: Event bus for inter-module communication
|
||||
- `constants.ts`: Application-wide constants
|
||||
|
||||
## Main Use-Cases
|
||||
|
||||
### For Discord Users
|
||||
|
||||
1. **Class System**: Users can join different RPG classes with unique roles
|
||||
2. **Economy**:
|
||||
- View balance and net worth
|
||||
- Earn currency through daily rewards, trivia, and lootdrops
|
||||
- Send payments to other users
|
||||
3. **Trading**: Secure trading system between users
|
||||
4. **Inventory Management**: Collect, use, and trade items with rarities
|
||||
5. **Leveling**: XP-based progression system tied to activity
|
||||
6. **Quests**: Complete quests for rewards
|
||||
7. **Lootdrops**: Random currency drops in text channels
|
||||
|
||||
### For Server Administrators
|
||||
|
||||
1. **Bot Configuration**: Adjust economy rates, enable/disable features via dashboard
|
||||
2. **Moderation Tools**:
|
||||
- Warn, note, and track moderation cases
|
||||
- Mass prune inactive members
|
||||
- Role management
|
||||
3. **Quest Management**: Create and manage server-specific quests
|
||||
4. **Monitoring**:
|
||||
- Real-time dashboard with live statistics
|
||||
- Activity charts and event logs
|
||||
- Economy leaderboards
|
||||
|
||||
### For Developers
|
||||
|
||||
1. **Single Process Architecture**: Easy debugging with unified runtime
|
||||
2. **Type Safety**: Full TypeScript across all modules
|
||||
3. **Testing**: Bun test framework with unit tests for core services
|
||||
4. **Docker Support**: Production-ready containerization
|
||||
5. **Remote Access**: SSH tunneling scripts for production debugging
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Layer | Technology |
|
||||
| ---------------- | --------------------------------- |
|
||||
| Runtime | Bun 1.0+ |
|
||||
| Bot Framework | Discord.js 14.x |
|
||||
| Web Framework | React 19 + Bun |
|
||||
| Database | PostgreSQL 17 |
|
||||
| ORM | Drizzle ORM |
|
||||
| Styling | Tailwind CSS v4 + ShadCN/Radix UI |
|
||||
| Validation | Zod |
|
||||
| Containerization | Docker |
|
||||
|
||||
## Running the Application
|
||||
|
||||
```bash
|
||||
# Database migrations
|
||||
bun run migrate
|
||||
|
||||
# Production (Docker)
|
||||
docker compose up
|
||||
```
|
||||
|
||||
The bot and dashboard process run on port 3000 and are accessible at `http://localhost:3000`.
|
||||
20
package.json
20
package.json
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "app",
|
||||
"version": "1.1.3",
|
||||
"module": "bot/index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"drizzle-kit": "^0.31.7"
|
||||
"drizzle-kit": "^0.31.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "docker compose run --rm app drizzle-kit generate",
|
||||
@@ -16,18 +17,19 @@
|
||||
"db:push": "docker compose run --rm app drizzle-kit push",
|
||||
"db:push:local": "drizzle-kit push",
|
||||
"dev": "bun --watch bot/index.ts",
|
||||
"db:studio": "drizzle-kit studio --host 0.0.0.0",
|
||||
"studio:remote": "bash shared/scripts/remote-studio.sh",
|
||||
"dashboard:remote": "bash shared/scripts/remote-dashboard.sh",
|
||||
"db:studio": "drizzle-kit studio --port 4983 --host 0.0.0.0",
|
||||
"remote": "bash shared/scripts/remote.sh",
|
||||
"test": "bun test"
|
||||
"logs": "bash shared/scripts/logs.sh",
|
||||
"db:backup": "bash shared/scripts/db-backup.sh",
|
||||
"test": "bun test",
|
||||
"docker:cleanup": "bash shared/scripts/docker-cleanup.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "^0.1.84",
|
||||
"@napi-rs/canvas": "^0.1.89",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"postgres": "^3.4.7",
|
||||
"zod": "^4.1.13"
|
||||
"postgres": "^3.4.8",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
import { drizzle } from "drizzle-orm/bun-sql";
|
||||
import { SQL } from "bun";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgresJs from "postgres"; // Renamed import
|
||||
import * as schema from "./schema";
|
||||
import { env } from "@shared/lib/env";
|
||||
|
||||
const connectionString = env.DATABASE_URL;
|
||||
export const postgres = new SQL(connectionString);
|
||||
|
||||
export const DrizzleClient = drizzle(postgres, { schema });
|
||||
// Disable prefetch to prevent connection handling issues in serverless/container environments
|
||||
const client = postgresJs(connectionString, { prepare: false });
|
||||
|
||||
export const DrizzleClient = drizzle(client, { schema });
|
||||
|
||||
// Export the raw client as 'postgres' to match previous Bun.SQL export name/usage
|
||||
export const postgres = client;
|
||||
|
||||
export const closeDatabase = async () => {
|
||||
await postgres.close();
|
||||
await client.end();
|
||||
};
|
||||
@@ -1,42 +1,43 @@
|
||||
import { expect, test, describe } from "bun:test";
|
||||
import { postgres } from "./DrizzleClient";
|
||||
import { DrizzleClient } from "./DrizzleClient";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
describe("Database Indexes", () => {
|
||||
test("should have indexes on users table", async () => {
|
||||
const result = await postgres`
|
||||
const result = await DrizzleClient.execute(sql`
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'users'
|
||||
`;
|
||||
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
|
||||
`);
|
||||
const indexNames = result.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`
|
||||
const result = await DrizzleClient.execute(sql`
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'transactions'
|
||||
`;
|
||||
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
|
||||
`);
|
||||
const indexNames = result.map(r => r.indexname);
|
||||
expect(indexNames).toContain("transactions_created_at_idx");
|
||||
});
|
||||
|
||||
test("should have indexes on moderation_cases table", async () => {
|
||||
const result = await postgres`
|
||||
const result = await DrizzleClient.execute(sql`
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'moderation_cases'
|
||||
`;
|
||||
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
|
||||
`);
|
||||
const indexNames = result.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`
|
||||
const result = await DrizzleClient.execute(sql`
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'user_timers'
|
||||
`;
|
||||
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
|
||||
`);
|
||||
const indexNames = result.map(r => r.indexname);
|
||||
expect(indexNames).toContain("user_timers_expires_at_idx");
|
||||
expect(indexNames).toContain("user_timers_lookup_idx");
|
||||
});
|
||||
|
||||
@@ -13,7 +13,13 @@ import {
|
||||
bigserial,
|
||||
check
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { relations, sql } from 'drizzle-orm';
|
||||
import { relations, sql, type InferSelectModel } from 'drizzle-orm';
|
||||
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Transaction = InferSelectModel<typeof transactions>;
|
||||
export type ModerationCase = InferSelectModel<typeof moderationCases>;
|
||||
export type Item = InferSelectModel<typeof items>;
|
||||
export type Inventory = InferSelectModel<typeof inventory>;
|
||||
|
||||
// --- TABLES ---
|
||||
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
# 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'. |
|
||||
@@ -1,160 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,149 +0,0 @@
|
||||
# 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. |
|
||||
@@ -1,127 +0,0 @@
|
||||
# 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..."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -1,72 +0,0 @@
|
||||
# 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`.
|
||||
@@ -1,3 +1,4 @@
|
||||
import { jsonReplacer } from './utils';
|
||||
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
@@ -69,6 +70,14 @@ export interface GameConfigType {
|
||||
autoTimeoutThreshold?: number;
|
||||
};
|
||||
};
|
||||
trivia: {
|
||||
entryFee: bigint;
|
||||
rewardMultiplier: number;
|
||||
timeoutSeconds: number;
|
||||
cooldownMs: number;
|
||||
categories: number[];
|
||||
difficulty: 'easy' | 'medium' | 'hard' | 'random';
|
||||
};
|
||||
system: Record<string, any>;
|
||||
}
|
||||
|
||||
@@ -162,6 +171,21 @@ const configSchema = z.object({
|
||||
dmOnWarn: true
|
||||
}
|
||||
}),
|
||||
trivia: z.object({
|
||||
entryFee: bigIntSchema,
|
||||
rewardMultiplier: z.number().min(0).max(10),
|
||||
timeoutSeconds: z.number().min(5).max(300),
|
||||
cooldownMs: z.number().min(0),
|
||||
categories: z.array(z.number()).default([]),
|
||||
difficulty: z.enum(['easy', 'medium', 'hard', 'random']).default('random'),
|
||||
}).default({
|
||||
entryFee: 50n,
|
||||
rewardMultiplier: 1.8,
|
||||
timeoutSeconds: 30,
|
||||
cooldownMs: 60000,
|
||||
categories: [],
|
||||
difficulty: 'random'
|
||||
}),
|
||||
system: z.record(z.string(), z.any()).default({}),
|
||||
});
|
||||
|
||||
@@ -191,14 +215,7 @@ export function saveConfig(newConfig: unknown) {
|
||||
// Validate and transform input
|
||||
const validatedConfig = configSchema.parse(newConfig);
|
||||
|
||||
const replacer = (key: string, value: any) => {
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const jsonString = JSON.stringify(validatedConfig, replacer, 4);
|
||||
const jsonString = JSON.stringify(validatedConfig, jsonReplacer, 4);
|
||||
writeFileSync(configPath, jsonString, 'utf-8');
|
||||
reloadConfig();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export enum TimerType {
|
||||
EFFECT = 'EFFECT',
|
||||
ACCESS = 'ACCESS',
|
||||
EXAM_SYSTEM = 'EXAM_SYSTEM',
|
||||
TRIVIA_COOLDOWN = 'TRIVIA_COOLDOWN',
|
||||
}
|
||||
|
||||
export enum EffectType {
|
||||
@@ -30,6 +31,8 @@ export enum TransactionType {
|
||||
TRADE_IN = 'TRADE_IN',
|
||||
TRADE_OUT = 'TRADE_OUT',
|
||||
QUEST_REWARD = 'QUEST_REWARD',
|
||||
TRIVIA_ENTRY = 'TRIVIA_ENTRY',
|
||||
TRIVIA_WIN = 'TRIVIA_WIN',
|
||||
}
|
||||
|
||||
export enum ItemTransactionType {
|
||||
@@ -63,3 +66,28 @@ export enum LootType {
|
||||
XP = 'XP',
|
||||
ITEM = 'ITEM',
|
||||
}
|
||||
|
||||
export enum TriviaCategory {
|
||||
GENERAL_KNOWLEDGE = 9,
|
||||
BOOKS = 10,
|
||||
FILM = 11,
|
||||
MUSIC = 12,
|
||||
VIDEO_GAMES = 15,
|
||||
SCIENCE_NATURE = 17,
|
||||
COMPUTERS = 18,
|
||||
MATHEMATICS = 19,
|
||||
MYTHOLOGY = 20,
|
||||
SPORTS = 21,
|
||||
GEOGRAPHY = 22,
|
||||
HISTORY = 23,
|
||||
POLITICS = 24,
|
||||
ART = 25,
|
||||
ANIMALS = 27,
|
||||
ANIME_MANGA = 31,
|
||||
}
|
||||
|
||||
export const BRANDING = {
|
||||
COLOR: 0x00d4ff as const,
|
||||
FOOTER_TEXT: 'Aurora' as const,
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ const envSchema = z.object({
|
||||
DATABASE_URL: z.string().min(1, "Database URL is required"),
|
||||
PORT: z.coerce.number().default(3000),
|
||||
HOST: z.string().default("127.0.0.1"),
|
||||
ADMIN_TOKEN: z.string().min(8, "ADMIN_TOKEN must be at least 8 characters").optional(),
|
||||
});
|
||||
|
||||
const parsedEnv = envSchema.safeParse(process.env);
|
||||
|
||||
24
shared/lib/events.ts
Normal file
24
shared/lib/events.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
/**
|
||||
* Global system event bus for cross-module communication.
|
||||
* Used primarily for real-time dashboard updates.
|
||||
*/
|
||||
class SystemEventEmitter extends EventEmitter { }
|
||||
|
||||
export const systemEvents = new SystemEventEmitter();
|
||||
|
||||
export const EVENTS = {
|
||||
DASHBOARD: {
|
||||
STATS_UPDATE: "dashboard:stats_update",
|
||||
NEW_EVENT: "dashboard:new_event",
|
||||
},
|
||||
ACTIONS: {
|
||||
RELOAD_COMMANDS: "actions:reload_commands",
|
||||
CLEAR_CACHE: "actions:clear_cache",
|
||||
MAINTENANCE_MODE: "actions:maintenance_mode",
|
||||
},
|
||||
QUEST: {
|
||||
COMPLETED: "quest:completed",
|
||||
}
|
||||
} as const;
|
||||
118
shared/lib/logger.test.ts
Normal file
118
shared/lib/logger.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { expect, test, describe, beforeAll, afterAll, spyOn } from "bun:test";
|
||||
import { logger } from "./logger";
|
||||
import { existsSync, unlinkSync, readFileSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
describe("Logger", () => {
|
||||
const logDir = join(process.cwd(), "logs");
|
||||
const logFile = join(logDir, "error.log");
|
||||
|
||||
beforeAll(() => {
|
||||
// Cleanup if exists
|
||||
try {
|
||||
if (existsSync(logFile)) unlinkSync(logFile);
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
test("should log info messages to console with correct format", () => {
|
||||
const spy = spyOn(console, "log");
|
||||
const message = "Formatting test";
|
||||
logger.info("system", message);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
const callArgs = spy.mock.calls[0]?.[0];
|
||||
expect(callArgs).toBeDefined();
|
||||
if (callArgs) {
|
||||
// Strict regex check for ISO timestamp and format
|
||||
const regex = /^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[INFO\] \[SYSTEM\] Formatting test$/;
|
||||
expect(callArgs).toMatch(regex);
|
||||
}
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test("should write error logs to file with stack trace", async () => {
|
||||
const errorMessage = "Test error message";
|
||||
const testError = new Error("Source error");
|
||||
|
||||
logger.error("system", errorMessage, testError);
|
||||
|
||||
// Polling for file write instead of fixed timeout
|
||||
let content = "";
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (existsSync(logFile)) {
|
||||
content = readFileSync(logFile, "utf-8");
|
||||
if (content.includes("Source error")) break;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
expect(content).toMatch(/^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[ERROR\] \[SYSTEM\] Test error message: Source error/);
|
||||
expect(content).toContain("Stack Trace:");
|
||||
expect(content).toContain("Error: Source error");
|
||||
expect(content).toContain("logger.test.ts");
|
||||
});
|
||||
|
||||
test("should handle log directory creation failures gracefully", async () => {
|
||||
const consoleSpy = spyOn(console, "error");
|
||||
|
||||
// We trigger an error by trying to use a path that is a file where a directory should be
|
||||
const triggerFile = join(process.cwd(), "logs_fail_trigger");
|
||||
|
||||
try {
|
||||
writeFileSync(triggerFile, "not a directory");
|
||||
|
||||
// Manually override paths for this test instance
|
||||
const originalLogDir = (logger as any).logDir;
|
||||
const originalLogPath = (logger as any).errorLogPath;
|
||||
|
||||
(logger as any).logDir = triggerFile;
|
||||
(logger as any).errorLogPath = join(triggerFile, "error.log");
|
||||
(logger as any).initialized = false;
|
||||
|
||||
logger.error("system", "This should fail directory creation");
|
||||
|
||||
// Wait for async initialization attempt
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
expect(consoleSpy.mock.calls.some(call =>
|
||||
String(call[0]).includes("Failed to initialize logger directory")
|
||||
)).toBe(true);
|
||||
|
||||
// Reset logger state
|
||||
(logger as any).logDir = originalLogDir;
|
||||
(logger as any).errorLogPath = originalLogPath;
|
||||
(logger as any).initialized = false;
|
||||
} finally {
|
||||
if (existsSync(triggerFile)) unlinkSync(triggerFile);
|
||||
consoleSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("should include complex data objects in logs", () => {
|
||||
const spy = spyOn(console, "log");
|
||||
const data = { userId: "123", tags: ["test"] };
|
||||
logger.info("bot", "Message with data", data);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
const callArgs = spy.mock.calls[0]?.[0];
|
||||
expect(callArgs).toBeDefined();
|
||||
if (callArgs) {
|
||||
expect(callArgs).toContain(` | Data: ${JSON.stringify(data)}`);
|
||||
}
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test("should handle circular references in data objects", () => {
|
||||
const spy = spyOn(console, "log");
|
||||
const data: any = { name: "circular" };
|
||||
data.self = data;
|
||||
|
||||
logger.info("bot", "Circular test", data);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
const callArgs = spy.mock.calls[0]?.[0];
|
||||
expect(callArgs).toContain("[Circular]");
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
162
shared/lib/logger.ts
Normal file
162
shared/lib/logger.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { join, resolve } from "path";
|
||||
import { appendFile, mkdir, stat } from "fs/promises";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
export enum LogLevel {
|
||||
DEBUG = 0,
|
||||
INFO = 1,
|
||||
WARN = 2,
|
||||
ERROR = 3,
|
||||
}
|
||||
|
||||
const LogLevelNames = {
|
||||
[LogLevel.DEBUG]: "DEBUG",
|
||||
[LogLevel.INFO]: "INFO",
|
||||
[LogLevel.WARN]: "WARN",
|
||||
[LogLevel.ERROR]: "ERROR",
|
||||
};
|
||||
|
||||
export type LogSource = "bot" | "web" | "shared" | "system";
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
source: LogSource;
|
||||
message: string;
|
||||
data?: any;
|
||||
stack?: string;
|
||||
}
|
||||
|
||||
class Logger {
|
||||
private logDir: string;
|
||||
private errorLogPath: string;
|
||||
private initialized: boolean = false;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
constructor() {
|
||||
// Use resolve with __dirname or process.cwd() but make it more robust
|
||||
// Since this is in shared/lib/, we can try to find the project root
|
||||
// For now, let's stick to a resolved path from process.cwd() or a safer alternative
|
||||
this.logDir = resolve(process.cwd(), "logs");
|
||||
this.errorLogPath = join(this.logDir, "error.log");
|
||||
}
|
||||
|
||||
private async ensureInitialized() {
|
||||
if (this.initialized) return;
|
||||
if (this.initPromise) return this.initPromise;
|
||||
|
||||
this.initPromise = (async () => {
|
||||
try {
|
||||
await mkdir(this.logDir, { recursive: true });
|
||||
this.initialized = true;
|
||||
} catch (err: any) {
|
||||
if (err.code === "EEXIST" || err.code === "ENOTDIR") {
|
||||
try {
|
||||
const stats = await stat(this.logDir);
|
||||
if (stats.isDirectory()) {
|
||||
this.initialized = true;
|
||||
return;
|
||||
}
|
||||
} catch (statErr) {}
|
||||
}
|
||||
console.error(`[SYSTEM] Failed to initialize logger directory at ${this.logDir}:`, err);
|
||||
} finally {
|
||||
this.initPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private safeStringify(data: any): string {
|
||||
try {
|
||||
return JSON.stringify(data);
|
||||
} catch (err) {
|
||||
const seen = new WeakSet();
|
||||
return JSON.stringify(data, (key, value) => {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
if (seen.has(value)) return "[Circular]";
|
||||
seen.add(value);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private formatMessage(entry: LogEntry): string {
|
||||
const dataStr = entry.data ? ` | Data: ${this.safeStringify(entry.data)}` : "";
|
||||
const stackStr = entry.stack ? `\nStack Trace:\n${entry.stack}` : "";
|
||||
return `[${entry.timestamp}] [${entry.level}] [${entry.source.toUpperCase()}] ${entry.message}${dataStr}${stackStr}`;
|
||||
}
|
||||
|
||||
private async writeToErrorLog(formatted: string) {
|
||||
await this.ensureInitialized();
|
||||
try {
|
||||
await appendFile(this.errorLogPath, formatted + "\n");
|
||||
} catch (err) {
|
||||
console.error("[SYSTEM] Failed to write to error log file:", err);
|
||||
}
|
||||
}
|
||||
|
||||
private log(level: LogLevel, source: LogSource, message: string, errorOrData?: any) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const levelName = LogLevelNames[level];
|
||||
|
||||
const entry: LogEntry = {
|
||||
timestamp,
|
||||
level: levelName,
|
||||
source,
|
||||
message,
|
||||
};
|
||||
|
||||
if (level === LogLevel.ERROR && errorOrData instanceof Error) {
|
||||
entry.stack = errorOrData.stack;
|
||||
entry.message = `${message}: ${errorOrData.message}`;
|
||||
} else if (errorOrData !== undefined) {
|
||||
entry.data = errorOrData;
|
||||
}
|
||||
|
||||
const formatted = this.formatMessage(entry);
|
||||
|
||||
// Print to console
|
||||
switch (level) {
|
||||
case LogLevel.DEBUG:
|
||||
console.debug(formatted);
|
||||
break;
|
||||
case LogLevel.INFO:
|
||||
console.log(formatted);
|
||||
break;
|
||||
case LogLevel.WARN:
|
||||
console.warn(formatted);
|
||||
break;
|
||||
case LogLevel.ERROR:
|
||||
console.error(formatted);
|
||||
break;
|
||||
}
|
||||
|
||||
// Persistent error logging
|
||||
if (level === LogLevel.ERROR) {
|
||||
this.writeToErrorLog(formatted).catch(() => {
|
||||
// Silently fail to avoid infinite loops
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
debug(source: LogSource, message: string, data?: any) {
|
||||
this.log(LogLevel.DEBUG, source, message, data);
|
||||
}
|
||||
|
||||
info(source: LogSource, message: string, data?: any) {
|
||||
this.log(LogLevel.INFO, source, message, data);
|
||||
}
|
||||
|
||||
warn(source: LogSource, message: string, data?: any) {
|
||||
this.log(LogLevel.WARN, source, message, data);
|
||||
}
|
||||
|
||||
error(source: LogSource, message: string, error?: any) {
|
||||
this.log(LogLevel.ERROR, source, message, error);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger();
|
||||
@@ -9,3 +9,42 @@ import type { Command } from "./types";
|
||||
export function createCommand(command: Command): Command {
|
||||
return command;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON Replacer function for serialization
|
||||
* Handles safe serialization of BigInt values to strings
|
||||
*/
|
||||
export const jsonReplacer = (_key: string, value: unknown): unknown => {
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deep merge utility
|
||||
*/
|
||||
export function deepMerge(target: any, source: any): any {
|
||||
if (typeof target !== 'object' || target === null) {
|
||||
return source;
|
||||
}
|
||||
if (typeof source !== 'object' || source === null) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const output = { ...target };
|
||||
|
||||
Object.keys(source).forEach(key => {
|
||||
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||
if (!(key in target)) {
|
||||
Object.assign(output, { [key]: source[key] });
|
||||
} else {
|
||||
output[key] = deepMerge(target[key], source[key]);
|
||||
}
|
||||
} else {
|
||||
Object.assign(output, { [key]: source[key] });
|
||||
}
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
66
shared/modules/admin/action.service.test.ts
Normal file
66
shared/modules/admin/action.service.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test, mock, beforeEach, spyOn } from "bun:test";
|
||||
import { actionService } from "./action.service";
|
||||
import { systemEvents, EVENTS } from "@shared/lib/events";
|
||||
import { dashboardService } from "@shared/modules/dashboard/dashboard.service";
|
||||
|
||||
describe("ActionService", () => {
|
||||
beforeEach(() => {
|
||||
// Clear any previous mock state
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
/**
|
||||
* Test Case: Command Reload
|
||||
* Requirement: Emits event and records to dashboard
|
||||
*/
|
||||
test("reloadCommands should emit RELOAD_COMMANDS event and record dashboard event", async () => {
|
||||
const emitSpy = spyOn(systemEvents, "emit");
|
||||
const recordSpy = spyOn(dashboardService, "recordEvent").mockImplementation(() => Promise.resolve());
|
||||
|
||||
const result = await actionService.reloadCommands();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(emitSpy).toHaveBeenCalledWith(EVENTS.ACTIONS.RELOAD_COMMANDS);
|
||||
expect(recordSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: "info",
|
||||
message: "Admin: Triggered command reload"
|
||||
}));
|
||||
});
|
||||
|
||||
/**
|
||||
* Test Case: Cache Clearance
|
||||
* Requirement: Emits event and records to dashboard
|
||||
*/
|
||||
test("clearCache should emit CLEAR_CACHE event and record dashboard event", async () => {
|
||||
const emitSpy = spyOn(systemEvents, "emit");
|
||||
const recordSpy = spyOn(dashboardService, "recordEvent").mockImplementation(() => Promise.resolve());
|
||||
|
||||
const result = await actionService.clearCache();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(emitSpy).toHaveBeenCalledWith(EVENTS.ACTIONS.CLEAR_CACHE);
|
||||
expect(recordSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: "info",
|
||||
message: "Admin: Triggered cache clearance"
|
||||
}));
|
||||
});
|
||||
|
||||
/**
|
||||
* Test Case: Maintenance Mode Toggle
|
||||
* Requirement: Emits event with correct payload and records to dashboard with warning type
|
||||
*/
|
||||
test("toggleMaintenanceMode should emit MAINTENANCE_MODE event and record dashboard event", async () => {
|
||||
const emitSpy = spyOn(systemEvents, "emit");
|
||||
const recordSpy = spyOn(dashboardService, "recordEvent").mockImplementation(() => Promise.resolve());
|
||||
|
||||
const result = await actionService.toggleMaintenanceMode(true, "Test Reason");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(emitSpy).toHaveBeenCalledWith(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled: true, reason: "Test Reason" });
|
||||
expect(recordSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: "warn",
|
||||
message: "Admin: Maintenance mode ENABLED (Test Reason)"
|
||||
}));
|
||||
});
|
||||
});
|
||||
53
shared/modules/admin/action.service.ts
Normal file
53
shared/modules/admin/action.service.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { systemEvents, EVENTS } from "@shared/lib/events";
|
||||
import { dashboardService } from "@shared/modules/dashboard/dashboard.service";
|
||||
|
||||
/**
|
||||
* Service to handle administrative actions triggered from the dashboard.
|
||||
* These actions are broadcasted to the bot via the system event bus.
|
||||
*/
|
||||
export const actionService = {
|
||||
/**
|
||||
* Triggers a reload of all bot commands.
|
||||
*/
|
||||
reloadCommands: async () => {
|
||||
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
|
||||
|
||||
await dashboardService.recordEvent({
|
||||
type: "info",
|
||||
message: "Admin: Triggered command reload",
|
||||
icon: "♻️"
|
||||
});
|
||||
|
||||
return { success: true, message: "Command reload triggered" };
|
||||
},
|
||||
|
||||
/**
|
||||
* Triggers a clearance of internal bot caches.
|
||||
*/
|
||||
clearCache: async () => {
|
||||
systemEvents.emit(EVENTS.ACTIONS.CLEAR_CACHE);
|
||||
|
||||
await dashboardService.recordEvent({
|
||||
type: "info",
|
||||
message: "Admin: Triggered cache clearance",
|
||||
icon: "🧹"
|
||||
});
|
||||
|
||||
return { success: true, message: "Cache clearance triggered" };
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggles maintenance mode for the bot.
|
||||
*/
|
||||
toggleMaintenanceMode: async (enabled: boolean, reason?: string) => {
|
||||
systemEvents.emit(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled, reason });
|
||||
|
||||
await dashboardService.recordEvent({
|
||||
type: enabled ? "warn" : "info",
|
||||
message: `Admin: Maintenance mode ${enabled ? "ENABLED" : "DISABLED"}${reason ? ` (${reason})` : ""}`,
|
||||
icon: "🛠️"
|
||||
});
|
||||
|
||||
return { success: true, enabled, message: `Maintenance mode ${enabled ? "enabled" : "disabled"}` };
|
||||
}
|
||||
};
|
||||
@@ -1,248 +0,0 @@
|
||||
import { describe, expect, test, mock, beforeEach, afterAll, spyOn } from "bun:test";
|
||||
import * as fs from "fs/promises";
|
||||
|
||||
// Mock child_process BEFORE importing the service
|
||||
const mockExec = mock((cmd: string, callback?: any) => {
|
||||
// Handle calls without callback (like exec().unref())
|
||||
if (!callback) {
|
||||
return { unref: () => { } };
|
||||
}
|
||||
|
||||
if (cmd.includes("git rev-parse")) {
|
||||
callback(null, { stdout: "main\n" });
|
||||
} else if (cmd.includes("git fetch")) {
|
||||
callback(null, { stdout: "" });
|
||||
} else if (cmd.includes("git log")) {
|
||||
callback(null, { stdout: "abcdef Update 1\n123456 Update 2" });
|
||||
} else if (cmd.includes("git diff")) {
|
||||
callback(null, { stdout: "package.json\nsrc/index.ts" });
|
||||
} else if (cmd.includes("git reset")) {
|
||||
callback(null, { stdout: "HEAD is now at abcdef Update 1" });
|
||||
} else if (cmd.includes("bun install")) {
|
||||
callback(null, { stdout: "Installed dependencies" });
|
||||
} else if (cmd.includes("drizzle-kit migrate")) {
|
||||
callback(null, { stdout: "Migrations applied" });
|
||||
} else {
|
||||
callback(null, { stdout: "" });
|
||||
}
|
||||
});
|
||||
|
||||
mock.module("child_process", () => ({
|
||||
exec: mockExec
|
||||
}));
|
||||
|
||||
// Mock fs/promises
|
||||
const mockWriteFile = mock((path: string, content: string) => Promise.resolve());
|
||||
const mockReadFile = mock((path: string, encoding: string) => Promise.resolve("{}"));
|
||||
const mockUnlink = mock((path: string) => Promise.resolve());
|
||||
|
||||
mock.module("fs/promises", () => ({
|
||||
writeFile: mockWriteFile,
|
||||
readFile: mockReadFile,
|
||||
unlink: mockUnlink
|
||||
}));
|
||||
|
||||
// Mock view module to avoid import issues
|
||||
mock.module("./update.view", () => ({
|
||||
getPostRestartEmbed: () => ({ title: "Update Complete" }),
|
||||
getInstallingDependenciesEmbed: () => ({ title: "Installing..." }),
|
||||
}));
|
||||
|
||||
describe("UpdateService", () => {
|
||||
let UpdateService: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockExec.mockClear();
|
||||
mockWriteFile.mockClear();
|
||||
mockReadFile.mockClear();
|
||||
mockUnlink.mockClear();
|
||||
|
||||
// Dynamically import to ensure mock is used
|
||||
const module = await import("./update.service");
|
||||
UpdateService = module.UpdateService;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe("checkForUpdates", () => {
|
||||
test("should return updates if git log has output", async () => {
|
||||
const result = await UpdateService.checkForUpdates();
|
||||
|
||||
expect(result.hasUpdates).toBe(true);
|
||||
expect(result.branch).toBe("main");
|
||||
expect(result.log).toContain("Update 1");
|
||||
});
|
||||
|
||||
test("should call git rev-parse, fetch, and log commands", async () => {
|
||||
await UpdateService.checkForUpdates();
|
||||
|
||||
const calls = mockExec.mock.calls.map((c: any) => c[0]);
|
||||
expect(calls.some((cmd: string) => cmd.includes("git rev-parse"))).toBe(true);
|
||||
expect(calls.some((cmd: string) => cmd.includes("git fetch"))).toBe(true);
|
||||
expect(calls.some((cmd: string) => cmd.includes("git log"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("performUpdate", () => {
|
||||
test("should run git reset --hard with correct branch", async () => {
|
||||
await UpdateService.performUpdate("main");
|
||||
|
||||
const lastCall = mockExec.mock.lastCall;
|
||||
expect(lastCall).toBeDefined();
|
||||
expect(lastCall![0]).toContain("git reset --hard origin/main");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkUpdateRequirements", () => {
|
||||
test("should detect package.json and schema.ts changes", async () => {
|
||||
const result = await UpdateService.checkUpdateRequirements("main");
|
||||
|
||||
expect(result.needsInstall).toBe(true);
|
||||
expect(result.needsMigrations).toBe(false); // mock doesn't include schema.ts
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should call git diff with correct branch", async () => {
|
||||
await UpdateService.checkUpdateRequirements("develop");
|
||||
|
||||
const lastCall = mockExec.mock.lastCall;
|
||||
expect(lastCall).toBeDefined();
|
||||
expect(lastCall![0]).toContain("git diff HEAD..origin/develop");
|
||||
});
|
||||
});
|
||||
|
||||
describe("installDependencies", () => {
|
||||
test("should run bun install and return output", async () => {
|
||||
const output = await UpdateService.installDependencies();
|
||||
|
||||
expect(output).toBe("Installed dependencies");
|
||||
const lastCall = mockExec.mock.lastCall;
|
||||
expect(lastCall![0]).toBe("bun install");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareRestartContext", () => {
|
||||
test("should write context to file", async () => {
|
||||
const context = {
|
||||
channelId: "123",
|
||||
userId: "456",
|
||||
timestamp: Date.now(),
|
||||
runMigrations: true,
|
||||
installDependencies: false
|
||||
};
|
||||
|
||||
await UpdateService.prepareRestartContext(context);
|
||||
|
||||
expect(mockWriteFile).toHaveBeenCalled();
|
||||
const lastCall = mockWriteFile.mock.lastCall as [string, string] | undefined;
|
||||
expect(lastCall).toBeDefined();
|
||||
expect(lastCall![0]).toContain("restart_context");
|
||||
expect(JSON.parse(lastCall![1])).toEqual(context);
|
||||
});
|
||||
});
|
||||
|
||||
describe("triggerRestart", () => {
|
||||
test("should use RESTART_COMMAND env var when set", async () => {
|
||||
const originalEnv = process.env.RESTART_COMMAND;
|
||||
process.env.RESTART_COMMAND = "pm2 restart bot";
|
||||
|
||||
await UpdateService.triggerRestart();
|
||||
|
||||
const lastCall = mockExec.mock.lastCall;
|
||||
expect(lastCall).toBeDefined();
|
||||
expect(lastCall![0]).toBe("pm2 restart bot");
|
||||
|
||||
process.env.RESTART_COMMAND = originalEnv;
|
||||
});
|
||||
|
||||
test("should write to trigger file when no env var", async () => {
|
||||
const originalEnv = process.env.RESTART_COMMAND;
|
||||
delete process.env.RESTART_COMMAND;
|
||||
|
||||
await UpdateService.triggerRestart();
|
||||
|
||||
expect(mockWriteFile).toHaveBeenCalled();
|
||||
const lastCall = mockWriteFile.mock.lastCall as [string, string] | undefined;
|
||||
expect(lastCall).toBeDefined();
|
||||
expect(lastCall![0]).toContain("restart_trigger");
|
||||
|
||||
process.env.RESTART_COMMAND = originalEnv;
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlePostRestart", () => {
|
||||
const createMockClient = (channel: any = null) => ({
|
||||
channels: {
|
||||
fetch: mock(() => Promise.resolve(channel))
|
||||
}
|
||||
});
|
||||
|
||||
const createMockChannel = () => ({
|
||||
isSendable: () => true,
|
||||
send: mock(() => Promise.resolve())
|
||||
});
|
||||
|
||||
test("should ignore stale context (>10 mins old)", async () => {
|
||||
const staleContext = {
|
||||
channelId: "123",
|
||||
userId: "456",
|
||||
timestamp: Date.now() - (15 * 60 * 1000), // 15 mins ago
|
||||
runMigrations: true,
|
||||
installDependencies: true
|
||||
};
|
||||
|
||||
mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(staleContext)));
|
||||
|
||||
const mockChannel = createMockChannel();
|
||||
// Create mock with instanceof support
|
||||
const channel = Object.assign(mockChannel, { constructor: { name: "TextChannel" } });
|
||||
Object.setPrototypeOf(channel, Object.create({ constructor: { name: "TextChannel" } }));
|
||||
|
||||
const mockClient = createMockClient(channel);
|
||||
|
||||
await UpdateService.handlePostRestart(mockClient);
|
||||
|
||||
// Should not send any message for stale context
|
||||
expect(mockChannel.send).not.toHaveBeenCalled();
|
||||
// Should clean up the context file
|
||||
expect(mockUnlink).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should do nothing if no context file exists", async () => {
|
||||
mockReadFile.mockImplementationOnce(() => Promise.reject(new Error("ENOENT")));
|
||||
|
||||
const mockClient = createMockClient();
|
||||
|
||||
await UpdateService.handlePostRestart(mockClient);
|
||||
|
||||
// Should not throw and not try to clean up
|
||||
expect(mockUnlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should clean up context file after processing", async () => {
|
||||
const validContext = {
|
||||
channelId: "123",
|
||||
userId: "456",
|
||||
timestamp: Date.now(),
|
||||
runMigrations: false,
|
||||
installDependencies: false
|
||||
};
|
||||
|
||||
mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(validContext)));
|
||||
|
||||
// Create a proper TextChannel mock
|
||||
const { TextChannel } = await import("discord.js");
|
||||
const mockChannel = Object.create(TextChannel.prototype);
|
||||
mockChannel.isSendable = () => true;
|
||||
mockChannel.send = mock(() => Promise.resolve());
|
||||
|
||||
const mockClient = createMockClient(mockChannel);
|
||||
|
||||
await UpdateService.handlePostRestart(mockClient);
|
||||
|
||||
expect(mockUnlink).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,318 +0,0 @@
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { writeFile, readFile, unlink } from "fs/promises";
|
||||
import { Client, TextChannel } from "discord.js";
|
||||
import { getPostRestartEmbed, getInstallingDependenciesEmbed, getRunningMigrationsEmbed } from "@/modules/admin/update.view";
|
||||
import type { PostRestartResult } from "@/modules/admin/update.view";
|
||||
import type { RestartContext, UpdateCheckResult, UpdateInfo, CommitInfo } from "@/modules/admin/update.types";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Constants
|
||||
const STALE_CONTEXT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
|
||||
|
||||
export class UpdateService {
|
||||
private static readonly CONTEXT_FILE = ".restart_context.json";
|
||||
private static readonly ROLLBACK_FILE = ".rollback_commit.txt";
|
||||
|
||||
/**
|
||||
* Check for available updates with detailed commit information
|
||||
*/
|
||||
static async checkForUpdates(): Promise<UpdateInfo> {
|
||||
const { stdout: branchName } = await execAsync("git rev-parse --abbrev-ref HEAD");
|
||||
const branch = branchName.trim();
|
||||
|
||||
const { stdout: currentCommit } = await execAsync("git rev-parse --short HEAD");
|
||||
|
||||
await execAsync("git fetch --all");
|
||||
|
||||
const { stdout: latestCommit } = await execAsync(`git rev-parse --short origin/${branch}`);
|
||||
|
||||
// Get commit log with author info
|
||||
const { stdout: logOutput } = await execAsync(
|
||||
`git log HEAD..origin/${branch} --format="%h|%s|%an" --no-merges`
|
||||
);
|
||||
|
||||
const commits: CommitInfo[] = logOutput
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(line => line.length > 0)
|
||||
.map(line => {
|
||||
const [hash, message, author] = line.split("|");
|
||||
return { hash: hash || "", message: message || "", author: author || "" };
|
||||
});
|
||||
|
||||
return {
|
||||
hasUpdates: commits.length > 0,
|
||||
branch,
|
||||
currentCommit: currentCommit.trim(),
|
||||
latestCommit: latestCommit.trim(),
|
||||
commitCount: commits.length,
|
||||
commits
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze what the update requires
|
||||
*/
|
||||
static async checkUpdateRequirements(branch: string): Promise<UpdateCheckResult> {
|
||||
try {
|
||||
const { stdout } = await execAsync(`git diff HEAD..origin/${branch} --name-only`);
|
||||
const changedFiles = stdout.trim().split("\n").filter(f => f.length > 0);
|
||||
|
||||
const needsRootInstall = changedFiles.some(file =>
|
||||
file === "package.json" || file === "bun.lock"
|
||||
);
|
||||
|
||||
const needsWebInstall = changedFiles.some(file =>
|
||||
file === "web/package.json" || file === "web/bun.lock"
|
||||
);
|
||||
|
||||
const needsMigrations = changedFiles.some(file =>
|
||||
file.includes("schema.ts") || file.startsWith("drizzle/")
|
||||
);
|
||||
|
||||
return {
|
||||
needsRootInstall,
|
||||
needsWebInstall,
|
||||
needsMigrations,
|
||||
changedFiles
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Failed to check update requirements:", e);
|
||||
return {
|
||||
needsRootInstall: false,
|
||||
needsWebInstall: false,
|
||||
needsMigrations: false,
|
||||
changedFiles: [],
|
||||
error: e instanceof Error ? e : new Error(String(e))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary of changed file categories
|
||||
*/
|
||||
static categorizeChanges(changedFiles: string[]): Record<string, number> {
|
||||
const categories: Record<string, number> = {};
|
||||
|
||||
for (const file of changedFiles) {
|
||||
let category = "Other";
|
||||
|
||||
if (file.startsWith("bot/commands/")) category = "Commands";
|
||||
else if (file.startsWith("bot/modules/")) category = "Modules";
|
||||
else if (file.startsWith("web/")) category = "Web Dashboard";
|
||||
else if (file.startsWith("bot/lib/") || file.startsWith("shared/lib/")) category = "Library";
|
||||
else if (file.startsWith("drizzle/") || file.includes("schema")) category = "Database";
|
||||
else if (file.endsWith(".test.ts")) category = "Tests";
|
||||
else if (file.includes("package.json") || file.includes("lock")) category = "Dependencies";
|
||||
|
||||
categories[category] = (categories[category] || 0) + 1;
|
||||
}
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current commit for potential rollback
|
||||
*/
|
||||
static async saveRollbackPoint(): Promise<string> {
|
||||
const { stdout } = await execAsync("git rev-parse HEAD");
|
||||
const commit = stdout.trim();
|
||||
await writeFile(this.ROLLBACK_FILE, commit);
|
||||
return commit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback to the previous commit
|
||||
*/
|
||||
static async rollback(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const rollbackCommit = await readFile(this.ROLLBACK_FILE, "utf-8");
|
||||
await execAsync(`git reset --hard ${rollbackCommit.trim()}`);
|
||||
await unlink(this.ROLLBACK_FILE);
|
||||
return { success: true, message: `Rolled back to ${rollbackCommit.trim().substring(0, 7)}` };
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
message: e instanceof Error ? e.message : "No rollback point available"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a rollback point exists
|
||||
*/
|
||||
static async hasRollbackPoint(): Promise<boolean> {
|
||||
try {
|
||||
await readFile(this.ROLLBACK_FILE, "utf-8");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the git update
|
||||
*/
|
||||
static async performUpdate(branch: string): Promise<void> {
|
||||
await execAsync(`git reset --hard origin/${branch}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install dependencies for specified projects
|
||||
*/
|
||||
static async installDependencies(options: { root: boolean; web: boolean }): Promise<string> {
|
||||
const outputs: string[] = [];
|
||||
|
||||
if (options.root) {
|
||||
const { stdout } = await execAsync("bun install");
|
||||
outputs.push(`📦 Root: ${stdout.trim() || "Done"}`);
|
||||
}
|
||||
|
||||
if (options.web) {
|
||||
const { stdout } = await execAsync("cd web && bun install");
|
||||
outputs.push(`🌐 Web: ${stdout.trim() || "Done"}`);
|
||||
}
|
||||
|
||||
return outputs.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare restart context with rollback info
|
||||
*/
|
||||
static async prepareRestartContext(context: RestartContext): Promise<void> {
|
||||
await writeFile(this.CONTEXT_FILE, JSON.stringify(context));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a restart
|
||||
*/
|
||||
static async triggerRestart(): Promise<void> {
|
||||
if (process.env.RESTART_COMMAND) {
|
||||
exec(process.env.RESTART_COMMAND).unref();
|
||||
} else {
|
||||
setTimeout(() => process.exit(0), 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle post-restart tasks
|
||||
*/
|
||||
static async handlePostRestart(client: Client): Promise<void> {
|
||||
try {
|
||||
const context = await this.loadRestartContext();
|
||||
if (!context) return;
|
||||
|
||||
if (this.isContextStale(context)) {
|
||||
await this.cleanupContext();
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = await this.fetchNotificationChannel(client, context.channelId);
|
||||
if (!channel) {
|
||||
await this.cleanupContext();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.executePostRestartTasks(context, channel);
|
||||
await this.notifyPostRestartResult(channel, result, context);
|
||||
await this.cleanupContext();
|
||||
} catch (e) {
|
||||
console.error("Failed to handle post-restart context:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Private Helper Methods ---
|
||||
|
||||
private static async loadRestartContext(): Promise<RestartContext | null> {
|
||||
try {
|
||||
const contextData = await readFile(this.CONTEXT_FILE, "utf-8");
|
||||
return JSON.parse(contextData) as RestartContext;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static isContextStale(context: RestartContext): boolean {
|
||||
return Date.now() - context.timestamp > STALE_CONTEXT_MS;
|
||||
}
|
||||
|
||||
private static async fetchNotificationChannel(client: Client, channelId: string): Promise<TextChannel | null> {
|
||||
try {
|
||||
const channel = await client.channels.fetch(channelId);
|
||||
if (channel && channel.isSendable() && channel instanceof TextChannel) {
|
||||
return channel;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async executePostRestartTasks(
|
||||
context: RestartContext,
|
||||
channel: TextChannel
|
||||
): Promise<PostRestartResult> {
|
||||
const result: PostRestartResult = {
|
||||
installSuccess: true,
|
||||
installOutput: "",
|
||||
migrationSuccess: true,
|
||||
migrationOutput: "",
|
||||
ranInstall: context.installDependencies,
|
||||
ranMigrations: context.runMigrations,
|
||||
previousCommit: context.previousCommit,
|
||||
newCommit: context.newCommit
|
||||
};
|
||||
|
||||
// 1. Install Dependencies if needed
|
||||
if (context.installDependencies) {
|
||||
try {
|
||||
await channel.send({ embeds: [getInstallingDependenciesEmbed()] });
|
||||
|
||||
const { stdout: rootOutput } = await execAsync("bun install");
|
||||
const { stdout: webOutput } = await execAsync("cd web && bun install");
|
||||
|
||||
result.installOutput = `📦 Root: ${rootOutput.trim() || "Done"}\n🌐 Web: ${webOutput.trim() || "Done"}`;
|
||||
} catch (err: unknown) {
|
||||
result.installSuccess = false;
|
||||
result.installOutput = err instanceof Error ? err.message : String(err);
|
||||
console.error("Dependency Install Failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Run Migrations
|
||||
if (context.runMigrations) {
|
||||
try {
|
||||
await channel.send({ embeds: [getRunningMigrationsEmbed()] });
|
||||
const { stdout } = await execAsync("bun x drizzle-kit migrate");
|
||||
result.migrationOutput = stdout;
|
||||
} catch (err: unknown) {
|
||||
result.migrationSuccess = false;
|
||||
result.migrationOutput = err instanceof Error ? err.message : String(err);
|
||||
console.error("Migration Failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async notifyPostRestartResult(
|
||||
channel: TextChannel,
|
||||
result: PostRestartResult,
|
||||
context: RestartContext
|
||||
): Promise<void> {
|
||||
const hasRollback = await this.hasRollbackPoint();
|
||||
await channel.send(getPostRestartEmbed(result, hasRollback));
|
||||
}
|
||||
|
||||
private static async cleanupContext(): Promise<void> {
|
||||
try {
|
||||
await unlink(this.CONTEXT_FILE);
|
||||
} catch {
|
||||
// File may not exist, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
102
shared/modules/dashboard/dashboard.service.test.ts
Normal file
102
shared/modules/dashboard/dashboard.service.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
|
||||
// Mock DrizzleClient before importing service
|
||||
const mockFindMany = mock();
|
||||
const mockLimit = mock();
|
||||
|
||||
// Helper to support the chained calls in getLeaderboards
|
||||
const mockChain = {
|
||||
from: () => mockChain,
|
||||
leftJoin: () => mockChain,
|
||||
groupBy: () => mockChain,
|
||||
orderBy: () => mockChain,
|
||||
limit: mockLimit
|
||||
};
|
||||
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: {
|
||||
select: () => mockChain,
|
||||
query: {
|
||||
lootdrops: {
|
||||
findMany: mockFindMany
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Import service after mocking
|
||||
import { dashboardService } from "./dashboard.service";
|
||||
|
||||
describe("dashboardService", () => {
|
||||
beforeEach(() => {
|
||||
mockFindMany.mockClear();
|
||||
mockLimit.mockClear();
|
||||
});
|
||||
|
||||
describe("getActiveLootdrops", () => {
|
||||
test("should return active lootdrops when found", async () => {
|
||||
const mockDrops = [
|
||||
{
|
||||
messageId: "123",
|
||||
channelId: "general",
|
||||
rewardAmount: 100,
|
||||
currency: "Gold",
|
||||
createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 3600000),
|
||||
claimedBy: null
|
||||
}
|
||||
];
|
||||
mockFindMany.mockResolvedValue(mockDrops);
|
||||
|
||||
const result = await dashboardService.getActiveLootdrops();
|
||||
expect(result).toEqual(mockDrops);
|
||||
expect(mockFindMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should return empty array if no active drops", async () => {
|
||||
mockFindMany.mockResolvedValue([]);
|
||||
const result = await dashboardService.getActiveLootdrops();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLeaderboards", () => {
|
||||
test("should combine top levels and wealth", async () => {
|
||||
const mockTopLevels = [
|
||||
{ username: "Alice", level: 10, avatar: "a.png" },
|
||||
{ username: "Bob", level: 5, avatar: null },
|
||||
{ username: "Charlie", level: 2, avatar: "c.png" }
|
||||
];
|
||||
const mockTopWealth = [
|
||||
{ username: "Alice", balance: 1000n, avatar: "a.png" },
|
||||
{ username: "Dave", balance: 500n, avatar: "d.png" },
|
||||
{ username: "Bob", balance: 100n, avatar: null }
|
||||
];
|
||||
|
||||
// Mock sequential calls to limit()
|
||||
// First call is topLevels, second is topWealth
|
||||
mockLimit
|
||||
.mockResolvedValueOnce(mockTopLevels)
|
||||
.mockResolvedValueOnce(mockTopWealth)
|
||||
.mockResolvedValueOnce(mockTopWealth); // Mock net worth same as wealth for simplicity
|
||||
|
||||
const result = await dashboardService.getLeaderboards();
|
||||
|
||||
expect(result.topLevels).toEqual(mockTopLevels);
|
||||
// Verify balance BigInt to string conversion
|
||||
expect(result.topWealth).toHaveLength(3);
|
||||
expect(result.topWealth[0]!.balance).toBe("1000");
|
||||
expect(result.topWealth[0]!.username).toBe("Alice");
|
||||
expect(result.topWealth[1]!.balance).toBe("500");
|
||||
expect(mockLimit).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test("should handle empty leaderboards", async () => {
|
||||
mockLimit.mockResolvedValue([]);
|
||||
|
||||
const result = await dashboardService.getLeaderboards();
|
||||
expect(result.topLevels).toEqual([]);
|
||||
expect(result.topWealth).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
284
shared/modules/dashboard/dashboard.service.ts
Normal file
284
shared/modules/dashboard/dashboard.service.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { users, transactions, moderationCases, inventory, lootdrops, items, type User } from "@db/schema";
|
||||
import { desc, sql, gte, eq } from "drizzle-orm";
|
||||
import type { RecentEvent, ActivityData } from "./dashboard.types";
|
||||
import { TransactionType } from "@shared/lib/constants";
|
||||
|
||||
export const dashboardService = {
|
||||
/**
|
||||
* Get count of active users from database
|
||||
*/
|
||||
getActiveUserCount: async (): Promise<number> => {
|
||||
const result = await DrizzleClient
|
||||
.select({ count: sql<string>`COUNT(*)` })
|
||||
.from(users)
|
||||
.where(sql`${users.isActive} = true`);
|
||||
|
||||
return Number(result[0]?.count || 0);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get total user count
|
||||
*/
|
||||
getTotalUserCount: async (): Promise<number> => {
|
||||
const result = await DrizzleClient
|
||||
.select({ count: sql<string>`COUNT(*)` })
|
||||
.from(users);
|
||||
|
||||
return Number(result[0]?.count || 0);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get economy statistics
|
||||
*/
|
||||
getEconomyStats: async (): Promise<{
|
||||
totalWealth: bigint;
|
||||
avgLevel: number;
|
||||
topStreak: number;
|
||||
}> => {
|
||||
const allUsers = await DrizzleClient.select().from(users);
|
||||
|
||||
const totalWealth = allUsers.reduce(
|
||||
(acc: bigint, u: User) => acc + (u.balance || 0n),
|
||||
0n
|
||||
);
|
||||
|
||||
const avgLevel = allUsers.length > 0
|
||||
? Math.round(
|
||||
allUsers.reduce((acc: number, u: User) => acc + (u.level || 1), 0) / allUsers.length
|
||||
)
|
||||
: 1;
|
||||
|
||||
const topStreak = allUsers.reduce(
|
||||
(max: number, u: User) => Math.max(max, u.dailyStreak || 0),
|
||||
0
|
||||
);
|
||||
|
||||
return { totalWealth, avgLevel, topStreak };
|
||||
},
|
||||
|
||||
/**
|
||||
* Get total items in circulation
|
||||
*/
|
||||
getTotalItems: async (): Promise<number> => {
|
||||
const result = await DrizzleClient
|
||||
.select({ total: sql<string>`COALESCE(SUM(${inventory.quantity}), 0)` })
|
||||
.from(inventory);
|
||||
|
||||
return Number(result[0]?.total || 0);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get recent transactions as events (last 24 hours)
|
||||
*/
|
||||
getRecentTransactions: async (limit: number = 10): Promise<RecentEvent[]> => {
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
const recentTx = await DrizzleClient.query.transactions.findMany({
|
||||
limit,
|
||||
orderBy: [desc(transactions.createdAt)],
|
||||
where: gte(transactions.createdAt, oneDayAgo),
|
||||
with: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
return recentTx.map((tx) => ({
|
||||
type: 'info' as const,
|
||||
message: `${tx.user?.username || 'Unknown'}: ${tx.description || 'Transaction'}`,
|
||||
timestamp: tx.createdAt || new Date(),
|
||||
icon: getTransactionIcon(tx.type),
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get recent moderation cases as events (last 24 hours)
|
||||
*/
|
||||
getRecentModerationCases: async (limit: number = 10): Promise<RecentEvent[]> => {
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
const recentCases = await DrizzleClient.query.moderationCases.findMany({
|
||||
limit,
|
||||
orderBy: [desc(moderationCases.createdAt)],
|
||||
where: gte(moderationCases.createdAt, oneDayAgo),
|
||||
});
|
||||
|
||||
return recentCases.map((modCase) => ({
|
||||
type: modCase.type === 'warn' || modCase.type === 'ban' ? 'error' : 'info',
|
||||
message: `${modCase.type.toUpperCase()}: ${modCase.username} - ${modCase.reason}`,
|
||||
timestamp: modCase.createdAt || new Date(),
|
||||
icon: getModerationIcon(modCase.type as string),
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get combined recent events (transactions + moderation)
|
||||
*/
|
||||
getRecentEvents: async (limit: number = 10): Promise<RecentEvent[]> => {
|
||||
const [txEvents, modEvents] = await Promise.all([
|
||||
dashboardService.getRecentTransactions(limit),
|
||||
dashboardService.getRecentModerationCases(limit),
|
||||
]);
|
||||
|
||||
// Combine and sort by timestamp
|
||||
const allEvents = [...txEvents, ...modEvents]
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
.slice(0, limit);
|
||||
|
||||
return allEvents;
|
||||
},
|
||||
|
||||
/**
|
||||
* Records a new internal event and broadcasts it via WebSocket
|
||||
*/
|
||||
recordEvent: async (event: Omit<RecentEvent, 'timestamp'>): Promise<void> => {
|
||||
const fullEvent: RecentEvent = {
|
||||
...event,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
// Broadcast to WebSocket clients
|
||||
try {
|
||||
const { systemEvents, EVENTS } = await import("@shared/lib/events");
|
||||
systemEvents.emit(EVENTS.DASHBOARD.NEW_EVENT, {
|
||||
...fullEvent,
|
||||
timestamp: (fullEvent.timestamp instanceof Date)
|
||||
? fullEvent.timestamp.toISOString()
|
||||
: fullEvent.timestamp
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to emit system event:", e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get hourly activity aggregation for the last 24 hours
|
||||
*/
|
||||
getActivityAggregation: async (): Promise<ActivityData[]> => {
|
||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
// Postgres aggregation
|
||||
// We treat everything as a transaction.
|
||||
// We treat everything except TRANSFER_IN as a 'command' (to avoid double counting transfers)
|
||||
const result = await DrizzleClient
|
||||
.select({
|
||||
hour: sql<string>`date_trunc('hour', ${transactions.createdAt})`,
|
||||
transactions: sql<string>`COUNT(*)`,
|
||||
commands: sql<string>`COUNT(*) FILTER (WHERE ${transactions.type} != ${TransactionType.TRANSFER_IN})`
|
||||
})
|
||||
.from(transactions)
|
||||
.where(gte(transactions.createdAt, twentyFourHoursAgo))
|
||||
.groupBy(sql`1`)
|
||||
.orderBy(sql`1`);
|
||||
|
||||
// Map into a record for easy lookups
|
||||
const dataMap = new Map<string, { commands: number, transactions: number }>();
|
||||
result.forEach(row => {
|
||||
if (!row.hour) return;
|
||||
const dateStr = new Date(row.hour).toISOString();
|
||||
dataMap.set(dateStr, {
|
||||
commands: Number(row.commands),
|
||||
transactions: Number(row.transactions)
|
||||
});
|
||||
});
|
||||
|
||||
// Generate the last 24 hours of data
|
||||
const activity: ActivityData[] = [];
|
||||
const current = new Date();
|
||||
current.setHours(current.getHours(), 0, 0, 0);
|
||||
|
||||
for (let i = 23; i >= 0; i--) {
|
||||
const h = new Date(current.getTime() - i * 60 * 60 * 1000);
|
||||
const iso = h.toISOString();
|
||||
const existing = dataMap.get(iso);
|
||||
|
||||
activity.push({
|
||||
hour: iso,
|
||||
commands: existing?.commands || 0,
|
||||
transactions: existing?.transactions || 0
|
||||
});
|
||||
}
|
||||
|
||||
return activity;
|
||||
},
|
||||
/**
|
||||
* Get active lootdrops
|
||||
*/
|
||||
getActiveLootdrops: async () => {
|
||||
const activeDrops = await DrizzleClient.query.lootdrops.findMany({
|
||||
where: (lootdrops, { isNull }) => isNull(lootdrops.claimedBy),
|
||||
limit: 1,
|
||||
orderBy: desc(lootdrops.createdAt)
|
||||
});
|
||||
|
||||
return activeDrops;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get leaderboards (Top 3 Levels and Wealth)
|
||||
*/
|
||||
getLeaderboards: async () => {
|
||||
const topLevels = await DrizzleClient.select({
|
||||
username: users.username,
|
||||
level: users.level,
|
||||
})
|
||||
.from(users)
|
||||
.orderBy(desc(users.level))
|
||||
.limit(10);
|
||||
|
||||
const topWealth = await DrizzleClient.select({
|
||||
username: users.username,
|
||||
balance: users.balance,
|
||||
})
|
||||
.from(users)
|
||||
.orderBy(desc(users.balance))
|
||||
.limit(10);
|
||||
|
||||
|
||||
|
||||
const topNetWorth = await DrizzleClient.select({
|
||||
username: users.username,
|
||||
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, users.username, users.balance)
|
||||
.orderBy(desc(sql`net_worth`))
|
||||
.limit(10);
|
||||
|
||||
return {
|
||||
topLevels,
|
||||
topWealth: topWealth.map(u => ({ ...u, balance: (u.balance || 0n).toString() })),
|
||||
topNetWorth: topNetWorth.map(u => ({ ...u, netWorth: (u.netWorth || 0n).toString() }))
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to get icon for transaction type
|
||||
*/
|
||||
function getTransactionIcon(type: string): 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 "📜";
|
||||
if (type.includes("TRANSFER")) return "💸";
|
||||
return "💫";
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get icon for moderation type
|
||||
*/
|
||||
function getModerationIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'warn': return '⚠️';
|
||||
case 'timeout': return '⏸️';
|
||||
case 'kick': return '👢';
|
||||
case 'ban': return '🔨';
|
||||
case 'note': return '📝';
|
||||
case 'prune': return '🧹';
|
||||
default: return '🛡️';
|
||||
}
|
||||
}
|
||||
122
shared/modules/dashboard/dashboard.types.ts
Normal file
122
shared/modules/dashboard/dashboard.types.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const RecentEventSchema = z.object({
|
||||
type: z.enum(['success', 'error', 'info', 'warn']),
|
||||
message: z.string(),
|
||||
timestamp: z.union([z.date(), z.string().datetime()]),
|
||||
icon: z.string().optional(),
|
||||
});
|
||||
|
||||
export type RecentEvent = z.infer<typeof RecentEventSchema>;
|
||||
|
||||
export const DashboardStatsSchema = z.object({
|
||||
bot: z.object({
|
||||
name: z.string(),
|
||||
avatarUrl: z.string().nullable(),
|
||||
status: z.string().nullable(),
|
||||
}),
|
||||
guilds: z.object({
|
||||
count: z.number(),
|
||||
changeFromLastMonth: z.number().optional(),
|
||||
}),
|
||||
users: z.object({
|
||||
active: z.number(),
|
||||
total: z.number(),
|
||||
changePercentFromLastMonth: z.number().optional(),
|
||||
}),
|
||||
commands: z.object({
|
||||
total: z.number(),
|
||||
active: z.number(),
|
||||
disabled: z.number(),
|
||||
changePercentFromLastMonth: z.number().optional(),
|
||||
}),
|
||||
ping: z.object({
|
||||
avg: z.number(),
|
||||
changeFromLastHour: z.number().optional(),
|
||||
}),
|
||||
economy: z.object({
|
||||
totalWealth: z.string(),
|
||||
avgLevel: z.number(),
|
||||
topStreak: z.number(),
|
||||
totalItems: z.number().optional(),
|
||||
}),
|
||||
recentEvents: z.array(RecentEventSchema),
|
||||
activeLootdrops: z.array(z.object({
|
||||
rewardAmount: z.number(),
|
||||
currency: z.string(),
|
||||
createdAt: z.string(),
|
||||
expiresAt: z.string().nullable(),
|
||||
})).optional(),
|
||||
lootdropState: z.object({
|
||||
monitoredChannels: z.number(),
|
||||
hottestChannel: z.object({
|
||||
id: z.string(),
|
||||
messages: z.number(),
|
||||
progress: z.number(),
|
||||
cooldown: z.boolean(),
|
||||
}).nullable(),
|
||||
config: z.object({
|
||||
requiredMessages: z.number(),
|
||||
dropChance: z.number(),
|
||||
}),
|
||||
}).optional(),
|
||||
leaderboards: z.object({
|
||||
topLevels: z.array(z.object({
|
||||
username: z.string(),
|
||||
level: z.number(),
|
||||
})),
|
||||
topWealth: z.array(z.object({
|
||||
username: z.string(),
|
||||
balance: z.string(),
|
||||
})),
|
||||
topNetWorth: z.array(z.object({
|
||||
username: z.string(),
|
||||
netWorth: z.string(),
|
||||
})),
|
||||
}).optional(),
|
||||
uptime: z.number(),
|
||||
lastCommandTimestamp: z.number().nullable(),
|
||||
maintenanceMode: z.boolean(),
|
||||
});
|
||||
|
||||
export type DashboardStats = z.infer<typeof DashboardStatsSchema>;
|
||||
|
||||
export const ClientStatsSchema = z.object({
|
||||
bot: z.object({
|
||||
name: z.string(),
|
||||
avatarUrl: z.string().nullable(),
|
||||
status: z.string().nullable(),
|
||||
}),
|
||||
guilds: z.number(),
|
||||
ping: z.number(),
|
||||
cachedUsers: z.number(),
|
||||
commandsRegistered: z.number(),
|
||||
commandsKnown: z.number(),
|
||||
uptime: z.number(),
|
||||
lastCommandTimestamp: z.number().nullable(),
|
||||
});
|
||||
|
||||
export type ClientStats = z.infer<typeof ClientStatsSchema>;
|
||||
|
||||
// Action Schemas
|
||||
export const MaintenanceModeSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
// WebSocket Message Schemas
|
||||
export const WsMessageSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("PING") }),
|
||||
z.object({ type: z.literal("PONG") }),
|
||||
z.object({ type: z.literal("STATS_UPDATE"), data: DashboardStatsSchema }),
|
||||
z.object({ type: z.literal("NEW_EVENT"), data: RecentEventSchema }),
|
||||
]);
|
||||
|
||||
export type WsMessage = z.infer<typeof WsMessageSchema>;
|
||||
export const ActivityDataSchema = z.object({
|
||||
hour: z.string(),
|
||||
commands: z.number(),
|
||||
transactions: z.number(),
|
||||
});
|
||||
|
||||
export type ActivityData = z.infer<typeof ActivityDataSchema>;
|
||||
@@ -3,7 +3,7 @@ import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { users, userTimers, transactions } from "@db/schema";
|
||||
|
||||
// Define mock functions
|
||||
const mockFindMany = mock();
|
||||
const mockFindMany = mock(() => Promise.resolve([]));
|
||||
const mockFindFirst = mock();
|
||||
const mockInsert = mock();
|
||||
const mockUpdate = mock();
|
||||
@@ -33,6 +33,7 @@ mock.module("@shared/db/DrizzleClient", () => {
|
||||
query: {
|
||||
users: { findFirst: mockFindFirst },
|
||||
userTimers: { findFirst: mockFindFirst },
|
||||
userQuests: { findMany: mockFindMany },
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
@@ -173,7 +174,7 @@ describe("economyService", () => {
|
||||
it("should throw if cooldown is active", async () => {
|
||||
const future = new Date("2023-01-02T12:00:00Z"); // +24h
|
||||
mockFindFirst.mockResolvedValue({ expiresAt: future });
|
||||
expect(economyService.claimDaily("1")).rejects.toThrow("Daily already claimed");
|
||||
expect(economyService.claimDaily("1")).rejects.toThrow("You have already claimed your daily reward today");
|
||||
});
|
||||
|
||||
it("should set cooldown to next UTC midnight", async () => {
|
||||
|
||||
@@ -61,6 +61,14 @@ export const economyService = {
|
||||
description: `Transfer from ${fromUserId}`,
|
||||
});
|
||||
|
||||
// Record dashboard event
|
||||
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||
await dashboardService.recordEvent({
|
||||
type: 'info',
|
||||
message: `${sender.username} transferred ${amount.toLocaleString()} AU to User ID ${toUserId}`,
|
||||
icon: '💸'
|
||||
});
|
||||
|
||||
return { success: true, amount };
|
||||
}, tx);
|
||||
},
|
||||
@@ -79,7 +87,7 @@ export const economyService = {
|
||||
});
|
||||
|
||||
if (cooldown && cooldown.expiresAt > now) {
|
||||
throw new UserError(`Daily already claimed today. Next claim <t:${Math.floor(cooldown.expiresAt.getTime() / 1000)}:F>`);
|
||||
throw new UserError(`You have already claimed your daily reward today.\nNext claim available: <t:${Math.floor(cooldown.expiresAt.getTime() / 1000)}:F> (<t:${Math.floor(cooldown.expiresAt.getTime() / 1000)}:R>)`);
|
||||
}
|
||||
|
||||
// Get user for streak logic
|
||||
@@ -149,6 +157,14 @@ export const economyService = {
|
||||
description: `Daily reward (Streak: ${streak})`,
|
||||
});
|
||||
|
||||
// Record dashboard event
|
||||
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||
await dashboardService.recordEvent({
|
||||
type: 'success',
|
||||
message: `${user.username} claimed daily reward: ${totalReward.toLocaleString()} AU`,
|
||||
icon: '☀️'
|
||||
});
|
||||
|
||||
return { claimed: true, amount: totalReward, streak, nextReadyAt, isWeekly: isWeeklyCurrent, weeklyBonus: weeklyBonusAmount };
|
||||
}, tx);
|
||||
},
|
||||
@@ -180,6 +196,10 @@ export const economyService = {
|
||||
description: description,
|
||||
});
|
||||
|
||||
// Trigger Quest Event
|
||||
const { questService } = await import("@shared/modules/quest/quest.service");
|
||||
await questService.handleEvent(id, type, 1, txFn);
|
||||
|
||||
return user;
|
||||
}, tx);
|
||||
},
|
||||
|
||||
237
shared/modules/economy/exam.service.test.ts
Normal file
237
shared/modules/economy/exam.service.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { describe, it, expect, mock, beforeEach, setSystemTime } from "bun:test";
|
||||
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
|
||||
import { users, userTimers, transactions } from "@db/schema";
|
||||
|
||||
// Define mock functions
|
||||
const mockFindFirst = mock();
|
||||
const mockInsert = mock();
|
||||
const mockUpdate = mock();
|
||||
const mockValues = mock();
|
||||
const mockReturning = mock();
|
||||
const mockSet = mock();
|
||||
const mockWhere = mock();
|
||||
|
||||
// Chainable mock setup
|
||||
mockInsert.mockReturnValue({ values: mockValues });
|
||||
mockValues.mockReturnValue({ returning: mockReturning });
|
||||
mockUpdate.mockReturnValue({ set: mockSet });
|
||||
mockSet.mockReturnValue({ where: mockWhere });
|
||||
mockWhere.mockReturnValue({ returning: mockReturning });
|
||||
|
||||
// Mock DrizzleClient
|
||||
mock.module("@shared/db/DrizzleClient", () => {
|
||||
const createMockTx = () => ({
|
||||
query: {
|
||||
users: { findFirst: mockFindFirst },
|
||||
userTimers: { findFirst: mockFindFirst },
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
});
|
||||
|
||||
return {
|
||||
DrizzleClient: {
|
||||
query: {
|
||||
users: { findFirst: mockFindFirst },
|
||||
userTimers: { findFirst: mockFindFirst },
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
transaction: async (cb: any) => {
|
||||
return cb(createMockTx());
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock withTransaction
|
||||
mock.module("@/lib/db", () => ({
|
||||
withTransaction: async (cb: any, tx?: any) => {
|
||||
if (tx) return cb(tx);
|
||||
return cb({
|
||||
query: {
|
||||
users: { findFirst: mockFindFirst },
|
||||
userTimers: { findFirst: mockFindFirst },
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock Config
|
||||
mock.module("@shared/lib/config", () => ({
|
||||
config: {
|
||||
economy: {
|
||||
exam: {
|
||||
multMin: 1.0,
|
||||
multMax: 2.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock User Service
|
||||
mock.module("@shared/modules/user/user.service", () => ({
|
||||
userService: {
|
||||
getOrCreateUser: mock()
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock Dashboard Service
|
||||
mock.module("@shared/modules/dashboard/dashboard.service", () => ({
|
||||
dashboardService: {
|
||||
recordEvent: mock()
|
||||
}
|
||||
}));
|
||||
|
||||
describe("ExamService", () => {
|
||||
beforeEach(() => {
|
||||
mockFindFirst.mockReset();
|
||||
mockInsert.mockClear();
|
||||
mockUpdate.mockClear();
|
||||
mockValues.mockClear();
|
||||
mockReturning.mockClear();
|
||||
mockSet.mockClear();
|
||||
mockWhere.mockClear();
|
||||
});
|
||||
|
||||
describe("getExamStatus", () => {
|
||||
it("should return NOT_REGISTERED if no timer exists", async () => {
|
||||
mockFindFirst.mockResolvedValue(undefined);
|
||||
const status = await examService.getExamStatus("1");
|
||||
expect(status.status).toBe(ExamStatus.NOT_REGISTERED);
|
||||
});
|
||||
|
||||
it("should return COOLDOWN if now < expiresAt", async () => {
|
||||
const now = new Date("2024-01-10T12:00:00Z");
|
||||
setSystemTime(now);
|
||||
const future = new Date("2024-01-11T00:00:00Z");
|
||||
|
||||
mockFindFirst.mockResolvedValue({
|
||||
expiresAt: future,
|
||||
metadata: { examDay: 3, lastXp: "100" }
|
||||
});
|
||||
|
||||
const status = await examService.getExamStatus("1");
|
||||
expect(status.status).toBe(ExamStatus.COOLDOWN);
|
||||
expect(status.nextExamAt?.getTime()).toBe(future.setHours(0,0,0,0));
|
||||
});
|
||||
|
||||
it("should return MISSED if it is the wrong day", async () => {
|
||||
const now = new Date("2024-01-15T12:00:00Z"); // Monday (1)
|
||||
setSystemTime(now);
|
||||
const past = new Date("2024-01-10T00:00:00Z"); // Wednesday (3) last week
|
||||
|
||||
mockFindFirst.mockResolvedValue({
|
||||
expiresAt: past,
|
||||
metadata: { examDay: 3, lastXp: "100" } // Registered for Wednesday
|
||||
});
|
||||
|
||||
const status = await examService.getExamStatus("1");
|
||||
expect(status.status).toBe(ExamStatus.MISSED);
|
||||
expect(status.examDay).toBe(3);
|
||||
});
|
||||
|
||||
it("should return AVAILABLE if it is the correct day", async () => {
|
||||
const now = new Date("2024-01-17T12:00:00Z"); // Wednesday (3)
|
||||
setSystemTime(now);
|
||||
const past = new Date("2024-01-10T00:00:00Z");
|
||||
|
||||
mockFindFirst.mockResolvedValue({
|
||||
expiresAt: past,
|
||||
metadata: { examDay: 3, lastXp: "100" }
|
||||
});
|
||||
|
||||
const status = await examService.getExamStatus("1");
|
||||
expect(status.status).toBe(ExamStatus.AVAILABLE);
|
||||
expect(status.examDay).toBe(3);
|
||||
expect(status.lastXp).toBe(100n);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerForExam", () => {
|
||||
it("should create user and timer correctly", async () => {
|
||||
const now = new Date("2024-01-15T12:00:00Z"); // Monday (1)
|
||||
setSystemTime(now);
|
||||
|
||||
const { userService } = await import("@shared/modules/user/user.service");
|
||||
(userService.getOrCreateUser as any).mockResolvedValue({ id: 1n, xp: 500n });
|
||||
|
||||
const result = await examService.registerForExam("1", "testuser");
|
||||
|
||||
expect(result.status).toBe(ExamStatus.NOT_REGISTERED);
|
||||
expect(result.examDay).toBe(1);
|
||||
|
||||
expect(mockInsert).toHaveBeenCalledWith(userTimers);
|
||||
expect(mockInsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("takeExam", () => {
|
||||
it("should return NOT_REGISTERED if not registered", async () => {
|
||||
mockFindFirst.mockResolvedValueOnce({ id: 1n }) // user check
|
||||
.mockResolvedValueOnce(undefined); // timer check
|
||||
|
||||
const result = await examService.takeExam("1");
|
||||
expect(result.status).toBe(ExamStatus.NOT_REGISTERED);
|
||||
});
|
||||
|
||||
it("should handle missed exam and schedule for next exam day", async () => {
|
||||
const now = new Date("2024-01-15T12:00:00Z"); // Monday (1)
|
||||
setSystemTime(now);
|
||||
const past = new Date("2024-01-10T00:00:00Z");
|
||||
|
||||
mockFindFirst.mockResolvedValueOnce({ id: 1n, xp: 600n }) // user
|
||||
.mockResolvedValueOnce({
|
||||
expiresAt: past,
|
||||
metadata: { examDay: 3, lastXp: "500" } // Registered for Wednesday
|
||||
}); // timer
|
||||
|
||||
const result = await examService.takeExam("1");
|
||||
|
||||
expect(result.status).toBe(ExamStatus.MISSED);
|
||||
expect(result.examDay).toBe(3);
|
||||
|
||||
// Should set next exam to next Wednesday
|
||||
// Monday (1) + 2 days = Wednesday (3)
|
||||
const expected = new Date("2024-01-17T00:00:00Z");
|
||||
expect(result.nextExamAt!.getTime()).toBe(expected.getTime());
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith(userTimers);
|
||||
});
|
||||
|
||||
it("should calculate rewards and update state when passed", async () => {
|
||||
const now = new Date("2024-01-17T12:00:00Z"); // Wednesday (3)
|
||||
setSystemTime(now);
|
||||
const past = new Date("2024-01-10T00:00:00Z");
|
||||
|
||||
mockFindFirst.mockResolvedValueOnce({ id: 1n, username: "testuser", xp: 1000n, balance: 0n }) // user
|
||||
.mockResolvedValueOnce({
|
||||
expiresAt: past,
|
||||
metadata: { examDay: 3, lastXp: "500" }
|
||||
}); // timer
|
||||
|
||||
const result = await examService.takeExam("1");
|
||||
|
||||
expect(result.status).toBe(ExamStatus.AVAILABLE);
|
||||
expect(result.xpDiff).toBe(500n);
|
||||
// Multiplier is between 1.0 and 2.0 based on mock config
|
||||
expect(result.multiplier).toBeGreaterThanOrEqual(1.0);
|
||||
expect(result.multiplier).toBeLessThanOrEqual(2.0);
|
||||
expect(result.reward).toBeGreaterThanOrEqual(500n);
|
||||
expect(result.reward).toBeLessThanOrEqual(1000n);
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith(userTimers);
|
||||
expect(mockUpdate).toHaveBeenCalledWith(users);
|
||||
|
||||
// Verify transaction
|
||||
expect(mockInsert).toHaveBeenCalledWith(transactions);
|
||||
expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
amount: result.reward,
|
||||
userId: 1n,
|
||||
type: expect.anything()
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
262
shared/modules/economy/exam.service.ts
Normal file
262
shared/modules/economy/exam.service.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { users, userTimers, transactions } from "@db/schema";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { TimerType, TransactionType } from "@shared/lib/constants";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { withTransaction } from "@/lib/db";
|
||||
import type { Transaction } from "@shared/lib/types";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
|
||||
const EXAM_TIMER_KEY = 'default';
|
||||
|
||||
export interface ExamMetadata {
|
||||
examDay: number;
|
||||
lastXp: string;
|
||||
}
|
||||
|
||||
export enum ExamStatus {
|
||||
NOT_REGISTERED = 'NOT_REGISTERED',
|
||||
COOLDOWN = 'COOLDOWN',
|
||||
MISSED = 'MISSED',
|
||||
AVAILABLE = 'AVAILABLE',
|
||||
}
|
||||
|
||||
export interface ExamActionResult {
|
||||
status: ExamStatus;
|
||||
nextExamAt?: Date;
|
||||
reward?: bigint;
|
||||
xpDiff?: bigint;
|
||||
multiplier?: number;
|
||||
examDay?: number;
|
||||
}
|
||||
|
||||
export const examService = {
|
||||
/**
|
||||
* Get the current exam status for a user.
|
||||
*/
|
||||
async getExamStatus(userId: string, tx?: Transaction) {
|
||||
return await withTransaction(async (txFn) => {
|
||||
const timer = await txFn.query.userTimers.findFirst({
|
||||
where: and(
|
||||
eq(userTimers.userId, BigInt(userId)),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
)
|
||||
});
|
||||
|
||||
if (!timer) {
|
||||
return { status: ExamStatus.NOT_REGISTERED };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(timer.expiresAt);
|
||||
expiresAt.setHours(0, 0, 0, 0);
|
||||
|
||||
if (now < expiresAt) {
|
||||
return {
|
||||
status: ExamStatus.COOLDOWN,
|
||||
nextExamAt: expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
const metadata = timer.metadata as unknown as ExamMetadata;
|
||||
const currentDay = now.getDay();
|
||||
|
||||
if (currentDay !== metadata.examDay) {
|
||||
return {
|
||||
status: ExamStatus.MISSED,
|
||||
nextExamAt: expiresAt,
|
||||
examDay: metadata.examDay
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: ExamStatus.AVAILABLE,
|
||||
examDay: metadata.examDay,
|
||||
lastXp: BigInt(metadata.lastXp || "0")
|
||||
};
|
||||
}, tx);
|
||||
},
|
||||
|
||||
/**
|
||||
* Register a user for the first time.
|
||||
*/
|
||||
async registerForExam(userId: string, username: string, tx?: Transaction): Promise<ExamActionResult> {
|
||||
return await withTransaction(async (txFn) => {
|
||||
// Ensure user exists
|
||||
const { userService } = await import("@shared/modules/user/user.service");
|
||||
const user = await userService.getOrCreateUser(userId, username, txFn);
|
||||
if (!user) throw new Error("Failed to get or create user.");
|
||||
|
||||
const now = new Date();
|
||||
const currentDay = now.getDay();
|
||||
|
||||
// Set next exam to next week
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + 7);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const metadata: ExamMetadata = {
|
||||
examDay: currentDay,
|
||||
lastXp: (user.xp ?? 0n).toString()
|
||||
};
|
||||
|
||||
await txFn.insert(userTimers).values({
|
||||
userId: BigInt(userId),
|
||||
type: EXAM_TIMER_TYPE,
|
||||
key: EXAM_TIMER_KEY,
|
||||
expiresAt: nextExamDate,
|
||||
metadata: metadata
|
||||
});
|
||||
|
||||
return {
|
||||
status: ExamStatus.NOT_REGISTERED,
|
||||
nextExamAt: nextExamDate,
|
||||
examDay: currentDay
|
||||
};
|
||||
}, tx);
|
||||
},
|
||||
|
||||
/**
|
||||
* Take the exam. Handles missed exams and reward calculations.
|
||||
*/
|
||||
async takeExam(userId: string, tx?: Transaction): Promise<ExamActionResult> {
|
||||
return await withTransaction(async (txFn) => {
|
||||
const user = await txFn.query.users.findFirst({
|
||||
where: eq(users.id, BigInt(userId))
|
||||
});
|
||||
|
||||
if (!user) throw new Error("User not found");
|
||||
|
||||
const timer = await txFn.query.userTimers.findFirst({
|
||||
where: and(
|
||||
eq(userTimers.userId, BigInt(userId)),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
)
|
||||
});
|
||||
|
||||
if (!timer) {
|
||||
return { status: ExamStatus.NOT_REGISTERED };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(timer.expiresAt);
|
||||
expiresAt.setHours(0, 0, 0, 0);
|
||||
|
||||
if (now < expiresAt) {
|
||||
return {
|
||||
status: ExamStatus.COOLDOWN,
|
||||
nextExamAt: expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
const metadata = timer.metadata as unknown as ExamMetadata;
|
||||
const examDay = metadata.examDay;
|
||||
const currentDay = now.getDay();
|
||||
|
||||
if (currentDay !== examDay) {
|
||||
// Missed exam logic
|
||||
let daysUntil = (examDay - currentDay + 7) % 7;
|
||||
if (daysUntil === 0) daysUntil = 7;
|
||||
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + daysUntil);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const newMetadata: ExamMetadata = {
|
||||
examDay: examDay,
|
||||
lastXp: (user.xp ?? 0n).toString()
|
||||
};
|
||||
|
||||
await txFn.update(userTimers)
|
||||
.set({
|
||||
expiresAt: nextExamDate,
|
||||
metadata: newMetadata
|
||||
})
|
||||
.where(and(
|
||||
eq(userTimers.userId, BigInt(userId)),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
));
|
||||
|
||||
return {
|
||||
status: ExamStatus.MISSED,
|
||||
nextExamAt: nextExamDate,
|
||||
examDay: examDay
|
||||
};
|
||||
}
|
||||
|
||||
// Reward Calculation
|
||||
const lastXp = BigInt(metadata.lastXp || "0");
|
||||
const currentXp = user.xp ?? 0n;
|
||||
const diff = currentXp - lastXp;
|
||||
|
||||
const multMin = config.economy.exam.multMin;
|
||||
const multMax = config.economy.exam.multMax;
|
||||
const multiplier = Math.random() * (multMax - multMin) + multMin;
|
||||
|
||||
let reward = 0n;
|
||||
if (diff > 0n) {
|
||||
// Use scaled BigInt arithmetic to avoid precision loss with large XP values
|
||||
const scaledMultiplier = BigInt(Math.round(multiplier * 10000));
|
||||
reward = (diff * scaledMultiplier) / 10000n;
|
||||
}
|
||||
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + 7);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const newMetadata: ExamMetadata = {
|
||||
examDay: examDay,
|
||||
lastXp: currentXp.toString()
|
||||
};
|
||||
|
||||
// Update Timer
|
||||
await txFn.update(userTimers)
|
||||
.set({
|
||||
expiresAt: nextExamDate,
|
||||
metadata: newMetadata
|
||||
})
|
||||
.where(and(
|
||||
eq(userTimers.userId, BigInt(userId)),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
));
|
||||
|
||||
// Add Currency
|
||||
if (reward > 0n) {
|
||||
await txFn.update(users)
|
||||
.set({
|
||||
balance: sql`${users.balance} + ${reward}`
|
||||
})
|
||||
.where(eq(users.id, BigInt(userId)));
|
||||
|
||||
// Add Transaction Record
|
||||
await txFn.insert(transactions).values({
|
||||
userId: BigInt(userId),
|
||||
amount: reward,
|
||||
type: TransactionType.EXAM_REWARD,
|
||||
description: `Weekly exam reward (XP Diff: ${diff})`,
|
||||
});
|
||||
}
|
||||
|
||||
// Record dashboard event
|
||||
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||
await dashboardService.recordEvent({
|
||||
type: 'success',
|
||||
message: `${user.username} passed their exam: ${reward.toLocaleString()} AU`,
|
||||
icon: '🎓'
|
||||
});
|
||||
|
||||
return {
|
||||
status: ExamStatus.AVAILABLE,
|
||||
nextExamAt: nextExamDate,
|
||||
reward,
|
||||
xpDiff: diff,
|
||||
multiplier,
|
||||
examDay
|
||||
};
|
||||
}, tx);
|
||||
}
|
||||
};
|
||||
@@ -163,6 +163,48 @@ class LootdropService {
|
||||
return { success: false, error: "An error occurred while processing the reward." };
|
||||
}
|
||||
}
|
||||
public getLootdropState() {
|
||||
let hottestChannel: { id: string; messages: number; progress: number; cooldown: boolean; } | null = null;
|
||||
let maxMessages = -1;
|
||||
|
||||
const window = config.lootdrop.activityWindowMs;
|
||||
const now = Date.now();
|
||||
const required = config.lootdrop.minMessages;
|
||||
|
||||
for (const [channelId, timestamps] of this.channelActivity.entries()) {
|
||||
// Filter valid just to be sure we are reporting accurate numbers
|
||||
const validCount = timestamps.filter(t => now - t < window).length;
|
||||
|
||||
// Check cooldown
|
||||
const cooldownUntil = this.channelCooldowns.get(channelId);
|
||||
const isOnCooldown = !!(cooldownUntil && now < cooldownUntil);
|
||||
|
||||
if (validCount > maxMessages) {
|
||||
maxMessages = validCount;
|
||||
hottestChannel = {
|
||||
id: channelId,
|
||||
messages: validCount,
|
||||
progress: Math.min(100, (validCount / required) * 100),
|
||||
cooldown: isOnCooldown
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
monitoredChannels: this.channelActivity.size,
|
||||
hottestChannel,
|
||||
config: {
|
||||
requiredMessages: required,
|
||||
dropChance: config.lootdrop.spawnChance
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public async clearCaches() {
|
||||
this.channelActivity.clear();
|
||||
this.channelCooldowns.clear();
|
||||
console.log("[LootdropService] Caches cleared via administrative action.");
|
||||
}
|
||||
}
|
||||
|
||||
export const lootdropService = new LootdropService();
|
||||
|
||||
@@ -48,6 +48,8 @@ mock.module("@shared/db/DrizzleClient", () => {
|
||||
inventory: { findFirst: mockFindFirst, findMany: mockFindMany },
|
||||
items: { findFirst: mockFindFirst },
|
||||
userTimers: { findFirst: mockFindFirst },
|
||||
userQuests: { findMany: mockFindMany, findFirst: mockFindFirst },
|
||||
quests: { findMany: mockFindMany },
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
@@ -79,6 +81,7 @@ describe("inventoryService", () => {
|
||||
beforeEach(() => {
|
||||
mockFindFirst.mockReset();
|
||||
mockFindMany.mockReset();
|
||||
mockFindMany.mockResolvedValue([]);
|
||||
mockInsert.mockClear();
|
||||
mockUpdate.mockClear();
|
||||
mockDelete.mockClear();
|
||||
|
||||
@@ -37,6 +37,11 @@ export const inventoryService = {
|
||||
eq(inventory.itemId, itemId)
|
||||
))
|
||||
.returning();
|
||||
|
||||
// Trigger Quest Event
|
||||
const { questService } = await import("@shared/modules/quest/quest.service");
|
||||
await questService.handleEvent(userId, `ITEM_COLLECT:${itemId}`, Number(quantity), txFn);
|
||||
|
||||
return entry;
|
||||
} else {
|
||||
// Check Slot Limit
|
||||
@@ -60,6 +65,11 @@ export const inventoryService = {
|
||||
quantity: quantity,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Trigger Quest Event
|
||||
const { questService } = await import("@shared/modules/quest/quest.service");
|
||||
await questService.handleEvent(userId, `ITEM_COLLECT:${itemId}`, Number(quantity), txFn);
|
||||
|
||||
return entry;
|
||||
}
|
||||
}, tx);
|
||||
@@ -179,6 +189,10 @@ export const inventoryService = {
|
||||
await inventoryService.removeItem(userId, itemId, 1n, txFn);
|
||||
}
|
||||
|
||||
// Trigger Quest Event
|
||||
const { questService } = await import("@shared/modules/quest/quest.service");
|
||||
await questService.handleEvent(userId, `ITEM_USE:${itemId}`, 1, txFn);
|
||||
|
||||
return { success: true, results, usageData, item };
|
||||
}, tx);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { users, userTimers } from "@db/schema";
|
||||
|
||||
// Mock dependencies
|
||||
const mockFindFirst = mock();
|
||||
const mockFindMany = mock(() => Promise.resolve([]));
|
||||
const mockUpdate = mock();
|
||||
const mockSet = mock();
|
||||
const mockWhere = mock();
|
||||
@@ -24,8 +25,10 @@ mockOnConflictDoUpdate.mockResolvedValue({});
|
||||
mock.module("@shared/db/DrizzleClient", () => {
|
||||
const createMockTx = () => ({
|
||||
query: {
|
||||
|
||||
users: { findFirst: mockFindFirst },
|
||||
userTimers: { findFirst: mockFindFirst },
|
||||
userQuests: { findMany: mockFindMany },
|
||||
},
|
||||
update: mockUpdate,
|
||||
insert: mockInsert,
|
||||
|
||||
@@ -68,6 +68,10 @@ export const levelingService = {
|
||||
.where(eq(users.id, BigInt(id)))
|
||||
.returning();
|
||||
|
||||
// Trigger Quest Event
|
||||
const { questService } = await import("@shared/modules/quest/quest.service");
|
||||
await questService.handleEvent(id, 'XP_GAIN', Number(amount), txFn);
|
||||
|
||||
return { user: updatedUser, levelUp, currentLevel: newLevel };
|
||||
}, tx);
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@ mock.module("@shared/lib/config", () => ({
|
||||
|
||||
// Mock View
|
||||
const mockGetUserWarningEmbed = mock(() => ({}));
|
||||
mock.module("./moderation.view", () => ({
|
||||
mock.module("@/modules/moderation/moderation.view", () => ({
|
||||
getUserWarningEmbed: mockGetUserWarningEmbed
|
||||
}));
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ mock.module("@shared/db/DrizzleClient", () => {
|
||||
const createMockTx = () => ({
|
||||
query: {
|
||||
userQuests: { findFirst: mockFindFirst, findMany: mockFindMany },
|
||||
quests: { findMany: mockFindMany },
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
@@ -148,4 +149,147 @@ describe("questService", () => {
|
||||
expect(result).toEqual(mockData as any);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAvailableQuests", () => {
|
||||
it("should return quests not yet accepted by user", async () => {
|
||||
// First call to findMany (userQuests) returns accepted quest IDs
|
||||
// Second call to findMany (quests) returns available quests
|
||||
mockFindMany
|
||||
.mockResolvedValueOnce([{ questId: 1 }]) // userQuests
|
||||
.mockResolvedValueOnce([{ id: 2, name: "New Quest" }]); // quests
|
||||
|
||||
const result = await questService.getAvailableQuests("1");
|
||||
|
||||
expect(result).toEqual([{ id: 2, name: "New Quest" }] as any);
|
||||
expect(mockFindMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should return all quests if user has no assigned quests", async () => {
|
||||
mockFindMany
|
||||
.mockResolvedValueOnce([]) // userQuests
|
||||
.mockResolvedValueOnce([{ id: 1 }, { id: 2 }]); // quests
|
||||
|
||||
const result = await questService.getAvailableQuests("1");
|
||||
|
||||
expect(result).toEqual([{ id: 1 }, { id: 2 }] as any);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleEvent", () => {
|
||||
it("should progress a quest with sub-events", async () => {
|
||||
const mockUserQuest = {
|
||||
userId: 1n,
|
||||
questId: 101,
|
||||
progress: 0,
|
||||
completedAt: null,
|
||||
quest: { triggerEvent: "ITEM_USE:101", requirements: { target: 5 } }
|
||||
};
|
||||
mockFindMany.mockResolvedValue([mockUserQuest]);
|
||||
mockReturning.mockResolvedValue([{ userId: 1n, questId: 101, progress: 1 }]);
|
||||
|
||||
await questService.handleEvent("1", "ITEM_USE:101", 1);
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalled();
|
||||
expect(mockSet).toHaveBeenCalledWith({ progress: 1 });
|
||||
});
|
||||
|
||||
it("should complete a quest when target reached using sub-events", async () => {
|
||||
const mockUserQuest = {
|
||||
userId: 1n,
|
||||
questId: 101,
|
||||
progress: 4,
|
||||
completedAt: null,
|
||||
quest: {
|
||||
triggerEvent: "ITEM_COLLECT:505",
|
||||
requirements: { target: 5 },
|
||||
rewards: { balance: 100 }
|
||||
}
|
||||
};
|
||||
mockFindMany.mockResolvedValue([mockUserQuest]);
|
||||
mockFindFirst.mockResolvedValue(mockUserQuest); // For completeQuest
|
||||
|
||||
await questService.handleEvent("1", "ITEM_COLLECT:505", 1);
|
||||
|
||||
// Verify completeQuest was called (it will update completedAt)
|
||||
expect(mockUpdate).toHaveBeenCalled();
|
||||
expect(mockSet).toHaveBeenCalledWith({ completedAt: expect.any(Date) });
|
||||
});
|
||||
|
||||
it("should progress a quest with generic events", async () => {
|
||||
const mockUserQuest = {
|
||||
userId: 1n,
|
||||
questId: 102,
|
||||
progress: 0,
|
||||
completedAt: null,
|
||||
quest: { triggerEvent: "ITEM_COLLECT", requirements: { target: 5 } }
|
||||
};
|
||||
mockFindMany.mockResolvedValue([mockUserQuest]);
|
||||
mockReturning.mockResolvedValue([{ userId: 1n, questId: 102, progress: 1 }]);
|
||||
|
||||
await questService.handleEvent("1", "ITEM_COLLECT:505", 1);
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalled();
|
||||
expect(mockSet).toHaveBeenCalledWith({ progress: 1 });
|
||||
});
|
||||
|
||||
it("should ignore events that are not prefix matches", async () => {
|
||||
const mockUserQuest = {
|
||||
userId: 1n,
|
||||
questId: 103,
|
||||
progress: 0,
|
||||
completedAt: null,
|
||||
quest: { triggerEvent: "ITEM_COLLECT", requirements: { target: 5 } }
|
||||
};
|
||||
mockFindMany.mockResolvedValue([mockUserQuest]);
|
||||
|
||||
await questService.handleEvent("1", "ITEM_COLLECT_UNRELATED", 1);
|
||||
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not progress a specific quest with a different specific event", async () => {
|
||||
const mockUserQuest = {
|
||||
userId: 1n,
|
||||
questId: 104,
|
||||
progress: 0,
|
||||
completedAt: null,
|
||||
quest: { triggerEvent: "ITEM_COLLECT:101", requirements: { target: 5 } }
|
||||
};
|
||||
mockFindMany.mockResolvedValue([mockUserQuest]);
|
||||
|
||||
await questService.handleEvent("1", "ITEM_COLLECT:202", 1);
|
||||
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not progress a specific quest with a generic event", async () => {
|
||||
const mockUserQuest = {
|
||||
userId: 1n,
|
||||
questId: 105,
|
||||
progress: 0,
|
||||
completedAt: null,
|
||||
quest: { triggerEvent: "ITEM_COLLECT:101", requirements: { target: 5 } }
|
||||
};
|
||||
mockFindMany.mockResolvedValue([mockUserQuest]);
|
||||
|
||||
await questService.handleEvent("1", "ITEM_COLLECT", 1);
|
||||
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should ignore irrelevant events", async () => {
|
||||
const mockUserQuest = {
|
||||
userId: 1n,
|
||||
questId: 101,
|
||||
progress: 0,
|
||||
completedAt: null,
|
||||
quest: { triggerEvent: "DIFFERENT_EVENT", requirements: { target: 5 } }
|
||||
};
|
||||
mockFindMany.mockResolvedValue([mockUserQuest]);
|
||||
|
||||
await questService.handleEvent("1", "TEST_EVENT", 1);
|
||||
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { userQuests } from "@db/schema";
|
||||
import { userQuests, quests } from "@db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
@@ -7,6 +7,7 @@ import { levelingService } from "@shared/modules/leveling/leveling.service";
|
||||
import { withTransaction } from "@/lib/db";
|
||||
import type { Transaction } from "@shared/lib/types";
|
||||
import { TransactionType } from "@shared/lib/constants";
|
||||
import { systemEvents, EVENTS } from "@shared/lib/events";
|
||||
|
||||
export const questService = {
|
||||
assignQuest: async (userId: string, questId: number, tx?: Transaction) => {
|
||||
@@ -34,6 +35,40 @@ export const questService = {
|
||||
}, tx);
|
||||
},
|
||||
|
||||
handleEvent: async (userId: string, eventName: string, weight: number = 1, tx?: Transaction) => {
|
||||
return await withTransaction(async (txFn) => {
|
||||
// 1. Fetch active user quests for this event
|
||||
const activeUserQuests = await txFn.query.userQuests.findMany({
|
||||
where: and(
|
||||
eq(userQuests.userId, BigInt(userId)),
|
||||
),
|
||||
with: {
|
||||
quest: true
|
||||
}
|
||||
});
|
||||
|
||||
const relevant = activeUserQuests.filter(uq => {
|
||||
const trigger = uq.quest.triggerEvent;
|
||||
// Exact match or prefix match (e.g. ITEM_COLLECT matches ITEM_COLLECT:101)
|
||||
const isMatch = eventName === trigger || eventName.startsWith(trigger + ":");
|
||||
return isMatch && !uq.completedAt;
|
||||
});
|
||||
|
||||
for (const uq of relevant) {
|
||||
const requirements = uq.quest.requirements as { target?: number };
|
||||
const target = requirements?.target || 1;
|
||||
|
||||
const newProgress = (uq.progress || 0) + weight;
|
||||
|
||||
if (newProgress >= target) {
|
||||
await questService.completeQuest(userId, uq.questId, txFn);
|
||||
} else {
|
||||
await questService.updateProgress(userId, uq.questId, newProgress, txFn);
|
||||
}
|
||||
}
|
||||
}, tx);
|
||||
},
|
||||
|
||||
completeQuest: async (userId: string, questId: number, tx?: Transaction) => {
|
||||
return await withTransaction(async (txFn) => {
|
||||
const userQuest = await txFn.query.userQuests.findFirst({
|
||||
@@ -73,6 +108,14 @@ export const questService = {
|
||||
results.xp = xp;
|
||||
}
|
||||
|
||||
// Emit completion event for the bot to handle notifications
|
||||
systemEvents.emit(EVENTS.QUEST.COMPLETED, {
|
||||
userId,
|
||||
questId,
|
||||
quest: userQuest.quest,
|
||||
rewards: results
|
||||
});
|
||||
|
||||
return { success: true, rewards: results };
|
||||
}, tx);
|
||||
},
|
||||
@@ -84,5 +127,75 @@ export const questService = {
|
||||
quest: true,
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async getAvailableQuests(userId: string) {
|
||||
const userQuestIds = (await DrizzleClient.query.userQuests.findMany({
|
||||
where: eq(userQuests.userId, BigInt(userId)),
|
||||
columns: {
|
||||
questId: true
|
||||
}
|
||||
})).map(uq => uq.questId);
|
||||
|
||||
return await DrizzleClient.query.quests.findMany({
|
||||
where: (quests, { notInArray }) => userQuestIds.length > 0
|
||||
? notInArray(quests.id, userQuestIds)
|
||||
: undefined
|
||||
});
|
||||
},
|
||||
|
||||
async createQuest(data: {
|
||||
name: string;
|
||||
description: string;
|
||||
triggerEvent: string;
|
||||
requirements: { target: number };
|
||||
rewards: { xp: number; balance: number };
|
||||
}, tx?: Transaction) {
|
||||
return await withTransaction(async (txFn) => {
|
||||
return await txFn.insert(quests)
|
||||
.values({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
triggerEvent: data.triggerEvent,
|
||||
requirements: data.requirements,
|
||||
rewards: data.rewards,
|
||||
})
|
||||
.returning();
|
||||
}, tx);
|
||||
},
|
||||
|
||||
async getAllQuests() {
|
||||
return await DrizzleClient.query.quests.findMany({
|
||||
orderBy: (quests, { asc }) => [asc(quests.id)],
|
||||
});
|
||||
},
|
||||
|
||||
async deleteQuest(id: number, tx?: Transaction) {
|
||||
return await withTransaction(async (txFn) => {
|
||||
return await txFn.delete(quests)
|
||||
.where(eq(quests.id, id))
|
||||
.returning();
|
||||
}, tx);
|
||||
},
|
||||
|
||||
async updateQuest(id: number, data: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
triggerEvent?: string;
|
||||
requirements?: { target?: number };
|
||||
rewards?: { xp?: number; balance?: number };
|
||||
}, tx?: Transaction) {
|
||||
return await withTransaction(async (txFn) => {
|
||||
return await txFn.update(quests)
|
||||
.set({
|
||||
...(data.name !== undefined && { name: data.name }),
|
||||
...(data.description !== undefined && { description: data.description }),
|
||||
...(data.triggerEvent !== undefined && { triggerEvent: data.triggerEvent }),
|
||||
...(data.requirements !== undefined && { requirements: data.requirements }),
|
||||
...(data.rewards !== undefined && { rewards: data.rewards }),
|
||||
})
|
||||
.where(eq(quests.id, id))
|
||||
.returning();
|
||||
}, tx);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -196,5 +196,10 @@ export const tradeService = {
|
||||
});
|
||||
|
||||
tradeService.endSession(threadId);
|
||||
},
|
||||
|
||||
clearSessions: () => {
|
||||
sessions.clear();
|
||||
console.log("[TradeService] All active trade sessions cleared.");
|
||||
}
|
||||
};
|
||||
|
||||
267
shared/modules/trivia/trivia.service.test.ts
Normal file
267
shared/modules/trivia/trivia.service.test.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||
import { triviaService } from "./trivia.service";
|
||||
import { users, userTimers, transactions } from "@db/schema";
|
||||
import { TimerType } from "@shared/lib/constants";
|
||||
|
||||
// Define mock functions
|
||||
const mockFindFirst = mock();
|
||||
const mockFindMany = mock(() => Promise.resolve([]));
|
||||
const mockInsert = mock();
|
||||
const mockUpdate = mock();
|
||||
const mockDelete = mock();
|
||||
const mockValues = mock();
|
||||
const mockReturning = mock();
|
||||
const mockSet = mock();
|
||||
const mockWhere = mock();
|
||||
const mockOnConflictDoUpdate = mock();
|
||||
const mockRecordEvent = mock(() => Promise.resolve());
|
||||
|
||||
// Chain setup
|
||||
mockInsert.mockReturnValue({ values: mockValues });
|
||||
mockValues.mockReturnValue({
|
||||
returning: mockReturning,
|
||||
onConflictDoUpdate: mockOnConflictDoUpdate
|
||||
});
|
||||
mockOnConflictDoUpdate.mockResolvedValue({});
|
||||
|
||||
mockUpdate.mockReturnValue({ set: mockSet });
|
||||
mockSet.mockReturnValue({ where: mockWhere });
|
||||
mockWhere.mockReturnValue({ returning: mockReturning });
|
||||
|
||||
// Mock DrizzleClient
|
||||
mock.module("@shared/db/DrizzleClient", () => {
|
||||
const createMockTx = () => ({
|
||||
query: {
|
||||
users: { findFirst: mockFindFirst },
|
||||
userTimers: { findFirst: mockFindFirst },
|
||||
userQuests: { findMany: mockFindMany },
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
});
|
||||
|
||||
return {
|
||||
DrizzleClient: {
|
||||
query: {
|
||||
users: { findFirst: mockFindFirst },
|
||||
userTimers: { findFirst: mockFindFirst },
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
transaction: async (cb: any) => cb(createMockTx())
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Mock Config
|
||||
mock.module("@shared/lib/config", () => ({
|
||||
config: {
|
||||
trivia: {
|
||||
entryFee: 50n,
|
||||
rewardMultiplier: 2.0,
|
||||
timeoutSeconds: 300,
|
||||
cooldownMs: 60000,
|
||||
categories: [9],
|
||||
difficulty: 'medium'
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock Dashboard Service
|
||||
mock.module("@shared/modules/dashboard/dashboard.service", () => ({
|
||||
dashboardService: {
|
||||
recordEvent: mockRecordEvent
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock fetch for OpenTDB
|
||||
global.fetch = mock(() => Promise.resolve({
|
||||
json: () => Promise.resolve({
|
||||
response_code: 0,
|
||||
results: [{
|
||||
category: Buffer.from('General Knowledge').toString('base64'),
|
||||
type: 'multiple',
|
||||
difficulty: Buffer.from('medium').toString('base64'),
|
||||
question: Buffer.from('What is 2 + 2?').toString('base64'),
|
||||
correct_answer: Buffer.from('4').toString('base64'),
|
||||
incorrect_answers: [
|
||||
Buffer.from('3').toString('base64'),
|
||||
Buffer.from('5').toString('base64'),
|
||||
Buffer.from('22').toString('base64'),
|
||||
]
|
||||
}]
|
||||
})
|
||||
})) as any;
|
||||
|
||||
describe("TriviaService", () => {
|
||||
const TEST_USER_ID = "999999999";
|
||||
const TEST_USERNAME = "testuser";
|
||||
|
||||
beforeEach(() => {
|
||||
mockFindFirst.mockReset();
|
||||
mockInsert.mockClear();
|
||||
mockUpdate.mockClear();
|
||||
mockDelete.mockClear();
|
||||
mockValues.mockClear();
|
||||
mockReturning.mockClear();
|
||||
mockSet.mockClear();
|
||||
mockWhere.mockClear();
|
||||
mockOnConflictDoUpdate.mockClear();
|
||||
mockRecordEvent.mockClear();
|
||||
// Clear active sessions
|
||||
(triviaService as any).activeSessions.clear();
|
||||
});
|
||||
|
||||
describe("fetchQuestion", () => {
|
||||
it("should fetch and decode a trivia question", async () => {
|
||||
const question = await triviaService.fetchQuestion();
|
||||
|
||||
expect(question).toBeDefined();
|
||||
expect(question.question).toBe('What is 2 + 2?');
|
||||
expect(question.correctAnswer).toBe('4');
|
||||
expect(question.incorrectAnswers).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canPlayTrivia", () => {
|
||||
it("should allow playing when no cooldown exists", async () => {
|
||||
mockFindFirst.mockResolvedValue(undefined);
|
||||
const result = await triviaService.canPlayTrivia(TEST_USER_ID);
|
||||
expect(result.canPlay).toBe(true);
|
||||
});
|
||||
|
||||
it("should prevent playing when on cooldown", async () => {
|
||||
const future = new Date(Date.now() + 60000);
|
||||
mockFindFirst.mockResolvedValue({ expiresAt: future });
|
||||
|
||||
const result = await triviaService.canPlayTrivia(TEST_USER_ID);
|
||||
expect(result.canPlay).toBe(false);
|
||||
expect(result.nextAvailable).toBe(future);
|
||||
});
|
||||
|
||||
it("should allow playing when cooldown has expired", async () => {
|
||||
const past = new Date(Date.now() - 1000);
|
||||
mockFindFirst.mockResolvedValue({ expiresAt: past });
|
||||
|
||||
const result = await triviaService.canPlayTrivia(TEST_USER_ID);
|
||||
expect(result.canPlay).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("startTrivia", () => {
|
||||
it("should start a trivia session and deduct entry fee", async () => {
|
||||
// Mock cooldown check (first call) and balance check (second call)
|
||||
mockFindFirst
|
||||
.mockResolvedValueOnce(undefined) // No cooldown
|
||||
.mockResolvedValueOnce({ id: 1n, balance: 1000n }); // User balance
|
||||
|
||||
const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME);
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(session.userId).toBe(TEST_USER_ID);
|
||||
expect(session.entryFee).toBe(50n);
|
||||
|
||||
// Check deduction
|
||||
expect(mockUpdate).toHaveBeenCalledWith(users);
|
||||
expect(mockSet).toHaveBeenCalledWith(expect.objectContaining({
|
||||
// sql templating makes exact match hard, checking general invocation
|
||||
}));
|
||||
|
||||
// Check transactions
|
||||
expect(mockInsert).toHaveBeenCalledWith(transactions);
|
||||
|
||||
// Check cooldown set
|
||||
expect(mockInsert).toHaveBeenCalledWith(userTimers);
|
||||
expect(mockOnConflictDoUpdate).toHaveBeenCalled();
|
||||
|
||||
// Check dashboard event
|
||||
expect(mockRecordEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw error if user has insufficient balance", async () => {
|
||||
mockFindFirst
|
||||
.mockResolvedValueOnce(undefined) // No cooldown
|
||||
.mockResolvedValueOnce({ id: 1n, balance: 10n }); // Insufficient balance
|
||||
|
||||
expect(triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME))
|
||||
.rejects.toThrow("Insufficient funds");
|
||||
});
|
||||
|
||||
it("should throw error if user is on cooldown", async () => {
|
||||
mockFindFirst.mockResolvedValueOnce({ expiresAt: new Date(Date.now() + 60000) });
|
||||
|
||||
expect(triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME))
|
||||
.rejects.toThrow("cooldown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("submitAnswer", () => {
|
||||
it("should award prize for correct answer", async () => {
|
||||
// Setup an active session manually
|
||||
const session = {
|
||||
sessionId: "test_session",
|
||||
userId: TEST_USER_ID,
|
||||
question: { correctAnswer: "4" },
|
||||
potentialReward: 100n
|
||||
};
|
||||
(triviaService as any).activeSessions.set("test_session", session);
|
||||
|
||||
// Mock user balance fetch for reward update
|
||||
mockFindFirst.mockResolvedValue({ id: 1n, balance: 950n });
|
||||
|
||||
const result = await triviaService.submitAnswer("test_session", TEST_USER_ID, true);
|
||||
|
||||
expect(result.correct).toBe(true);
|
||||
expect(result.reward).toBe(100n);
|
||||
|
||||
// Verify balance update
|
||||
expect(mockUpdate).toHaveBeenCalledWith(users);
|
||||
expect(mockInsert).toHaveBeenCalledWith(transactions);
|
||||
expect(mockRecordEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not award prize for incorrect answer", async () => {
|
||||
const session = {
|
||||
sessionId: "test_session",
|
||||
userId: TEST_USER_ID,
|
||||
question: { correctAnswer: "4" },
|
||||
potentialReward: 100n
|
||||
};
|
||||
(triviaService as any).activeSessions.set("test_session", session);
|
||||
|
||||
const result = await triviaService.submitAnswer("test_session", TEST_USER_ID, false);
|
||||
|
||||
expect(result.correct).toBe(false);
|
||||
expect(result.reward).toBe(0n);
|
||||
|
||||
// No balance update
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw error if session doesn't exist", async () => {
|
||||
expect(triviaService.submitAnswer("invalid", TEST_USER_ID, true))
|
||||
.rejects.toThrow("Session not found");
|
||||
});
|
||||
|
||||
it("should prevent double submission", async () => {
|
||||
const session = {
|
||||
sessionId: "test_session",
|
||||
userId: TEST_USER_ID,
|
||||
question: { correctAnswer: "4" },
|
||||
potentialReward: 100n
|
||||
};
|
||||
(triviaService as any).activeSessions.set("test_session", session);
|
||||
|
||||
// Mock user for first success
|
||||
mockFindFirst.mockResolvedValue({ id: 1n, balance: 950n });
|
||||
|
||||
await triviaService.submitAnswer("test_session", TEST_USER_ID, true);
|
||||
|
||||
// Second try
|
||||
expect(triviaService.submitAnswer("test_session", TEST_USER_ID, true))
|
||||
.rejects.toThrow("Session not found");
|
||||
});
|
||||
});
|
||||
});
|
||||
313
shared/modules/trivia/trivia.service.ts
Normal file
313
shared/modules/trivia/trivia.service.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { users, userTimers, transactions } from "@db/schema";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { withTransaction } from "@/lib/db";
|
||||
import type { Transaction } from "@shared/lib/types";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { TimerType, TransactionType } from "@shared/lib/constants";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
|
||||
// OpenTDB API Response Types
|
||||
interface OpenTDBResponse {
|
||||
response_code: number;
|
||||
results: Array<{
|
||||
category: string;
|
||||
type: 'boolean' | 'multiple';
|
||||
difficulty: string;
|
||||
question: string;
|
||||
correct_answer: string;
|
||||
incorrect_answers: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TriviaQuestion {
|
||||
question: string;
|
||||
correctAnswer: string;
|
||||
incorrectAnswers: string[];
|
||||
type: 'boolean' | 'multiple';
|
||||
difficulty: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface TriviaSession {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
question: TriviaQuestion;
|
||||
allAnswers: string[];
|
||||
correctIndex: number;
|
||||
expiresAt: Date;
|
||||
entryFee: bigint;
|
||||
potentialReward: bigint;
|
||||
}
|
||||
|
||||
export interface TriviaResult {
|
||||
correct: boolean;
|
||||
reward: bigint;
|
||||
correctAnswer: string;
|
||||
}
|
||||
|
||||
class TriviaService {
|
||||
private activeSessions: Map<string, TriviaSession> = new Map();
|
||||
|
||||
constructor() {
|
||||
// Cleanup expired sessions every 30 seconds
|
||||
setInterval(() => {
|
||||
this.cleanupExpiredSessions();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
private cleanupExpiredSessions() {
|
||||
const now = Date.now();
|
||||
const expired: string[] = [];
|
||||
|
||||
for (const [sessionId, session] of this.activeSessions.entries()) {
|
||||
if (session.expiresAt.getTime() < now) {
|
||||
expired.push(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const sessionId of expired) {
|
||||
this.activeSessions.delete(sessionId);
|
||||
}
|
||||
|
||||
if (expired.length > 0) {
|
||||
console.log(`[TriviaService] Cleaned up ${expired.length} expired sessions.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a trivia question from OpenTDB API
|
||||
*/
|
||||
async fetchQuestion(category?: number, difficulty?: string): Promise<TriviaQuestion> {
|
||||
let url = 'https://opentdb.com/api.php?amount=1&encode=base64';
|
||||
|
||||
if (category) {
|
||||
url += `&category=${category}`;
|
||||
}
|
||||
|
||||
if (difficulty && difficulty !== 'random') {
|
||||
url += `&difficulty=${difficulty}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const data = await response.json() as OpenTDBResponse;
|
||||
|
||||
if (data.response_code !== 0 || !data.results || data.results.length === 0) {
|
||||
throw new Error('Failed to fetch trivia question');
|
||||
}
|
||||
|
||||
const result = data.results[0];
|
||||
|
||||
if (!result) {
|
||||
throw new Error('No trivia question returned');
|
||||
}
|
||||
|
||||
// Decode base64
|
||||
return {
|
||||
category: Buffer.from(result.category, 'base64').toString('utf-8'),
|
||||
type: result.type,
|
||||
difficulty: Buffer.from(result.difficulty, 'base64').toString('utf-8'),
|
||||
question: Buffer.from(result.question, 'base64').toString('utf-8'),
|
||||
correctAnswer: Buffer.from(result.correct_answer, 'base64').toString('utf-8'),
|
||||
incorrectAnswers: result.incorrect_answers.map(ans =>
|
||||
Buffer.from(ans, 'base64').toString('utf-8')
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[TriviaService] Error fetching question:', error);
|
||||
throw new UserError('Failed to fetch trivia question. Please try again later.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can play trivia (cooldown check)
|
||||
*/
|
||||
async canPlayTrivia(userId: string): Promise<{ canPlay: boolean; nextAvailable?: Date }> {
|
||||
const now = new Date();
|
||||
|
||||
const cooldown = await DrizzleClient.query.userTimers.findFirst({
|
||||
where: and(
|
||||
eq(userTimers.userId, BigInt(userId)),
|
||||
eq(userTimers.type, TimerType.TRIVIA_COOLDOWN),
|
||||
eq(userTimers.key, 'default')
|
||||
),
|
||||
});
|
||||
|
||||
if (cooldown && cooldown.expiresAt > now) {
|
||||
return { canPlay: false, nextAvailable: cooldown.expiresAt };
|
||||
}
|
||||
|
||||
return { canPlay: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a trivia session - deducts entry fee and creates session
|
||||
*/
|
||||
async startTrivia(userId: string, username: string, categoryId?: number): Promise<TriviaSession> {
|
||||
// Check cooldown
|
||||
const cooldownCheck = await this.canPlayTrivia(userId);
|
||||
if (!cooldownCheck.canPlay) {
|
||||
const timestamp = Math.floor(cooldownCheck.nextAvailable!.getTime() / 1000);
|
||||
throw new UserError(`You're on cooldown! Try again <t:${timestamp}:R>.`);
|
||||
}
|
||||
|
||||
const entryFee = config.trivia.entryFee;
|
||||
|
||||
return await withTransaction(async (tx) => {
|
||||
// Check balance
|
||||
const user = await tx.query.users.findFirst({
|
||||
where: eq(users.id, BigInt(userId)),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UserError('User not found.');
|
||||
}
|
||||
|
||||
if ((user.balance ?? 0n) < entryFee) {
|
||||
throw new UserError(`Insufficient funds! You need ${entryFee} AU to play trivia.`);
|
||||
}
|
||||
|
||||
// Deduct entry fee (SINK MECHANISM)
|
||||
await tx.update(users)
|
||||
.set({
|
||||
balance: sql`${users.balance} - ${entryFee}`,
|
||||
})
|
||||
.where(eq(users.id, BigInt(userId)));
|
||||
|
||||
// Record transaction
|
||||
await tx.insert(transactions).values({
|
||||
userId: BigInt(userId),
|
||||
amount: -entryFee,
|
||||
type: TransactionType.TRIVIA_ENTRY,
|
||||
description: 'Trivia entry fee',
|
||||
});
|
||||
|
||||
// Fetch question
|
||||
let category = categoryId;
|
||||
if (!category) {
|
||||
category = config.trivia.categories.length > 0
|
||||
? config.trivia.categories[Math.floor(Math.random() * config.trivia.categories.length)]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const difficulty = config.trivia.difficulty;
|
||||
const question = await this.fetchQuestion(category, difficulty);
|
||||
|
||||
// Shuffle answers
|
||||
const allAnswers = [...question.incorrectAnswers, question.correctAnswer];
|
||||
const shuffled = allAnswers.sort(() => Math.random() - 0.5);
|
||||
const correctIndex = shuffled.indexOf(question.correctAnswer);
|
||||
|
||||
// Create session
|
||||
const sessionId = `${userId}_${Date.now()}`;
|
||||
const expiresAt = new Date(Date.now() + config.trivia.timeoutSeconds * 1000);
|
||||
const potentialReward = BigInt(Math.floor(Number(entryFee) * config.trivia.rewardMultiplier));
|
||||
|
||||
const session: TriviaSession = {
|
||||
sessionId,
|
||||
userId,
|
||||
question,
|
||||
allAnswers: shuffled,
|
||||
correctIndex,
|
||||
expiresAt,
|
||||
entryFee,
|
||||
potentialReward,
|
||||
};
|
||||
|
||||
this.activeSessions.set(sessionId, session);
|
||||
|
||||
// Set cooldown
|
||||
const cooldownEnd = new Date(Date.now() + config.trivia.cooldownMs);
|
||||
await tx.insert(userTimers)
|
||||
.values({
|
||||
userId: BigInt(userId),
|
||||
type: TimerType.TRIVIA_COOLDOWN,
|
||||
key: 'default',
|
||||
expiresAt: cooldownEnd,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [userTimers.userId, userTimers.type, userTimers.key],
|
||||
set: { expiresAt: cooldownEnd },
|
||||
});
|
||||
|
||||
// Record dashboard event
|
||||
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||
await dashboardService.recordEvent({
|
||||
type: 'info',
|
||||
message: `${username} started a trivia game (${question.difficulty})`,
|
||||
icon: '🎯'
|
||||
});
|
||||
|
||||
return session;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session by ID
|
||||
*/
|
||||
getSession(sessionId: string): TriviaSession | undefined {
|
||||
return this.activeSessions.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit answer and process reward
|
||||
*/
|
||||
async submitAnswer(sessionId: string, userId: string, isCorrect: boolean): Promise<TriviaResult> {
|
||||
const session = this.activeSessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
throw new UserError('Session not found or expired.');
|
||||
}
|
||||
|
||||
if (session.userId !== userId) {
|
||||
throw new UserError('This is not your trivia question!');
|
||||
}
|
||||
|
||||
// Remove session to prevent double-submit
|
||||
this.activeSessions.delete(sessionId);
|
||||
|
||||
const reward = isCorrect ? session.potentialReward : 0n;
|
||||
|
||||
if (isCorrect) {
|
||||
await withTransaction(async (tx) => {
|
||||
// Award prize
|
||||
await tx.update(users)
|
||||
.set({
|
||||
balance: (await tx.query.users.findFirst({
|
||||
where: eq(users.id, BigInt(userId))
|
||||
}))!.balance! + reward,
|
||||
})
|
||||
.where(eq(users.id, BigInt(userId)));
|
||||
|
||||
// Record transaction
|
||||
await tx.insert(transactions).values({
|
||||
userId: BigInt(userId),
|
||||
amount: reward,
|
||||
type: TransactionType.TRIVIA_WIN,
|
||||
description: 'Trivia prize',
|
||||
});
|
||||
|
||||
// Record dashboard event
|
||||
const user = await tx.query.users.findFirst({
|
||||
where: eq(users.id, BigInt(userId))
|
||||
});
|
||||
|
||||
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||
await dashboardService.recordEvent({
|
||||
type: 'success',
|
||||
message: `${user?.username} won ${reward.toLocaleString()} AU from trivia!`,
|
||||
icon: '🎉'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
correct: isCorrect,
|
||||
reward,
|
||||
correctAnswer: session.question.correctAnswer,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const triviaService = new TriviaService();
|
||||
56
shared/scripts/db-backup.sh
Executable file
56
shared/scripts/db-backup.sh
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# Aurora Database Backup Script
|
||||
# =============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# Load environment variables
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
BACKUP_DIR="$PROJECT_DIR/shared/db/backups"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_FILE="$BACKUP_DIR/backup_$TIMESTAMP.sql"
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${YELLOW}💾 Starting database backup...${NC}"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
if docker ps | grep -q aurora_db; then
|
||||
# Try to dump the database
|
||||
if docker exec aurora_db pg_dump -U "${DB_USER:-auroradev}" "${DB_NAME:-auroradev}" > "$BACKUP_FILE"; then
|
||||
# Check if backup file is not empty
|
||||
if [ -s "$BACKUP_FILE" ]; then
|
||||
echo -e " ${GREEN}✓${NC} Backup successful!"
|
||||
echo -e " 📂 File: $BACKUP_FILE"
|
||||
echo -e " 📏 Size: $(du -h "$BACKUP_FILE" | cut -f1)"
|
||||
|
||||
# Keep only last 10 backups
|
||||
cd "$BACKUP_DIR"
|
||||
ls -t backup_*.sql | tail -n +11 | xargs -r rm --
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Backup created but empty. Something went wrong."
|
||||
rm -f "$BACKUP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo -e " ${RED}✗${NC} pg_dump failed."
|
||||
rm -f "$BACKUP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Database container (aurora_db) is not running!"
|
||||
exit 1
|
||||
fi
|
||||
64
shared/scripts/db-restore.sh
Executable file
64
shared/scripts/db-restore.sh
Executable file
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# Aurora Database Restore Script
|
||||
# =============================================================================
|
||||
# Usage: ./db-restore.sh [path/to/backup.sql]
|
||||
# =============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# Load environment variables
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
echo -e "${RED}Error: Please specify the backup file to restore.${NC}"
|
||||
echo "Usage: ./db-restore.sh <path-to-sql-file>"
|
||||
echo "Available backups:"
|
||||
ls -lh shared/db/backups/*.sql 2>/dev/null || echo " (No backups found in shared/db/backups)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BACKUP_FILE="$1"
|
||||
|
||||
if [ ! -f "$BACKUP_FILE" ]; then
|
||||
echo -e "${RED}Error: File not found: $BACKUP_FILE${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}⚠️ WARNING: This will OVERWRITE the current database!${NC}"
|
||||
echo -e "Target Database: ${DB_NAME:-auroradev}"
|
||||
echo -e "Backup File: $BACKUP_FILE"
|
||||
echo ""
|
||||
read -p "Are you sure you want to proceed? (y/N): " -n 1 -r
|
||||
echo ""
|
||||
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo -e "${YELLOW}♻️ Restoring database...${NC}"
|
||||
|
||||
if docker ps | grep -q aurora_db; then
|
||||
# Drop and recreate public schema to ensure clean slate, then restore
|
||||
# Note: dependent on how the dump was created. Standard pg_dump usually includes CREATE commands if configured,
|
||||
# but often it's data only or structure+data.
|
||||
# For safety, we'll just pipe the file to psql.
|
||||
|
||||
cat "$BACKUP_FILE" | docker exec -i aurora_db psql -U "${DB_USER:-auroradev}" -d "${DB_NAME:-auroradev}"
|
||||
|
||||
echo -e " ${GREEN}✓${NC} Restore complete!"
|
||||
else
|
||||
echo -e "${RED}Error: Database container (aurora_db) is not running!${NC}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Operation cancelled."
|
||||
exit 0
|
||||
fi
|
||||
16
shared/scripts/debug-db.ts
Normal file
16
shared/scripts/debug-db.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
import postgres from "postgres";
|
||||
|
||||
const connectionString = "postgresql://auroradev:auroradev123@127.0.0.1:5432/aurora_test";
|
||||
console.log("Connecting to:", connectionString);
|
||||
|
||||
const sql = postgres(connectionString);
|
||||
|
||||
try {
|
||||
const result = await sql`SELECT 1 as val`;
|
||||
console.log("Success:", result);
|
||||
await sql.end();
|
||||
} catch (e) {
|
||||
console.error("Connection failed:", e);
|
||||
await sql.end();
|
||||
}
|
||||
131
shared/scripts/deploy.sh
Normal file
131
shared/scripts/deploy.sh
Normal file
@@ -0,0 +1,131 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# Aurora Production Deployment Script
|
||||
# =============================================================================
|
||||
# Run this script to deploy the latest version of Aurora
|
||||
# Usage: bash deploy.sh
|
||||
# =============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
echo -e "${GREEN}╔══════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Aurora Deployment Script ║${NC}"
|
||||
echo -e "${GREEN}╚══════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# =============================================================================
|
||||
# Pre-flight Checks
|
||||
# =============================================================================
|
||||
echo -e "${YELLOW}[1/5] Running pre-flight checks...${NC}"
|
||||
|
||||
# Check if .env exists
|
||||
if [ ! -f .env ]; then
|
||||
echo -e "${RED}Error: .env file not found${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if Docker is running
|
||||
if ! docker info &>/dev/null; then
|
||||
echo -e "${RED}Error: Docker is not running${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e " ${GREEN}✓${NC} Pre-flight checks passed"
|
||||
|
||||
# =============================================================================
|
||||
# Backup Database (optional but recommended)
|
||||
# =============================================================================
|
||||
echo -e "${YELLOW}[2/5] Creating database backup...${NC}"
|
||||
|
||||
BACKUP_DIR="$PROJECT_DIR/shared/db/backups"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
if docker ps | grep -q aurora_db; then
|
||||
BACKUP_FILE="$BACKUP_DIR/backup_$(date +%Y%m%d_%H%M%S).sql"
|
||||
docker exec aurora_db pg_dump -U "${DB_USER:-auroradev}" "${DB_NAME:-auroradev}" > "$BACKUP_FILE" 2>/dev/null || true
|
||||
if [ -f "$BACKUP_FILE" ] && [ -s "$BACKUP_FILE" ]; then
|
||||
echo -e " ${GREEN}✓${NC} Database backed up to: $BACKUP_FILE"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} Database backup skipped (container not running or empty)"
|
||||
rm -f "$BACKUP_FILE"
|
||||
fi
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} Database backup skipped (container not running)"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Pull Latest Code (if using git)
|
||||
# =============================================================================
|
||||
echo -e "${YELLOW}[3/5] Pulling latest code...${NC}"
|
||||
|
||||
if [ -d .git ]; then
|
||||
git pull origin main 2>/dev/null || git pull origin master 2>/dev/null || echo " Skipping git pull"
|
||||
echo -e " ${GREEN}✓${NC} Code updated"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} Not a git repository, skipping pull"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Build and Deploy
|
||||
# =============================================================================
|
||||
echo -e "${YELLOW}[4/5] Building and deploying containers...${NC}"
|
||||
|
||||
# Build the new image
|
||||
docker compose -f docker-compose.prod.yml build --no-cache
|
||||
|
||||
# Stop and remove old containers, start new ones
|
||||
docker compose -f docker-compose.prod.yml down
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
|
||||
echo -e " ${GREEN}✓${NC} Containers deployed"
|
||||
|
||||
# =============================================================================
|
||||
# Health Check
|
||||
# =============================================================================
|
||||
echo -e "${YELLOW}[5/5] Waiting for health checks...${NC}"
|
||||
|
||||
sleep 10
|
||||
|
||||
# Check container status
|
||||
if docker ps | grep -q "aurora_app.*healthy"; then
|
||||
echo -e " ${GREEN}✓${NC} aurora_app is healthy"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} aurora_app health check pending (may take up to 60s)"
|
||||
fi
|
||||
|
||||
if docker ps | grep -q "aurora_db.*healthy"; then
|
||||
echo -e " ${GREEN}✓${NC} aurora_db is healthy"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} aurora_db health check pending"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Cleanup
|
||||
# =============================================================================
|
||||
echo ""
|
||||
echo -e "${YELLOW}Cleaning up old Docker images...${NC}"
|
||||
docker image prune -f
|
||||
|
||||
# =============================================================================
|
||||
# Summary
|
||||
# =============================================================================
|
||||
echo ""
|
||||
echo -e "${GREEN}╔══════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Deployment Complete! 🚀 ║${NC}"
|
||||
echo -e "${GREEN}╚══════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "Container Status:"
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep aurora
|
||||
echo ""
|
||||
echo -e "View logs with: ${YELLOW}docker logs -f aurora_app${NC}"
|
||||
98
shared/scripts/docker-cleanup.sh
Executable file
98
shared/scripts/docker-cleanup.sh
Executable file
@@ -0,0 +1,98 @@
|
||||
#!/bin/bash
|
||||
# Cleanup script for Docker resources
|
||||
# Use: ./shared/scripts/docker-cleanup.sh
|
||||
# Use: ./shared/scripts/docker-cleanup.sh --full (for aggressive cleanup)
|
||||
|
||||
set -e
|
||||
|
||||
echo "🧹 Aurora Docker Cleanup"
|
||||
echo "========================"
|
||||
echo ""
|
||||
|
||||
# Show current disk usage first
|
||||
echo "📊 Current Docker disk usage:"
|
||||
docker system df
|
||||
echo ""
|
||||
|
||||
# Stop running containers for this project
|
||||
echo "📦 Stopping Aurora containers..."
|
||||
docker compose down 2>/dev/null || true
|
||||
|
||||
# Remove dangling images (untagged images from failed builds)
|
||||
echo ""
|
||||
echo "🗑️ Removing dangling images..."
|
||||
docker image prune -f
|
||||
|
||||
# Check for --full flag for aggressive cleanup
|
||||
if [[ "$1" == "--full" ]]; then
|
||||
echo ""
|
||||
echo "🔥 Full cleanup mode - removing all unused Docker resources..."
|
||||
|
||||
# Remove all unused images, not just dangling ones
|
||||
echo " → Removing unused images..."
|
||||
docker image prune -a -f
|
||||
|
||||
# Remove build cache
|
||||
echo " → Removing build cache..."
|
||||
docker builder prune -a -f
|
||||
|
||||
# Remove unused volumes (except named ones we need)
|
||||
echo " → Removing unused volumes..."
|
||||
docker volume prune -f
|
||||
|
||||
# Remove unused networks
|
||||
echo " → Removing unused networks..."
|
||||
docker network prune -f
|
||||
|
||||
# Remove node_modules volumes
|
||||
echo " → Removing node_modules volumes..."
|
||||
docker volume rm aurora_app_node_modules aurora_web_node_modules 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "✅ Full cleanup complete!"
|
||||
else
|
||||
# Interactive mode
|
||||
echo ""
|
||||
read -p "🔧 Remove Docker build cache? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
docker builder prune -f
|
||||
echo "✓ Build cache cleared"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
read -p "🖼️ Remove ALL unused images (not just dangling)? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
docker image prune -a -f
|
||||
echo "✓ Unused images removed"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
read -p "📁 Remove node_modules volumes? (forces fresh install) (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
docker volume rm aurora_app_node_modules aurora_web_node_modules 2>/dev/null || true
|
||||
echo "✓ Node modules volumes removed"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
read -p "🧨 Run full system prune (removes ALL unused data)? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
docker system prune -a -f --volumes
|
||||
echo "✓ Full system prune complete"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ Cleanup complete!"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "📊 Docker disk usage after cleanup:"
|
||||
docker system df
|
||||
echo ""
|
||||
echo "💡 Tip: Check container logs with: sudo du -sh /var/lib/docker/containers/*/*.log"
|
||||
echo "💡 Tip: Truncate logs with: sudo truncate -s 0 /var/lib/docker/containers/*/*.log"
|
||||
echo ""
|
||||
echo "Run 'docker compose up --build' to rebuild"
|
||||
38
shared/scripts/logs.sh
Executable file
38
shared/scripts/logs.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# Aurora Log Viewer
|
||||
# =============================================================================
|
||||
# Usage: ./logs.sh [app|db|all] [-f]
|
||||
# Default: app container, follow mode
|
||||
# =============================================================================
|
||||
|
||||
SERVICE=${1:-app}
|
||||
FOLLOW="-f"
|
||||
|
||||
if [[ "$1" == "-f" ]]; then
|
||||
SERVICE="app"
|
||||
FOLLOW="-f"
|
||||
elif [[ "$2" == "-f" ]]; then
|
||||
FOLLOW="-f"
|
||||
elif [[ "$2" == "--no-follow" ]]; then
|
||||
FOLLOW=""
|
||||
fi
|
||||
|
||||
echo "📋 Fetching logs for service: $SERVICE..."
|
||||
|
||||
case $SERVICE in
|
||||
app)
|
||||
docker compose logs $FOLLOW app
|
||||
;;
|
||||
db)
|
||||
docker compose logs $FOLLOW db
|
||||
;;
|
||||
all)
|
||||
docker compose logs $FOLLOW
|
||||
;;
|
||||
*)
|
||||
echo "Unknown service: $SERVICE"
|
||||
echo "Usage: ./logs.sh [app|db|all] [-f]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Load environment variables
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [ -z "$VPS_HOST" ] || [ -z "$VPS_USER" ]; then
|
||||
echo "Error: VPS_HOST and VPS_USER must be set in .env"
|
||||
echo "Please add them to your .env file:"
|
||||
echo "VPS_USER=your-username"
|
||||
echo "VPS_HOST=your-ip-address"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DASHBOARD_PORT=${DASHBOARD_PORT:-3000}
|
||||
|
||||
echo "🌐 Establishing secure tunnel to Aurora Dashboard..."
|
||||
echo "📊 Dashboard will be accessible at: http://localhost:$DASHBOARD_PORT"
|
||||
echo "Press Ctrl+C to stop the connection."
|
||||
echo ""
|
||||
|
||||
# Function to open browser (cross-platform)
|
||||
open_browser() {
|
||||
sleep 2
|
||||
if command -v open &> /dev/null; then
|
||||
open "http://localhost:$DASHBOARD_PORT"
|
||||
elif command -v xdg-open &> /dev/null; then
|
||||
xdg-open "http://localhost:$DASHBOARD_PORT"
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if autossh is available
|
||||
if command -v autossh &> /dev/null; then
|
||||
SSH_CMD="autossh -M 0 -o ServerAliveInterval=30 -o ServerAliveCountMax=3"
|
||||
else
|
||||
SSH_CMD="ssh -o ServerAliveInterval=30 -o ServerAliveCountMax=3"
|
||||
fi
|
||||
|
||||
open_browser &
|
||||
$SSH_CMD -N -L $DASHBOARD_PORT:127.0.0.1:$DASHBOARD_PORT $VPS_USER@$VPS_HOST
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Load environment variables
|
||||
if [ -f .env ]; then
|
||||
# export $(grep -v '^#' .env | xargs) # Use a safer way if possible, but for simple .env this often works.
|
||||
# Better way to source .env without exporting everything to shell if we just want to use them in script:
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [ -z "$VPS_HOST" ] || [ -z "$VPS_USER" ]; then
|
||||
echo "Error: VPS_HOST and VPS_USER must be set in .env"
|
||||
echo "Please add them to your .env file:"
|
||||
echo "VPS_USER=your-username"
|
||||
echo "VPS_HOST=your-ip-address"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔮 Establishing secure tunnel to Drizzle Studio..."
|
||||
echo "📚 Studio will be accessible at: https://local.drizzle.studio"
|
||||
echo "Press Ctrl+C to stop the connection."
|
||||
|
||||
# -N means "Do not execute a remote command". -L is for local port forwarding.
|
||||
ssh -N -L 4983:127.0.0.1:4983 $VPS_USER@$VPS_HOST
|
||||
160
shared/scripts/setup-server.sh
Normal file
160
shared/scripts/setup-server.sh
Normal file
@@ -0,0 +1,160 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# Server Setup Script for Aurora Production Deployment
|
||||
# =============================================================================
|
||||
# Run this script ONCE on a fresh server to configure security settings.
|
||||
# Usage: sudo bash setup-server.sh
|
||||
# =============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}╔══════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Aurora Server Security Setup Script ║${NC}"
|
||||
echo -e "${GREEN}╚══════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${RED}Error: Please run as root (sudo)${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 1. Create Deploy User
|
||||
# =============================================================================
|
||||
echo -e "${YELLOW}[1/5] Creating deploy user...${NC}"
|
||||
|
||||
DEPLOY_USER="deploy"
|
||||
|
||||
if id "$DEPLOY_USER" &>/dev/null; then
|
||||
echo -e " User '$DEPLOY_USER' already exists, skipping..."
|
||||
else
|
||||
adduser --disabled-password --gecos "" $DEPLOY_USER
|
||||
echo -e " ${GREEN}✓${NC} Created user '$DEPLOY_USER'"
|
||||
fi
|
||||
|
||||
# Add to docker group
|
||||
usermod -aG docker $DEPLOY_USER 2>/dev/null || groupadd docker && usermod -aG docker $DEPLOY_USER
|
||||
echo -e " ${GREEN}✓${NC} Added '$DEPLOY_USER' to docker group"
|
||||
|
||||
# Add to sudo group (optional - remove if you don't want sudo access)
|
||||
usermod -aG sudo $DEPLOY_USER
|
||||
echo -e " ${GREEN}✓${NC} Added '$DEPLOY_USER' to sudo group"
|
||||
|
||||
# Copy SSH keys from root to deploy user
|
||||
if [ -d /root/.ssh ]; then
|
||||
mkdir -p /home/$DEPLOY_USER/.ssh
|
||||
cp /root/.ssh/authorized_keys /home/$DEPLOY_USER/.ssh/ 2>/dev/null || true
|
||||
chown -R $DEPLOY_USER:$DEPLOY_USER /home/$DEPLOY_USER/.ssh
|
||||
chmod 700 /home/$DEPLOY_USER/.ssh
|
||||
chmod 600 /home/$DEPLOY_USER/.ssh/authorized_keys 2>/dev/null || true
|
||||
echo -e " ${GREEN}✓${NC} Copied SSH keys to '$DEPLOY_USER'"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 2. Configure UFW Firewall
|
||||
# =============================================================================
|
||||
echo -e "${YELLOW}[2/5] Configuring UFW firewall...${NC}"
|
||||
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq ufw
|
||||
|
||||
ufw default deny incoming
|
||||
ufw default allow outgoing
|
||||
ufw allow ssh
|
||||
# Add more rules as needed:
|
||||
# ufw allow 80/tcp # HTTP
|
||||
# ufw allow 443/tcp # HTTPS
|
||||
|
||||
# Enable UFW (non-interactive)
|
||||
echo "y" | ufw enable
|
||||
echo -e " ${GREEN}✓${NC} UFW firewall enabled and configured"
|
||||
|
||||
# =============================================================================
|
||||
# 3. Install and Configure Fail2ban
|
||||
# =============================================================================
|
||||
echo -e "${YELLOW}[3/5] Installing fail2ban...${NC}"
|
||||
|
||||
apt-get install -y -qq fail2ban
|
||||
|
||||
# Create local jail configuration
|
||||
cat > /etc/fail2ban/jail.local << 'EOF'
|
||||
[DEFAULT]
|
||||
bantime = 1h
|
||||
findtime = 10m
|
||||
maxretry = 5
|
||||
|
||||
[sshd]
|
||||
enabled = true
|
||||
port = ssh
|
||||
filter = sshd
|
||||
logpath = /var/log/auth.log
|
||||
maxretry = 3
|
||||
bantime = 24h
|
||||
EOF
|
||||
|
||||
systemctl enable fail2ban
|
||||
systemctl restart fail2ban
|
||||
echo -e " ${GREEN}✓${NC} Fail2ban installed and configured"
|
||||
|
||||
# =============================================================================
|
||||
# 4. Harden SSH Configuration
|
||||
# =============================================================================
|
||||
echo -e "${YELLOW}[4/5] Hardening SSH configuration...${NC}"
|
||||
|
||||
SSHD_CONFIG="/etc/ssh/sshd_config"
|
||||
|
||||
# Backup original config
|
||||
cp $SSHD_CONFIG ${SSHD_CONFIG}.backup
|
||||
|
||||
# Apply hardening settings
|
||||
sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' $SSHD_CONFIG
|
||||
sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' $SSHD_CONFIG
|
||||
sed -i 's/^#\?PubkeyAuthentication.*/PubkeyAuthentication yes/' $SSHD_CONFIG
|
||||
sed -i 's/^#\?X11Forwarding.*/X11Forwarding no/' $SSHD_CONFIG
|
||||
sed -i 's/^#\?MaxAuthTries.*/MaxAuthTries 3/' $SSHD_CONFIG
|
||||
|
||||
# Validate SSH config before restarting
|
||||
if sshd -t; then
|
||||
systemctl reload sshd
|
||||
echo -e " ${GREEN}✓${NC} SSH hardened (root login disabled, password auth disabled)"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} SSH config validation failed, restoring backup..."
|
||||
cp ${SSHD_CONFIG}.backup $SSHD_CONFIG
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 5. System Updates
|
||||
# =============================================================================
|
||||
echo -e "${YELLOW}[5/5] Installing system updates...${NC}"
|
||||
|
||||
apt-get upgrade -y -qq
|
||||
apt-get autoremove -y -qq
|
||||
echo -e " ${GREEN}✓${NC} System updated"
|
||||
|
||||
# =============================================================================
|
||||
# Summary
|
||||
# =============================================================================
|
||||
echo ""
|
||||
echo -e "${GREEN}╔══════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Setup Complete! ║${NC}"
|
||||
echo -e "${GREEN}╚══════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "Next steps:"
|
||||
echo -e " 1. Update your local .env file:"
|
||||
echo -e " ${YELLOW}VPS_USER=deploy${NC}"
|
||||
echo -e ""
|
||||
echo -e " 2. Test SSH access with the new user:"
|
||||
echo -e " ${YELLOW}ssh deploy@<your-server-ip>${NC}"
|
||||
echo -e ""
|
||||
echo -e " 3. Deploy the application:"
|
||||
echo -e " ${YELLOW}cd /home/deploy/Aurora && docker compose -f docker-compose.prod.yml up -d${NC}"
|
||||
echo ""
|
||||
echo -e "${RED}⚠️ IMPORTANT: Test SSH access with 'deploy' user BEFORE logging out!${NC}"
|
||||
echo -e "${RED} Keep this root session open until you confirm 'deploy' user works.${NC}"
|
||||
108
shared/scripts/simulate-ci.sh
Executable file
108
shared/scripts/simulate-ci.sh
Executable file
@@ -0,0 +1,108 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
DB_CONTAINER_NAME="aurora_ci_test_db"
|
||||
DB_PORT="5433"
|
||||
DB_USER="postgres"
|
||||
DB_PASS="postgres"
|
||||
DB_NAME="aurora_test"
|
||||
|
||||
echo "🚀 Starting CI Simulation..."
|
||||
|
||||
# Cleanup previous run if exists
|
||||
if docker ps -a --format '{{.Names}}' | grep -q "^${DB_CONTAINER_NAME}$"; then
|
||||
echo "🧹 Cleaning up old container..."
|
||||
docker rm -f $DB_CONTAINER_NAME
|
||||
fi
|
||||
|
||||
# 1. Start Postgres Service
|
||||
echo "🐳 Starting temporary PostgreSQL container on port $DB_PORT..."
|
||||
docker run -d \
|
||||
--name $DB_CONTAINER_NAME \
|
||||
-e POSTGRES_USER=$DB_USER \
|
||||
-e POSTGRES_PASSWORD=$DB_PASS \
|
||||
-e POSTGRES_DB=$DB_NAME \
|
||||
-p $DB_PORT:5432 \
|
||||
postgres:17-alpine
|
||||
|
||||
echo "⏳ Waiting for database to be ready..."
|
||||
# Wait for healthy
|
||||
for i in {1..30}; do
|
||||
if docker exec $DB_CONTAINER_NAME pg_isready -U $DB_USER > /dev/null 2>&1; then
|
||||
echo "✅ Database is ready!"
|
||||
break
|
||||
fi
|
||||
echo " ...waiting ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Define connection string
|
||||
export DATABASE_URL="postgresql://$DB_USER:$DB_PASS@127.0.0.1:$DB_PORT/$DB_NAME"
|
||||
|
||||
# 2. Create Config File (Match deploy.yml)
|
||||
echo "📝 Creating shared/config/config.json..."
|
||||
mkdir -p shared/config
|
||||
cat <<EOF > shared/config/config.json
|
||||
{
|
||||
"leveling": { "base": 100, "exponent": 2.5, "chat": { "cooldownMs": 60000, "minXp": 15, "maxXp": 25 } },
|
||||
"economy": {
|
||||
"daily": { "amount": "100", "streakBonus": "10", "weeklyBonus": "50", "cooldownMs": 86400000 },
|
||||
"transfers": { "allowSelfTransfer": false, "minAmount": "1" },
|
||||
"exam": { "multMin": 0.05, "multMax": 0.03 }
|
||||
},
|
||||
"inventory": { "maxStackSize": "99", "maxSlots": 50 },
|
||||
"commands": {},
|
||||
"lootdrop": {
|
||||
"activityWindowMs": 120000, "minMessages": 1, "spawnChance": 1, "cooldownMs": 3000,
|
||||
"reward": { "min": 40, "max": 150, "currency": "Astral Units" }
|
||||
},
|
||||
"studentRole": "123", "visitorRole": "456", "colorRoles": [],
|
||||
"moderation": {
|
||||
"prune": { "maxAmount": 100, "confirmThreshold": 50, "batchSize": 100, "batchDelayMs": 1000 },
|
||||
"cases": { "dmOnWarn": false }
|
||||
},
|
||||
"trivia": {
|
||||
"entryFee": "50", "rewardMultiplier": 1.5, "timeoutSeconds": 30, "cooldownMs": 60000,
|
||||
"categories": [], "difficulty": "random"
|
||||
},
|
||||
"system": {}
|
||||
}
|
||||
EOF
|
||||
|
||||
# 3. Setup Database Schema
|
||||
echo "📜 Pushing schema to test database..."
|
||||
bun run db:push:local
|
||||
|
||||
# 4. Export Test Env Vars
|
||||
export DISCORD_BOT_TOKEN="test_token"
|
||||
export DISCORD_CLIENT_ID="123456789"
|
||||
export DISCORD_GUILD_ID="123456789"
|
||||
export ADMIN_TOKEN="admin_token_123"
|
||||
export LOG_LEVEL="error"
|
||||
|
||||
# 5. Run Tests
|
||||
echo "🧪 Running Tests..."
|
||||
if [ -n "$1" ]; then
|
||||
echo "Running specific test: $1"
|
||||
if bun test "$1"; then
|
||||
echo "✅ Specific Test Passed!"
|
||||
EXIT_CODE=0
|
||||
else
|
||||
echo "❌ Specific Test Failed!"
|
||||
EXIT_CODE=1
|
||||
fi
|
||||
else
|
||||
if bash shared/scripts/test-sequential.sh; then
|
||||
echo "✅ CI Simulation Passed!"
|
||||
EXIT_CODE=0
|
||||
else
|
||||
echo "❌ CI Simulation Failed!"
|
||||
EXIT_CODE=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 6. Cleanup
|
||||
echo "🧹 Cleaning up container..."
|
||||
docker rm -f $DB_CONTAINER_NAME
|
||||
|
||||
exit $EXIT_CODE
|
||||
36
shared/scripts/test-sequential.sh
Executable file
36
shared/scripts/test-sequential.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🔍 Finding test files..."
|
||||
TEST_FILES=$(find . -name "*.test.ts" -not -path "*/node_modules/*")
|
||||
|
||||
if [ -z "$TEST_FILES" ]; then
|
||||
echo "⚠️ No test files found!"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "🧪 Running tests sequentially..."
|
||||
FAILED=0
|
||||
|
||||
for FILE in $TEST_FILES; do
|
||||
echo "---------------------------------------------------"
|
||||
echo "running: $FILE"
|
||||
if bun test "$FILE"; then
|
||||
echo "✅ passed: $FILE"
|
||||
else
|
||||
echo "❌ failed: $FILE"
|
||||
FAILED=1
|
||||
# Fail fast
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $FAILED -eq 0 ]; then
|
||||
echo "---------------------------------------------------"
|
||||
echo "✅ All tests passed!"
|
||||
exit 0
|
||||
else
|
||||
echo "---------------------------------------------------"
|
||||
echo "❌ Some tests failed."
|
||||
exit 1
|
||||
fi
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user