98 Commits

Author SHA1 Message Date
syntaxbullet
f9dafeac3b Merge branch 'main' of https://git.ayau.me/syntaxbullet/discord-rpg-concept
Some checks failed
Deploy to Production / test (push) Failing after 1m27s
Deploy to Production / build (push) Has been skipped
Deploy to Production / deploy (push) Has been skipped
2026-01-30 13:44:04 +01:00
syntaxbullet
1a2bbb011c feat: Introduce production Docker and CI/CD setup, removing internal documentation and agent workflows. 2026-01-30 13:43:59 +01:00
syntaxbullet
2ead35789d fix: prevent studio service from inheriting port 3000 from app 2026-01-23 13:58:37 +01:00
syntaxbullet
c1da71227d chore: update cleanup scripts 2026-01-23 13:47:48 +01:00
syntaxbullet
17e636c4e5 feat: Overhaul Docker infrastructure with multi-stage builds, add a cleanup script, and refactor the update service to combine update and requirement checks. 2026-01-17 16:20:33 +01:00
syntaxbullet
d7543d9f48 feat: (web) add item route 2026-01-17 13:11:50 +01:00
syntaxbullet
afe82c449b feat: add web asset rebuilding to update command and consolidate post-restart messages
- Detect web/src/** changes and trigger frontend rebuild after updates
- Add buildWebAssets flag to RestartContext and needsWebBuild to UpdateCheckResult
- Consolidate post-restart progress into single editable message
- Delete progress message after completion, show only final result
2026-01-16 16:37:11 +01:00
syntaxbullet
3c1334b30e fix: update sub-navigation item colors for active, hover, and default states 2026-01-16 16:27:23 +01:00
syntaxbullet
58f261562a feat: Implement an admin quest management table, enhance toast notifications with descriptions, and add new agent documentation. 2026-01-16 15:58:48 +01:00
syntaxbullet
4ecbffd617 refactor: replace hardcoded SVGs with lucide-react icons in quest-table 2026-01-16 15:27:15 +01:00
syntaxbullet
5491551544 fix: (web) prevent flickering during refresh
- Track isInitialLoading separately from isRefreshing
- Only show skeleton on initial page load (when quests is empty)
- During refresh, keep existing content visible
- Spinning refresh icon indicates refresh in progress without clearing table
2026-01-16 15:22:28 +01:00
syntaxbullet
7d658bbef9 fix: (web) fix refresh icon spinning indefinitely
- Remove redundant isRefreshing state
- Icon spin is controlled by isLoading prop from parent
- Parent correctly manages loading state during fetch
2026-01-16 15:20:36 +01:00
syntaxbullet
d117bcb697 fix: (web) restore quest table loading logic
- Simplify component by removing complex state management
- Show skeleton only during initial load, content otherwise
- Keep refresh icon spin during manual refresh
2026-01-16 15:18:51 +01:00
syntaxbullet
94e332ba57 fix: (web) improve quest table refresh UX
- Keep card visible during refresh to prevent flicker
- Add smooth animations when content loads
- Spin refresh icon independently from skeleton
- Show skeleton in place without replacing entire card
2026-01-16 15:16:48 +01:00
syntaxbullet
3ef9773990 feat: (web) add quest table component for admin quests page
- Add getAllQuests() method to quest.service.ts
- Add GET /api/quests endpoint to server.ts
- Create QuestTable component with data display, formatting, and states
- Update AdminQuests.tsx to fetch and display quests above the form
- Add onSuccess callback to QuestForm for refresh handling
2026-01-16 15:12:41 +01:00
syntaxbullet
d243a11bd3 feat: (docs) add main.md 2026-01-16 13:34:35 +01:00
syntaxbullet
47ce0f12e6 chore: remove old documentation. 2026-01-16 13:18:54 +01:00
syntaxbullet
f2caa1a3ee chore: replace tw-gradient classes with canonical shortened -linear classnames 2026-01-16 12:59:32 +01:00
syntaxbullet
2a72beb0ef feat: Implement new settings pages and refactor application layout and navigation with new components and hooks. 2026-01-16 12:49:17 +01:00
syntaxbullet
2f73f38877 feat: Add web admin page for quest management and refactor Discord bot's quest UI to use new components. 2026-01-15 17:21:49 +01:00
syntaxbullet
9e5c6b5ac3 feat: Implement interactive quest command allowing users to view active/available quests and accept new ones. 2026-01-15 15:30:01 +01:00
syntaxbullet
eb108695d3 feat: Implement flexible quest event matching to allow generic triggers to match specific event instances. 2026-01-15 15:22:20 +01:00
syntaxbullet
7d541825d8 feat: Update quest event triggers to include item IDs for granular tracking. 2026-01-15 15:09:37 +01:00
syntaxbullet
52f8ab11f0 feat: Implement quest event handling and integrate it into leveling, economy, and inventory services. 2026-01-15 15:04:50 +01:00
syntaxbullet
f8436e9755 chore: (agent) remove tickets and skills 2026-01-15 11:13:37 +01:00
syntaxbullet
194a032c7f chore(cleanup): remove completed tickets 2026-01-14 18:10:31 +01:00
syntaxbullet
94a5a183d0 feat(economy): refactor exam command to use ExamService with status-based flow and full test coverage 2026-01-14 18:10:13 +01:00
syntaxbullet
c7730b9355 refactor: migrate web server to centralized logger 2026-01-14 17:58:28 +01:00
syntaxbullet
1e20a5a7a0 refactor: migrate bot handlers to centralized logger 2026-01-14 17:58:28 +01:00
syntaxbullet
54944283a3 feat: implement centralized logger with file persistence 2026-01-14 17:58:28 +01:00
syntaxbullet
f79ee6fbc7 refactor: remove completed ticket file 2026-01-14 16:27:49 +01:00
syntaxbullet
915f1bc4ad fix(economy): improve daily cooldown message and consolidate UserError class 2026-01-14 16:26:27 +01:00
syntaxbullet
4af2690bab feat: implement branded discord embeds and versioning 2026-01-14 16:10:23 +01:00
syntaxbullet
6e57ab07e4 chore: update gitiignore 2026-01-14 15:12:51 +01:00
syntaxbullet
3a620a84c5 feat: add trivia category selection and sync trivia fixes 2026-01-11 16:08:11 +01:00
syntaxbullet
7d68652ea5 fix: fix potential issues with trivia command 2026-01-11 15:00:10 +01:00
syntaxbullet
35bd1f58dd feat: trivia command! 2026-01-11 14:37:17 +01:00
syntaxbullet
1cd3dbcd72 agent: update agent workflows 2026-01-09 22:04:40 +01:00
syntaxbullet
c97249f2ca docs: update README with dashboard architecture and ssh tunnel guide 2026-01-09 22:02:09 +01:00
syntaxbullet
0d923491b5 feat: (ui) settings drawers 2026-01-09 19:28:14 +01:00
syntaxbullet
d870ef69d5 feat: (ui) leaderboards 2026-01-09 16:45:36 +01:00
syntaxbullet
682e9d208e feat: more stat components 2026-01-09 16:18:52 +01:00
syntaxbullet
4a691ac71d feat: (ui) first dynamic data 2026-01-09 15:22:13 +01:00
syntaxbullet
1b84dbd36d feat: (ui) new design 2026-01-09 15:12:35 +01:00
syntaxbullet
a5b8d922e3 feat(web): implement full activity page with charts and logs 2026-01-08 23:20:00 +01:00
syntaxbullet
238d9a8803 refactor(web): enhance ui visual polish and ux
- Replace native selects with Shadcn UI Select in Settings
- Increase ActivityChart height for better visibility
- specific Economy Overview card height to fill column
- Add hover/active scale animations to sidebar items
2026-01-08 23:10:14 +01:00
syntaxbullet
713ea07040 feat(ui): use shadcn switch for toggles and remove sidebar user footer 2026-01-08 23:00:44 +01:00
syntaxbullet
bea6c33024 feat(settings): group commands by category in system tab 2026-01-08 22:55:40 +01:00
syntaxbullet
8fe300c8a2 feat(web): add toast notifications for settings save status 2026-01-08 22:47:31 +01:00
syntaxbullet
9caa95a0d8 feat(settings): support toggling disabled commands and auto-reload bot on save 2026-01-08 22:44:48 +01:00
syntaxbullet
c6fd23b5fa feat(dashboard): implement bot settings page with partial updates and serialization fixes 2026-01-08 22:35:46 +01:00
syntaxbullet
d46434de18 feat(dashboard): expand stats & remove admin token auth 2026-01-08 22:14:13 +01:00
syntaxbullet
cf4c28e1df fix : 404 error fix 2026-01-08 21:45:53 +01:00
syntaxbullet
39e405afde chore: polish analytics API logging and typing 2026-01-08 21:39:53 +01:00
syntaxbullet
6763e3c543 fix: address code review findings for analytics and security 2026-01-08 21:39:01 +01:00
syntaxbullet
11e07a0068 feat: implement visual analytics and activity charts 2026-01-08 21:36:19 +01:00
syntaxbullet
5d2d4bb0c6 refactor: improve type safety and remove forced casts in dashboard service 2026-01-08 21:31:40 +01:00
syntaxbullet
19206b5cc7 fix: address security review findings, implement real cache clearing, and fix lifecycle promises 2026-01-08 21:29:09 +01:00
syntaxbullet
0f6cce9b6e feat: implement administrative control panel with real-time bot actions 2026-01-08 21:19:16 +01:00
syntaxbullet
3f3a6c88e8 fix(dash): resolve test regressions, await promises, and improve TypeScript strictness 2026-01-08 21:12:41 +01:00
syntaxbullet
8253de9f73 fix(dash): address safety constraints, validation, and test quality issues 2026-01-08 21:08:47 +01:00
syntaxbullet
1251df286e feat: implement real-time dashboard updates via WebSockets 2026-01-08 21:01:33 +01:00
syntaxbullet
fff90804c0 feat(dash): Revamp dashboard UI with glassmorphism and real bot data 2026-01-08 20:58:57 +01:00
syntaxbullet
8ebaf7b4ee docs: update ticket status to In Review with implementation notes 2026-01-08 18:51:58 +01:00
syntaxbullet
17cb70ec00 feat: integrate real data into dashboard
- Created dashboard service with DB queries for users, economy, events
- Added client stats provider with 30s caching for Discord metrics
- Implemented /api/stats endpoint aggregating all dashboard data
- Created useDashboardStats React hook with auto-refresh
- Updated Dashboard.tsx to display real data with loading/error states
- Added comprehensive test coverage (11 tests passing)
- Replaced all mock values with live Discord and database metrics
2026-01-08 18:50:44 +01:00
syntaxbullet
a207d511be docs: clarify drizzle studio access via proxy URL 2026-01-08 18:20:27 +01:00
syntaxbullet
cf4f180124 fix: add web network to studio for port publishing 2026-01-08 18:17:27 +01:00
syntaxbullet
5df1396b3f chore: update docker compose 2026-01-08 18:12:39 +01:00
syntaxbullet
daad7be01c chore: attempt fixing drizzle studio 2026-01-08 18:04:40 +01:00
syntaxbullet
05f27ca604 refactor: fix frontend 2026-01-08 17:01:36 +01:00
syntaxbullet
d37059d50f chore: remove tickets from future commits 2026-01-08 16:45:49 +01:00
syntaxbullet
caafe6b34d refactor: update graphics paths 2026-01-08 16:42:14 +01:00
syntaxbullet
017f5ad818 refactor: fix stale imports 2026-01-08 16:39:34 +01:00
syntaxbullet
f92415b89c refactor: move drizzle to shared 2026-01-08 16:29:31 +01:00
syntaxbullet
3f028eb76a refactor: consolidate config loading 2026-01-08 16:21:25 +01:00
syntaxbullet
2b641c952d refactor: move config loading to shared directory 2026-01-08 16:15:55 +01:00
syntaxbullet
88b266f81b refactor: initial moves 2026-01-08 16:09:26 +01:00
syntaxbullet
53a2f1ff0c chore: combine processes 2026-01-08 15:13:09 +01:00
syntaxbullet
dc15212ecf web: mock dashboard 2026-01-08 14:49:59 +01:00
syntaxbullet
99e847175e chore: remove frontend boilerplate 2026-01-08 14:26:16 +01:00
syntaxbullet
b2c7fa6e83 feat: improvements to update command 2026-01-08 14:13:24 +01:00
syntaxbullet
9e7f18787b feat: improvements to web dashboard 2026-01-08 13:56:25 +01:00
47507dd65a Merge pull request 'added react app' (#4) from HotPlate/discord-rpg-concept:reactApp into main
Reviewed-on: syntaxbullet/AuroraBot-discord#4
2026-01-08 11:51:18 +00:00
Vraj Ved
e6f94c3e71 added react app 2026-01-08 17:15:28 +05:30
syntaxbullet
66af870aa9 fix: make dashboard locally accessible only 2026-01-07 14:33:19 +01:00
syntaxbullet
8047bce755 feat: add bot action controls and real-time vital statistics to the web dashboard 2026-01-07 14:26:37 +01:00
syntaxbullet
9804456257 docs: Remove completed and draft feature tickets from the tickets directory. 2026-01-07 13:49:04 +01:00
syntaxbullet
259b8d6875 feat: replace mock dashboard data with live telemetry 2026-01-07 13:47:02 +01:00
syntaxbullet
a2cb684b71 Merge branch 'feat/web-interface-expansion-mockup' into main 2026-01-07 13:39:41 +01:00
syntaxbullet
9c2098bc46 fix(test): use dynamic port for websocket tests 2026-01-07 13:37:21 +01:00
syntaxbullet
618d973863 feat: expansion of web dashboard with live activity feed and metrics 2026-01-07 13:34:29 +01:00
syntaxbullet
63f55b6dfd feat: implement dashboard mockup and route 2026-01-07 13:29:06 +01:00
syntaxbullet
ac4025e179 feat: implement websocket realtime data streaming 2026-01-07 13:25:41 +01:00
syntaxbullet
ff23f22337 feat: move status to footer and clean up home page 2026-01-07 13:21:36 +01:00
syntaxbullet
292991c605 feat: responsive mobile layout and touch optimizations 2026-01-07 13:08:02 +01:00
syntaxbullet
4640cd11a7 feat: ux enhancements (animations, dynamic backgrounds, micro-interactions) 2026-01-07 13:05:42 +01:00
syntaxbullet
43a003f641 feat: visual design system overhaul (HSL palette, fonts, components) 2026-01-07 13:04:40 +01:00
syntaxbullet
6f4426e49d feat: save progress on web server foundation and add new tickets 2026-01-07 13:02:36 +01:00
273 changed files with 15700 additions and 2617 deletions

View File

@@ -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: ...

View File

@@ -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: ...

View File

@@ -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
View 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

View File

@@ -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_USER=aurora
DB_PASSWORD=aurora DB_PASSWORD=aurora
DB_NAME=aurora DB_NAME=aurora
DB_PORT=5432 DB_PORT=5432
DB_HOST=db 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_BOT_TOKEN=your-discord-bot-token
DISCORD_CLIENT_ID=your-discord-client-id DISCORD_CLIENT_ID=your-discord-client-id
DISCORD_GUILD_ID=your-discord-guild-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 VPS_HOST=your-vps-ip

38
.env.prod.example Normal file
View 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

136
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,136 @@
# 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
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
- name: Run Tests
run: bun test
# ==========================================================================
# Build Job
# ==========================================================================
build:
runs-on: ubuntu-latest
needs: test
permissions:
contents: read
packages: write
outputs:
image_tag: ${{ steps.meta.outputs.tags }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=
type=raw,value=latest
- name: Build and Push Docker Image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.prod
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ==========================================================================
# Deploy Job
# ==========================================================================
deploy:
runs-on: ubuntu-latest
needs: build
environment: production
steps:
- name: Deploy to Production Server
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd ~/Aurora
# Pull latest code
git pull origin main
# Pull latest Docker image
docker compose -f docker-compose.prod.yml pull 2>/dev/null || true
# Build and restart containers
docker compose -f docker-compose.prod.yml build --no-cache
docker compose -f docker-compose.prod.yml down
docker compose -f docker-compose.prod.yml up -d
# Wait for health checks
sleep 15
# Verify deployment
docker ps | grep aurora
# Cleanup old images
docker image prune -f
- name: Verify Deployment
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
# Check if app container is healthy
if docker ps | grep -q "aurora_app.*healthy"; then
echo "✅ Deployment successful - aurora_app is healthy"
exit 0
else
echo "⚠️ Health check pending, checking container status..."
docker ps | grep aurora
docker logs aurora_app --tail 20
exit 0
fi

8
.gitignore vendored
View File

@@ -1,7 +1,9 @@
.env .env
node_modules node_modules
db-logs docker-compose.override.yml
db-data shared/db-logs
shared/db/data
shared/db/loga
.cursor .cursor
# dependencies (bun install) # dependencies (bun install)
@@ -44,4 +46,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
src/db/data src/db/data
src/db/log src/db/log
scratchpad/ scratchpad/
tickets/

244
AGENTS.md Normal file
View 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` |

View File

@@ -1,17 +1,55 @@
# ============================================
# Base stage - shared configuration
# ============================================
FROM oven/bun:latest AS base FROM oven/bun:latest AS base
WORKDIR /app WORKDIR /app
# Install system dependencies # Install system dependencies with cleanup in same layer
RUN apt-get update && apt-get install -y git RUN apt-get update && \
apt-get install -y --no-install-recommends git && \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Install dependencies # ============================================
# Dependencies stage - installs all deps
# ============================================
FROM base AS deps
# Copy only package files first (better layer caching)
COPY package.json bun.lock ./ COPY package.json bun.lock ./
RUN bun install --frozen-lockfile COPY web/package.json web/bun.lock ./web/
# Copy source code # Install all dependencies in one layer
COPY . . RUN bun install --frozen-lockfile && \
cd web && bun install --frozen-lockfile
# Expose port # ============================================
# 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 EXPOSE 3000
# Default command # Default command

54
Dockerfile.prod Normal file
View File

@@ -0,0 +1,54 @@
# =============================================================================
# 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
RUN groupadd --system appgroup && useradd --system --gid appgroup appuser
# Copy only what's needed for production
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/web/node_modules ./web/node_modules
COPY --from=builder --chown=appuser:appgroup /app/web/dist ./web/dist
COPY --from=builder --chown=appuser:appgroup /app/bot ./bot
COPY --from=builder --chown=appuser:appgroup /app/shared ./shared
COPY --from=builder --chown=appuser:appgroup /app/package.json .
COPY --from=builder --chown=appuser:appgroup /app/drizzle.config.ts .
COPY --from=builder --chown=appuser:appgroup /app/tsconfig.json .
# Switch to non-root user
USER appuser
# 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"]

View File

@@ -7,24 +7,44 @@
![Discord.js](https://img.shields.io/badge/Discord.js-14.x-5865F2) ![Discord.js](https://img.shields.io/badge/Discord.js-14.x-5865F2)
![Drizzle ORM](https://img.shields.io/badge/Drizzle_ORM-0.30+-C5F74F) ![Drizzle ORM](https://img.shields.io/badge/Drizzle_ORM-0.30+-C5F74F)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-336791) ![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-336791)
![React](https://img.shields.io/badge/React-19-61DAFB)
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. 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 ## ✨ Features
### Discord Bot
* **Class System**: Users can join different classes. * **Class System**: Users can join different classes.
* **Economy**: Complete economy system with balance, transactions, and daily rewards. * **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. * **Leveling**: XP-based leveling system to track user activity and progress.
* **Quests**: Quest system with requirements and rewards. * **Quests**: Quest system with requirements and rewards.
* **Trading**: Secure trading system between users. * **Trading**: Secure trading system between users.
* **Lootdrops**: Random loot drops in channels to engage users. * **Lootdrops**: Random loot drops in channels to engage users.
* **Admin Tools**: Administrative commands for server management. * **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 ## 🛠️ Tech Stack
* **Runtime**: [Bun](https://bun.sh/) * **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/) * **Database**: [PostgreSQL](https://www.postgresql.org/)
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/) * **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
* **Validation**: [Zod](https://zod.dev/) * **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 bun run db:push
``` ```
### Running the Bot ### Running the Bot & Dashboard
**Development Mode** (with hot reload): **Development Mode** (with hot reload):
```bash ```bash
bun run dev bun run dev
``` ```
* Bot: Online in Discord
* Dashboard: http://localhost:3000
**Production Mode**: **Production Mode**:
Build and run with Docker (recommended): Build and run with Docker (recommended):
@@ -87,27 +109,46 @@ Build and run with Docker (recommended):
docker compose up -d app 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 ## 📜 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 generate`: Generate Drizzle migrations.
* `bun run migrate`: Apply migrations (via Docker). * `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 run db:studio`: Open Drizzle Studio to inspect the database.
* `bun test`: Run tests. * `bun test`: Run tests.
## 📂 Project Structure ## 📂 Project Structure
``` ```
├── src ├── bot # Discord Bot logic & entry point
│ ├── commands # Slash commands ├── web # React Web Dashboard (Frontend + Server)
│ ├── events # Discord event handlers ├── shared # Shared code (Database, Config, Types)
│ ├── modules # Feature modules (Economy, Inventory, etc.)
│ ├── db # Database schema and connection
│ └── lib # Shared utilities
├── drizzle # Drizzle migration files ├── drizzle # Drizzle migration files
├── config # Configuration files ├── scripts # Utility scripts
── scripts # Utility scripts ── docker-compose.yml
└── package.json
``` ```
## 🤝 Contributing ## 🤝 Contributing

View File

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@/modules/moderation/moderation.service"; import { ModerationService } from "@shared/modules/moderation/moderation.service";
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
export const moderationCase = createCommand({ export const moderationCase = createCommand({

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@/modules/moderation/moderation.service"; import { ModerationService } from "@shared/modules/moderation/moderation.service";
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
export const cases = createCommand({ export const cases = createCommand({

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@/modules/moderation/moderation.service"; import { ModerationService } from "@shared/modules/moderation/moderation.service";
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
export const clearwarning = createCommand({ export const clearwarning = createCommand({

View File

@@ -1,7 +1,7 @@
import { createCommand } from "@lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ModalSubmitInteraction } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ModalSubmitInteraction } from "discord.js";
import { config, saveConfig } from "@lib/config"; import { config, saveConfig } from "@shared/lib/config";
import type { GameConfigType } from "@lib/config"; import type { GameConfigType } from "@shared/lib/config";
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds"; import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
export const configCommand = createCommand({ export const configCommand = createCommand({

View File

@@ -1,8 +1,8 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js";
import { config, saveConfig } from "@/lib/config"; import { config, saveConfig } from "@shared/lib/config";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { DrizzleClient } from "@shared/db/DrizzleClient";
import { items } from "@/db/schema"; import { items } from "@db/schema";
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds"; import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
export const createColor = createCommand({ export const createColor = createCommand({

View File

@@ -1,4 +1,4 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { renderWizard } from "@/modules/admin/item_wizard"; import { renderWizard } from "@/modules/admin/item_wizard";

View File

@@ -1,8 +1,7 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { createBaseEmbed } from "@lib/embeds"; import { createBaseEmbed } from "@lib/embeds";
import { configManager } from "@/lib/configManager"; import { config, reloadConfig, toggleCommand } from "@shared/lib/config";
import { config, reloadConfig } from "@/lib/config";
import { AuroraClient } from "@/lib/BotClient"; import { AuroraClient } from "@/lib/BotClient";
export const features = createCommand({ export const features = createCommand({
@@ -79,11 +78,11 @@ export const features = createCommand({
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await interaction.deferReply({ flags: MessageFlags.Ephemeral });
configManager.toggleCommand(commandName, enabled); toggleCommand(commandName, enabled);
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Reloading configuration...` }); await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Reloading configuration...` });
// Reload config from disk (which was updated by configManager) // Reload config from disk (which was updated by toggleCommand)
reloadConfig(); reloadConfig();
await AuroraClient.loadCommands(true); await AuroraClient.loadCommands(true);

View File

@@ -5,7 +5,7 @@ import { AuroraClient } from "@/lib/BotClient";
// Mock DrizzleClient // Mock DrizzleClient
const executeMock = mock(() => Promise.resolve()); const executeMock = mock(() => Promise.resolve());
mock.module("@/lib/DrizzleClient", () => ({ mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: { DrizzleClient: {
execute: executeMock execute: executeMock
} }

View File

@@ -1,7 +1,7 @@
import { createCommand } from "@lib/utils"; import { createCommand } from "@shared/lib/utils";
import { AuroraClient } from "@/lib/BotClient"; import { AuroraClient } from "@/lib/BotClient";
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, Colors } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, Colors } from "discord.js";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { DrizzleClient } from "@shared/db/DrizzleClient";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { createBaseEmbed } from "@lib/embeds"; import { createBaseEmbed } from "@lib/embeds";

View File

@@ -1,4 +1,4 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { import {
SlashCommandBuilder, SlashCommandBuilder,
ActionRowBuilder, ActionRowBuilder,
@@ -8,12 +8,12 @@ import {
PermissionFlagsBits, PermissionFlagsBits,
MessageFlags MessageFlags
} from "discord.js"; } from "discord.js";
import { inventoryService } from "@/modules/inventory/inventory.service"; import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds"; import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds";
import { UserError } from "@/lib/errors"; import { UserError } from "@shared/lib/errors";
import { items } from "@/db/schema"; import { items } from "@db/schema";
import { ilike, isNotNull, and } from "drizzle-orm"; import { ilike, isNotNull, and } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { DrizzleClient } from "@shared/db/DrizzleClient";
import { getShopListingMessage } from "@/modules/economy/shop.view"; import { getShopListingMessage } from "@/modules/economy/shop.view";
export const listing = createCommand({ export const listing = createCommand({
@@ -65,10 +65,10 @@ export const listing = createCommand({
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` }); await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
} catch (error: any) { } catch (error: any) {
if (error instanceof UserError) { if (error instanceof UserError) {
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true }); await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else { } else {
console.error("Error creating listing:", error); 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.")] });
} }
} }
}, },

View File

@@ -1,7 +1,7 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@/modules/moderation/moderation.service"; import { ModerationService } from "@shared/modules/moderation/moderation.service";
import { CaseType } from "@/lib/constants"; import { CaseType } from "@shared/lib/constants";
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
export const note = createCommand({ export const note = createCommand({

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@/modules/moderation/moderation.service"; import { ModerationService } from "@shared/modules/moderation/moderation.service";
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
export const notes = createCommand({ export const notes = createCommand({

View File

@@ -1,7 +1,7 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
import { config } from "@/lib/config"; import { config } from "@shared/lib/config";
import { PruneService } from "@/modules/moderation/prune.service"; import { PruneService } from "@shared/modules/moderation/prune.service";
import { import {
getConfirmationMessage, getConfirmationMessage,
getProgressEmbed, getProgressEmbed,

View File

@@ -1,4 +1,4 @@
import { createCommand } from "@lib/utils"; import { createCommand } from "@shared/lib/utils";
import { AuroraClient } from "@/lib/BotClient"; import { AuroraClient } from "@/lib/BotClient";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";

View File

@@ -1,7 +1,7 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
import { terminalService } from "@/modules/terminal/terminal.service"; import { terminalService } from "@shared/modules/terminal/terminal.service";
import { createBaseEmbed, createErrorEmbed } from "@/lib/embeds"; import { createBaseEmbed, createErrorEmbed } from "@/lib/embeds";
export const terminal = createCommand({ export const terminal = createCommand({

View File

@@ -0,0 +1,177 @@
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 (now includes requirements in one call)
await interaction.editReply({ embeds: [getCheckingEmbed()] });
const updateInfo = await UpdateService.checkForUpdates();
if (!updateInfo.hasUpdates && !force) {
await interaction.editReply({
embeds: [getNoUpdatesEmbed(updateInfo.currentCommit)]
});
return;
}
// 2. Extract requirements from the combined response
const { requirements } = updateInfo;
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,
buildWebAssets: requirements.needsWebBuild,
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)]
});
}
}

View File

@@ -1,12 +1,12 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@/modules/moderation/moderation.service"; import { ModerationService } from "@shared/modules/moderation/moderation.service";
import { import {
getWarnSuccessEmbed, getWarnSuccessEmbed,
getModerationErrorEmbed, getModerationErrorEmbed,
getUserWarningEmbed getUserWarningEmbed
} from "@/modules/moderation/moderation.view"; } from "@/modules/moderation/moderation.view";
import { config } from "@/lib/config"; import { config } from "@shared/lib/config";
export const warn = createCommand({ export const warn = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@/modules/moderation/moderation.service"; import { ModerationService } from "@shared/modules/moderation/moderation.service";
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
export const warnings = createCommand({ export const warnings = createCommand({

View File

@@ -1,4 +1,4 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { createErrorEmbed } from "@/lib/embeds"; import { createErrorEmbed } from "@/lib/embeds";
import { sendWebhookMessage } from "@/lib/webhookUtils"; import { sendWebhookMessage } from "@/lib/webhookUtils";

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js"; import { SlashCommandBuilder } from "discord.js";
import { userService } from "@/modules/user/user.service"; import { userService } from "@shared/modules/user/user.service";
import { createBaseEmbed } from "@lib/embeds"; import { createBaseEmbed } from "@lib/embeds";
export const balance = createCommand({ export const balance = createCommand({

View File

@@ -1,15 +1,16 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js"; import { SlashCommandBuilder } from "discord.js";
import { economyService } from "@/modules/economy/economy.service"; import { economyService } from "@shared/modules/economy/economy.service";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@/lib/errors"; import { UserError } from "@shared/lib/errors";
export const daily = createCommand({ export const daily = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("daily") .setName("daily")
.setDescription("Claim your daily reward"), .setDescription("Claim your daily reward"),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply();
try { try {
const result = await economyService.claimDaily(interaction.user.id); const result = await economyService.claimDaily(interaction.user.id);
@@ -21,14 +22,14 @@ export const daily = createCommand({
) )
.setColor("Gold"); .setColor("Gold");
await interaction.reply({ embeds: [embed] }); await interaction.editReply({ embeds: [embed] });
} catch (error: any) { } catch (error: any) {
if (error instanceof UserError) { if (error instanceof UserError) {
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true }); await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else { } else {
console.error("Error claiming daily:", error); 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.")] });
} }
} }
} }

View File

@@ -0,0 +1,75 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
export const exam = createCommand({
data: new SlashCommandBuilder()
.setName("exam")
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
execute: async (interaction) => {
await interaction.deferReply();
try {
// First, try to take the exam or check status
const result = await examService.takeExam(interaction.user.id);
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[regResult.examDay!]}** (Server Time).\n` +
`Come back on <t:${nextRegTimestamp}:D> (<t:${nextRegTimestamp}:R>) to take your first exam!`,
"Exam Registration Successful"
)]
});
return;
}
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:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`
)]
});
return;
}
if (result.status === ExamStatus.MISSED) {
await interaction.editReply({
embeds: [createErrorEmbed(
`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"
)]
});
return;
}
// If it reached here with AVAILABLE, it means they passed
await interaction.editReply({
embeds: [createSuccessEmbed(
`**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) {
console.error("Error in exam command:", error);
await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An unexpected error occurred.")] });
}
}
});

View File

@@ -1,11 +1,11 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, MessageFlags } from "discord.js"; import { SlashCommandBuilder, MessageFlags } from "discord.js";
import { economyService } from "@/modules/economy/economy.service"; import { economyService } from "@shared/modules/economy/economy.service";
import { userService } from "@/modules/user/user.service"; import { userService } from "@shared/modules/user/user.service";
import { config } from "@/lib/config"; import { config } from "@shared/lib/config";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@/lib/errors"; import { UserError } from "@shared/lib/errors";
export const pay = createCommand({ export const pay = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags } from "discord.js"; import { SlashCommandBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags } from "discord.js";
import { tradeService } from "@/modules/trade/trade.service"; import { tradeService } from "@shared/modules/trade/trade.service";
import { getTradeDashboard } from "@/modules/trade/trade.view"; import { getTradeDashboard } from "@/modules/trade/trade.view";
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds"; import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";

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

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js"; import { SlashCommandBuilder } from "discord.js";
import { config } from "@/lib/config"; import { config } from "@shared/lib/config";
import { createErrorEmbed } from "@/lib/embeds"; import { createErrorEmbed } from "@/lib/embeds";
import { getFeedbackTypeMenu } from "@/modules/feedback/feedback.view"; import { getFeedbackTypeMenu } from "@/modules/feedback/feedback.view";

View File

@@ -1,7 +1,7 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js"; import { SlashCommandBuilder } from "discord.js";
import { inventoryService } from "@/modules/inventory/inventory.service"; import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { userService } from "@/modules/user/user.service"; import { userService } from "@shared/modules/user/user.service";
import { createWarningEmbed } from "@lib/embeds"; import { createWarningEmbed } from "@lib/embeds";
import { getInventoryEmbed } from "@/modules/inventory/inventory.view"; import { getInventoryEmbed } from "@/modules/inventory/inventory.view";

View File

@@ -1,12 +1,12 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js"; import { SlashCommandBuilder } from "discord.js";
import { inventoryService } from "@/modules/inventory/inventory.service"; import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { userService } from "@/modules/user/user.service"; import { userService } from "@shared/modules/user/user.service";
import { createErrorEmbed } from "@lib/embeds"; import { createErrorEmbed } from "@lib/embeds";
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view"; import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
import type { ItemUsageData } from "@/lib/types"; import type { ItemUsageData } from "@shared/lib/types";
import { UserError } from "@/lib/errors"; import { UserError } from "@shared/lib/errors";
import { config } from "@/lib/config"; import { config } from "@shared/lib/config";
export const use = createCommand({ export const use = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()

View File

@@ -1,7 +1,7 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js"; import { SlashCommandBuilder } from "discord.js";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { DrizzleClient } from "@shared/db/DrizzleClient";
import { users, items, inventory } from "@/db/schema"; import { users, items, inventory } from "@db/schema";
import { desc, sql, eq } from "drizzle-orm"; import { desc, sql, eq } from "drizzle-orm";
import { createWarningEmbed } from "@lib/embeds"; import { createWarningEmbed } from "@lib/embeds";
import { getLeaderboardEmbed } from "@/modules/leveling/leveling.view"; import { getLeaderboardEmbed } from "@/modules/leveling/leveling.view";

View File

@@ -0,0 +1,83 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, MessageFlags } from "discord.js";
import { questService } from "@shared/modules/quest/quest.service";
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 and available quests"),
execute: async (interaction) => {
const response = await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const userId = interaction.user.id;
const updateView = async (viewType: 'active' | 'available') => {
const userQuests = await questService.getUserQuests(userId);
const availableQuests = await questService.getAvailableQuests(userId);
const containers = viewType === 'active'
? getQuestListComponents(userQuests)
: getAvailableQuestsComponents(availableQuests);
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(() => {});
});
}
});

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, AttachmentBuilder } from "discord.js"; import { SlashCommandBuilder, AttachmentBuilder } from "discord.js";
import { userService } from "@/modules/user/user.service"; import { userService } from "@shared/modules/user/user.service";
import { generateStudentIdCard } from "@/graphics/studentID"; import { generateStudentIdCard } from "@/graphics/studentID";
import { createWarningEmbed } from "@/lib/embeds"; import { createWarningEmbed } from "@/lib/embeds";

View File

@@ -1,7 +1,7 @@
import { Events } from "discord.js"; import { Events } from "discord.js";
import type { Event } from "@lib/types"; import type { Event } from "@shared/lib/types";
import { config } from "@lib/config"; import { config } from "@shared/lib/config";
import { userService } from "@modules/user/user.service"; import { userService } from "@shared/modules/user/user.service";
// Visitor role // Visitor role
const event: Event<Events.GuildMemberAdd> = { const event: Event<Events.GuildMemberAdd> = {

View File

@@ -1,6 +1,6 @@
import { Events } from "discord.js"; import { Events } from "discord.js";
import { ComponentInteractionHandler, AutocompleteHandler, CommandHandler } from "@/lib/handlers"; import { ComponentInteractionHandler, AutocompleteHandler, CommandHandler } from "@/lib/handlers";
import type { Event } from "@lib/types"; import type { Event } from "@shared/lib/types";
const event: Event<Events.InteractionCreate> = { const event: Event<Events.InteractionCreate> = {
name: Events.InteractionCreate, name: Events.InteractionCreate,

View File

@@ -1,7 +1,7 @@
import { Events } from "discord.js"; import { Events } from "discord.js";
import { userService } from "@/modules/user/user.service"; import { userService } from "@shared/modules/user/user.service";
import { levelingService } from "@/modules/leveling/leveling.service"; import { levelingService } from "@shared/modules/leveling/leveling.service";
import type { Event } from "@lib/types"; import type { Event } from "@shared/lib/types";
const event: Event<Events.MessageCreate> = { const event: Event<Events.MessageCreate> = {
name: Events.MessageCreate, name: Events.MessageCreate,
@@ -15,7 +15,7 @@ const event: Event<Events.MessageCreate> = {
levelingService.processChatXp(message.author.id); levelingService.processChatXp(message.author.id);
// Activity Tracking for Lootdrops // Activity Tracking for Lootdrops
import("@/modules/economy/lootdrop.service").then(m => m.lootdropService.processMessage(message)); import("@shared/modules/economy/lootdrop.service").then(m => m.lootdropService.processMessage(message));
}, },
}; };

View File

@@ -1,6 +1,6 @@
import { Events } from "discord.js"; import { Events } from "discord.js";
import { schedulerService } from "@/modules/system/scheduler"; import { schedulerService } from "@/modules/system/scheduler";
import type { Event } from "@lib/types"; import type { Event } from "@shared/lib/types";
const event: Event<Events.ClientReady> = { const event: Event<Events.ClientReady> = {
name: Events.ClientReady, name: Events.ClientReady,
@@ -10,7 +10,7 @@ const event: Event<Events.ClientReady> = {
schedulerService.start(); schedulerService.start();
// Handle post-update tasks // Handle post-update tasks
const { UpdateService } = await import("@/modules/admin/update.service"); const { UpdateService } = await import("@shared/modules/admin/update.service");
await UpdateService.handlePostRestart(c); await UpdateService.handlePostRestart(c);
}, },
}; };

View File

@@ -2,12 +2,12 @@ import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas';
import path from 'path'; import path from 'path';
// Register Fonts (same as studentID.ts) // Register Fonts (same as studentID.ts)
const fontDir = path.join(process.cwd(), 'src', 'assets', 'fonts'); const fontDir = path.join(process.cwd(), 'bot', 'assets', 'fonts');
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold'); GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold');
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold'); GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
export async function generateLootdropCard(amount: number, currency: string): Promise<Buffer> { export async function generateLootdropCard(amount: number, currency: string): Promise<Buffer> {
const templatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'lootdrop', 'template.png'); const templatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'lootdrop', 'template.png');
const template = await loadImage(templatePath); const template = await loadImage(templatePath);
const canvas = createCanvas(template.width, template.height); const canvas = createCanvas(template.width, template.height);
@@ -50,7 +50,7 @@ export async function generateLootdropCard(amount: number, currency: string): Pr
} }
export async function generateClaimedLootdropCard(amount: number, currency: string, username: string, avatarUrl: string): Promise<Buffer> { export async function generateClaimedLootdropCard(amount: number, currency: string, username: string, avatarUrl: string): Promise<Buffer> {
const templatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'lootdrop', 'template.png'); const templatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'lootdrop', 'template.png');
const template = await loadImage(templatePath); const template = await loadImage(templatePath);
const canvas = createCanvas(template.width, template.height); const canvas = createCanvas(template.width, template.height);

View File

@@ -1,9 +1,9 @@
import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas'; import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas';
import { levelingService } from '@/modules/leveling/leveling.service'; import { levelingService } from '@shared/modules/leveling/leveling.service';
import path from 'path'; import path from 'path';
// Register Fonts // Register Fonts
const fontDir = path.join(process.cwd(), 'src', 'assets', 'fonts'); const fontDir = path.join(process.cwd(), 'bot', 'assets', 'fonts');
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold'); GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold');
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold'); GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
@@ -18,8 +18,8 @@ interface StudentCardData {
} }
export async function generateStudentIdCard(data: StudentCardData): Promise<Buffer> { export async function generateStudentIdCard(data: StudentCardData): Promise<Buffer> {
const templatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'studentID', 'template.png'); const templatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'studentID', 'template.png');
const classTemplatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'studentID', `Constellation-${data.className}.png`); const classTemplatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'studentID', `Constellation-${data.className}.png`);
const template = await loadImage(templatePath); const template = await loadImage(templatePath);
const classTemplate = await loadImage(classTemplatePath); const classTemplate = await loadImage(classTemplatePath);

49
bot/index.ts Normal file
View File

@@ -0,0 +1,49 @@
import { AuroraClient } from "@/lib/BotClient";
import { env } from "@shared/lib/env";
import { join } from "node:path";
import { startWebServerFromRoot } from "../web/src/server";
// Load commands & events
await AuroraClient.loadCommands();
await AuroraClient.loadEvents();
await AuroraClient.deployCommands();
await AuroraClient.setupSystemEvents();
console.log("🌐 Starting web server...");
let shuttingDown = false;
const webProjectPath = join(import.meta.dir, "../web");
const webPort = Number(process.env.WEB_PORT) || 3000;
const webHost = process.env.HOST || "0.0.0.0";
// Start web server in the same process
const webServer = await startWebServerFromRoot(webProjectPath, {
port: webPort,
hostname: webHost,
});
// login with the token from .env
if (!env.DISCORD_BOT_TOKEN) {
throw new Error("❌ DISCORD_BOT_TOKEN is not set in environment variables.");
}
AuroraClient.login(env.DISCORD_BOT_TOKEN);
// Handle graceful shutdown
const shutdownHandler = async () => {
if (shuttingDown) return;
shuttingDown = true;
console.log("🛑 Shutdown signal received. Stopping services...");
// Stop web server
await webServer.stop();
// Stop bot
AuroraClient.shutdown();
process.exit(0);
};
process.on("SIGINT", shutdownHandler);
process.on("SIGTERM", shutdownHandler);

111
bot/lib/BotClient.test.ts Normal file
View File

@@ -0,0 +1,111 @@
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'
}
}));
// 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();
});
});

200
bot/lib/BotClient.ts Normal file
View File

@@ -0,0 +1,200 @@
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";
import { CommandLoader } from "@lib/loaders/CommandLoader";
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...");
}
const commandsPath = join(import.meta.dir, '../commands');
const result = await this.commandLoader.loadFromDirectory(commandsPath, reload);
console.log(`📦 Command loading complete: ${result.loaded} loaded, ${result.skipped} skipped, ${result.errors.length} errors`);
}
async loadEvents(reload: boolean = false) {
if (reload) {
this.removeAllListeners();
console.log("♻️ Reloading events...");
}
const eventsPath = join(import.meta.dir, '../events');
const result = await this.eventLoader.loadFromDirectory(eventsPath, reload);
console.log(`📦 Event loading complete: ${result.loaded} loaded, ${result.skipped} skipped, ${result.errors.length} errors`);
}
async deployCommands() {
// We use env.DISCORD_BOT_TOKEN directly so this can run without client.login()
const token = env.DISCORD_BOT_TOKEN;
if (!token) {
console.error("DISCORD_BOT_TOKEN is not set.");
return;
}
const rest = new REST().setToken(token);
const commandsData = this.commands.map(c => c.data.toJSON());
const guildId = env.DISCORD_GUILD_ID;
const clientId = env.DISCORD_CLIENT_ID;
if (!clientId) {
console.error("DISCORD_CLIENT_ID is not set.");
return;
}
try {
console.log(`Started refreshing ${commandsData.length} application (/) commands.`);
let data;
if (guildId) {
console.log(`Registering commands to guild: ${guildId}`);
data = await rest.put(
Routes.applicationGuildCommands(clientId, guildId),
{ body: commandsData },
);
// Clear global commands to avoid duplicates
await rest.put(Routes.applicationCommands(clientId), { body: [] });
} else {
console.log('Registering commands globally');
data = await rest.put(
Routes.applicationCommands(clientId),
{ body: commandsData },
);
}
console.log(`Successfully reloaded ${(data as any).length} application (/) commands.`);
} catch (error: any) {
if (error.code === 50001) {
console.warn("Missing Access: The bot is not in the guild or lacks 'applications.commands' scope.");
console.warn(" If you are testing locally, make sure you invited the bot with scope 'bot applications.commands'.");
} else {
console.error(error);
}
}
}
async shutdown() {
const { setShuttingDown, waitForTransactions } = await import("./shutdown");
const { closeDatabase } = await import("@shared/db/DrizzleClient");
console.log("🛑 Shutdown signal received. Starting graceful shutdown...");
setShuttingDown(true);
// Wait for transactions to complete
console.log("⏳ Waiting for active transactions to complete...");
await waitForTransactions(10000);
// Destroy Discord client
console.log("🔌 Disconnecting from Discord...");
this.destroy();
// Close database
console.log("🗄️ Closing database connection...");
await closeDatabase();
console.log("👋 Graceful shutdown complete. Exiting.");
process.exit(0);
}
}
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers, GatewayIntentBits.DirectMessages] });

View File

@@ -0,0 +1,74 @@
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,
},
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
View 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;
}

View File

@@ -1,5 +1,5 @@
import { DrizzleClient } from "./DrizzleClient"; import { DrizzleClient } from "@shared/db/DrizzleClient";
import type { Transaction } from "./types"; import type { Transaction } from "@shared/lib/types";
import { isShuttingDown, incrementTransactions, decrementTransactions } from "./shutdown"; import { isShuttingDown, incrementTransactions, decrementTransactions } from "./shutdown";
export const withTransaction = async <T>( export const withTransaction = async <T>(

View File

@@ -1,4 +1,15 @@
import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js"; 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. * 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. * @returns An EmbedBuilder instance configured as an error.
*/ */
export function createErrorEmbed(message: string, title: string = "Error"): EmbedBuilder { export function createErrorEmbed(message: string, title: string = "Error"): EmbedBuilder {
return new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(`${title}`) .setTitle(`${title}`)
.setDescription(message) .setDescription(message)
.setColor(Colors.Red) .setColor(Colors.Red)
.setTimestamp(); .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. * @returns An EmbedBuilder instance configured as a warning.
*/ */
export function createWarningEmbed(message: string, title: string = "Warning"): EmbedBuilder { export function createWarningEmbed(message: string, title: string = "Warning"): EmbedBuilder {
return new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(`⚠️ ${title}`) .setTitle(`⚠️ ${title}`)
.setDescription(message) .setDescription(message)
.setColor(Colors.Yellow) .setColor(Colors.Yellow)
.setTimestamp(); .setTimestamp();
return applyBranding(embed);
} }
/** /**
@@ -35,11 +50,13 @@ export function createWarningEmbed(message: string, title: string = "Warning"):
* @returns An EmbedBuilder instance configured as a success. * @returns An EmbedBuilder instance configured as a success.
*/ */
export function createSuccessEmbed(message: string, title: string = "Success"): EmbedBuilder { export function createSuccessEmbed(message: string, title: string = "Success"): EmbedBuilder {
return new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(`${title}`) .setTitle(`${title}`)
.setDescription(message) .setDescription(message)
.setColor(Colors.Green) .setColor(Colors.Green)
.setTimestamp(); .setTimestamp();
return applyBranding(embed);
} }
/** /**
@@ -49,11 +66,13 @@ export function createSuccessEmbed(message: string, title: string = "Success"):
* @returns An EmbedBuilder instance configured as info. * @returns An EmbedBuilder instance configured as info.
*/ */
export function createInfoEmbed(message: string, title: string = "Info"): EmbedBuilder { export function createInfoEmbed(message: string, title: string = "Info"): EmbedBuilder {
return new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(` ${title}`) .setTitle(` ${title}`)
.setDescription(message) .setDescription(message)
.setColor(Colors.Blue) .setColor(Colors.Blue)
.setTimestamp(); .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 { export function createBaseEmbed(title?: string, description?: string, color?: ColorResolvable): EmbedBuilder {
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTimestamp(); .setTimestamp()
.setColor(color ?? BRANDING.COLOR);
if (title) embed.setTitle(title); if (title) embed.setTitle(title);
if (description) embed.setDescription(description); if (description) embed.setDescription(description);
if (color) embed.setColor(color);
return embed; return applyBranding(embed);
} }

View File

@@ -1,6 +1,7 @@
import { AutocompleteInteraction } from "discord.js"; import { AutocompleteInteraction } from "discord.js";
import { AuroraClient } from "@/lib/BotClient"; import { AuroraClient } from "@/lib/BotClient";
import { logger } from "@lib/logger"; import { logger } from "@shared/lib/logger";
/** /**
* Handles autocomplete interactions for slash commands * Handles autocomplete interactions for slash commands
@@ -16,7 +17,7 @@ export class AutocompleteHandler {
try { try {
await command.autocomplete(interaction); await command.autocomplete(interaction);
} catch (error) { } catch (error) {
logger.error(`Error handling autocomplete for ${interaction.commandName}:`, error); logger.error("bot", `Error handling autocomplete for ${interaction.commandName}`, error);
} }
} }
} }

View File

@@ -4,7 +4,7 @@ import { AuroraClient } from "@/lib/BotClient";
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
// Mock UserService // Mock UserService
mock.module("@/modules/user/user.service", () => ({ mock.module("@shared/modules/user/user.service", () => ({
userService: { userService: {
getOrCreateUser: mock(() => Promise.resolve()) getOrCreateUser: mock(() => Promise.resolve())
} }
@@ -56,4 +56,28 @@ describe("CommandHandler", () => {
expect(executeError).toHaveBeenCalled(); expect(executeError).toHaveBeenCalled();
expect(AuroraClient.lastCommandTimestamp).toBeNull(); 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
});
}); });

View File

@@ -1,8 +1,9 @@
import { ChatInputCommandInteraction, MessageFlags } from "discord.js"; import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
import { AuroraClient } from "@/lib/BotClient"; import { AuroraClient } from "@/lib/BotClient";
import { userService } from "@/modules/user/user.service"; import { userService } from "@shared/modules/user/user.service";
import { createErrorEmbed } from "@lib/embeds"; import { createErrorEmbed } from "@lib/embeds";
import { logger } from "@lib/logger"; import { logger } from "@shared/lib/logger";
/** /**
* Handles slash command execution * Handles slash command execution
@@ -13,7 +14,14 @@ export class CommandHandler {
const command = AuroraClient.commands.get(interaction.commandName); const command = AuroraClient.commands.get(interaction.commandName);
if (!command) { if (!command) {
logger.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; return;
} }
@@ -21,14 +29,14 @@ export class CommandHandler {
try { try {
await userService.getOrCreateUser(interaction.user.id, interaction.user.username); await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
} catch (error) { } catch (error) {
logger.error("Failed to ensure user exists:", error); logger.error("bot", "Failed to ensure user exists", error);
} }
try { try {
await command.execute(interaction); await command.execute(interaction);
AuroraClient.lastCommandTimestamp = Date.now(); AuroraClient.lastCommandTimestamp = Date.now();
} catch (error) { } catch (error) {
logger.error(String(error)); logger.error("bot", `Error executing command ${interaction.commandName}`, error);
const errorEmbed = createErrorEmbed('There was an error while executing this command!'); const errorEmbed = createErrorEmbed('There was an error while executing this command!');
if (interaction.replied || interaction.deferred) { if (interaction.replied || interaction.deferred) {

View File

@@ -1,7 +1,8 @@
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js"; import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js";
import { logger } from "@lib/logger";
import { UserError } from "@lib/errors"; import { UserError } from "@shared/lib/errors";
import { createErrorEmbed } from "@lib/embeds"; import { createErrorEmbed } from "@lib/embeds";
import { logger } from "@shared/lib/logger";
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction; type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
@@ -28,7 +29,7 @@ export class ComponentInteractionHandler {
return; return;
} }
} else { } else {
logger.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 // Log system errors (non-user errors) for debugging
if (!isUserError) { if (!isUserError) {
logger.error(`Error in ${handlerName}:`, error); logger.error("bot", `Error in ${handlerName}`, error);
} }
const errorEmbed = createErrorEmbed(errorMessage); const errorEmbed = createErrorEmbed(errorMessage);
@@ -72,7 +73,7 @@ export class ComponentInteractionHandler {
} }
} catch (replyError) { } catch (replyError) {
// If we can't send a reply, log it // If we can't send a reply, log it
logger.error(`Failed to send error response in ${handlerName}:`, replyError); logger.error("bot", `Failed to send error response in ${handlerName}`, replyError);
} }
} }
} }

View File

@@ -37,6 +37,11 @@ export const interactionRoutes: InteractionRoute[] = [
handler: () => import("@/modules/economy/lootdrop.interaction"), handler: () => import("@/modules/economy/lootdrop.interaction"),
method: 'handleLootdropInteraction' method: 'handleLootdropInteraction'
}, },
{
predicate: (i) => i.isButton() && i.customId.startsWith("trivia_"),
handler: () => import("@/modules/trivia/trivia.interaction"),
method: 'handleTriviaInteraction'
},
// --- ADMIN MODULE --- // --- ADMIN MODULE ---
{ {

View File

@@ -1,10 +1,10 @@
import { readdir } from "node:fs/promises"; import { readdir } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import type { Command } from "@lib/types"; import type { Command } from "@shared/lib/types";
import { config } from "@lib/config"; import { config } from "@shared/lib/config";
import type { LoadResult, LoadError } from "./types"; import type { LoadResult, LoadError } from "./types";
import type { Client } from "../BotClient"; import type { Client } from "../BotClient";
import { logger } from "@lib/logger";
/** /**
* Handles loading commands from the file system * Handles loading commands from the file system
@@ -45,7 +45,7 @@ export class CommandLoader {
await this.loadCommandFile(filePath, reload, result); await this.loadCommandFile(filePath, reload, result);
} }
} catch (error) { } catch (error) {
logger.error(`Error reading directory ${dir}:`, error); console.error(`Error reading directory ${dir}:`, error);
result.errors.push({ file: dir, error }); result.errors.push({ file: dir, error });
} }
} }
@@ -60,7 +60,7 @@ export class CommandLoader {
const commands = Object.values(commandModule); const commands = Object.values(commandModule);
if (commands.length === 0) { if (commands.length === 0) {
logger.warn(`No commands found in ${filePath}`); console.warn(`No commands found in ${filePath}`);
result.skipped++; result.skipped++;
return; return;
} }
@@ -71,24 +71,27 @@ export class CommandLoader {
if (this.isValidCommand(command)) { if (this.isValidCommand(command)) {
command.category = category; 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; const isEnabled = config.commands[command.data.name] !== false;
if (!isEnabled) { if (!isEnabled) {
logger.info(`🚫 Skipping disabled command: ${command.data.name}`); console.log(`🚫 Skipping disabled command: ${command.data.name}`);
result.skipped++; result.skipped++;
continue; continue;
} }
this.client.commands.set(command.data.name, command); this.client.commands.set(command.data.name, command);
logger.success(`Loaded command: ${command.data.name}`); console.log(`Loaded command: ${command.data.name}`);
result.loaded++; result.loaded++;
} else { } else {
logger.warn(`Skipping invalid command in ${filePath}`); console.warn(`Skipping invalid command in ${filePath}`);
result.skipped++; result.skipped++;
} }
} }
} catch (error) { } catch (error) {
logger.error(`Failed to load command from ${filePath}:`, error); console.error(`Failed to load command from ${filePath}:`, error);
result.errors.push({ file: filePath, error }); result.errors.push({ file: filePath, error });
} }
} }

View File

@@ -1,9 +1,9 @@
import { readdir } from "node:fs/promises"; import { readdir } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import type { Event } from "@lib/types"; import type { Event } from "@shared/lib/types";
import type { LoadResult } from "./types"; import type { LoadResult } from "./types";
import type { Client } from "../BotClient"; import type { Client } from "../BotClient";
import { logger } from "@lib/logger";
/** /**
* Handles loading events from the file system * Handles loading events from the file system
@@ -44,7 +44,7 @@ export class EventLoader {
await this.loadEventFile(filePath, reload, result); await this.loadEventFile(filePath, reload, result);
} }
} catch (error) { } catch (error) {
logger.error(`Error reading directory ${dir}:`, error); console.error(`Error reading directory ${dir}:`, error);
result.errors.push({ file: dir, error }); result.errors.push({ file: dir, error });
} }
} }
@@ -64,14 +64,14 @@ export class EventLoader {
} else { } else {
this.client.on(event.name, (...args) => event.execute(...args)); this.client.on(event.name, (...args) => event.execute(...args));
} }
logger.success(`Loaded event: ${event.name}`); console.log(`Loaded event: ${event.name}`);
result.loaded++; result.loaded++;
} else { } else {
logger.warn(`Skipping invalid event in ${filePath}`); console.warn(`Skipping invalid event in ${filePath}`);
result.skipped++; result.skipped++;
} }
} catch (error) { } catch (error) {
logger.error(`Failed to load event from ${filePath}:`, error); console.error(`Failed to load event from ${filePath}:`, error);
result.errors.push({ file: filePath, error }); result.errors.push({ file: filePath, error });
} }
} }

View File

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

View File

@@ -6,13 +6,13 @@ import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction
const valuesMock = mock((_args: any) => Promise.resolve()); const valuesMock = mock((_args: any) => Promise.resolve());
const insertMock = mock(() => ({ values: valuesMock })); const insertMock = mock(() => ({ values: valuesMock }));
mock.module("@/lib/DrizzleClient", () => ({ mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: { DrizzleClient: {
insert: insertMock insert: insertMock
} }
})); }));
mock.module("@/db/schema", () => ({ mock.module("@db/schema", () => ({
items: "items_schema" items: "items_schema"
})); }));

View File

@@ -1,10 +1,10 @@
import { type Interaction } from "discord.js"; import { type Interaction } from "discord.js";
import { items } from "@/db/schema"; import { items } from "@db/schema";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { DrizzleClient } from "@shared/db/DrizzleClient";
import type { ItemUsageData, ItemEffect } from "@/lib/types"; import type { ItemUsageData, ItemEffect } from "@shared/lib/types";
import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view"; import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
import type { DraftItem } from "./item_wizard.types"; import type { DraftItem } from "./item_wizard.types";
import { ItemType, EffectType } from "@/lib/constants"; import { ItemType, EffectType } from "@shared/lib/constants";
// --- Types --- // --- Types ---
@@ -241,3 +241,8 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
} }
}; };
export const clearDraftSessions = () => {
draftSession.clear();
console.log("[ItemWizard] All draft item creation sessions cleared.");
};

View File

@@ -1,4 +1,4 @@
import type { ItemUsageData } from "@/lib/types"; import type { ItemUsageData } from "@shared/lib/types";
export interface DraftItem { export interface DraftItem {
name: string; name: string;

View File

@@ -10,7 +10,7 @@ import {
} from "discord.js"; } from "discord.js";
import { createBaseEmbed } from "@lib/embeds"; import { createBaseEmbed } from "@lib/embeds";
import type { DraftItem } from "./item_wizard.types"; import type { DraftItem } from "./item_wizard.types";
import { ItemType } from "@/lib/constants"; import { ItemType } from "@shared/lib/constants";
const getItemTypeOptions = () => [ const getItemTypeOptions = () => [
{ label: "Material", value: ItemType.MATERIAL, description: "Used for crafting or trading" }, { label: "Material", value: ItemType.MATERIAL, description: "Used for crafting or trading" },

View File

@@ -0,0 +1,35 @@
export interface RestartContext {
channelId: string;
userId: string;
timestamp: number;
runMigrations: boolean;
installDependencies: boolean;
buildWebAssets: boolean;
previousCommit: string;
newCommit: string;
}
export interface UpdateCheckResult {
needsRootInstall: boolean;
needsWebInstall: boolean;
needsWebBuild: 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;
}

View File

@@ -0,0 +1,356 @@
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, needsWebBuild, 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 (needsWebBuild) reqs.push("🏗️ Build web dashboard");
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.needsWebBuild) {
steps.push("🏗️ Web dashboard will be rebuilt 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;
webBuildSuccess: boolean;
webBuildOutput: string;
migrationSuccess: boolean;
migrationOutput: string;
ranInstall: boolean;
ranWebBuild: boolean;
ranMigrations: boolean;
previousCommit?: string;
newCommit?: string;
}
export function getPostRestartEmbed(result: PostRestartResult, hasRollback: boolean) {
const isSuccess = result.installSuccess && result.webBuildSuccess && 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.ranWebBuild) {
results.push(result.webBuildSuccess
? "✅ Web dashboard built"
: "❌ Web dashboard build 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.webBuildOutput && !result.webBuildSuccess) {
embed.addFields({
name: "Web Build Output",
value: `\`\`\`\n${truncate(result.webBuildOutput, 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 getBuildingWebEmbed() {
return createInfoEmbed(
"🌐 Building web dashboard assets...\nThis may take a moment.",
"⏳ Building Web Dashboard"
);
}
export interface PostRestartProgress {
installDeps: boolean;
buildWeb: boolean;
runMigrations: boolean;
currentStep: "starting" | "install" | "build" | "migrate" | "done";
installDone?: boolean;
buildDone?: boolean;
migrateDone?: boolean;
}
export function getPostRestartProgressEmbed(progress: PostRestartProgress) {
const steps: string[] = [];
// Installation step
if (progress.installDeps) {
if (progress.currentStep === "install") {
steps.push("⏳ Installing dependencies...");
} else if (progress.installDone) {
steps.push("✅ Dependencies installed");
} else {
steps.push("⬚ Install dependencies");
}
}
// Web build step
if (progress.buildWeb) {
if (progress.currentStep === "build") {
steps.push("⏳ Building web dashboard...");
} else if (progress.buildDone) {
steps.push("✅ Web dashboard built");
} else {
steps.push("⬚ Build web dashboard");
}
}
// Migrations step
if (progress.runMigrations) {
if (progress.currentStep === "migrate") {
steps.push("⏳ Running migrations...");
} else if (progress.migrateDone) {
steps.push("✅ Migrations applied");
} else {
steps.push("⬚ Run migrations");
}
}
if (steps.length === 0) {
steps.push("⚡ Quick restart (no extra steps needed)");
}
return createInfoEmbed(steps.join("\n"), "🔄 Post-Update Tasks");
}
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"
);
}

View File

@@ -1,6 +1,6 @@
import { ButtonInteraction } from "discord.js"; import { ButtonInteraction } from "discord.js";
import { lootdropService } from "./lootdrop.service"; import { lootdropService } from "@shared/modules/economy/lootdrop.service";
import { UserError } from "@/lib/errors"; import { UserError } from "@shared/lib/errors";
import { getLootdropClaimedMessage } from "./lootdrop.view"; import { getLootdropClaimedMessage } from "./lootdrop.view";
export async function handleLootdropInteraction(interaction: ButtonInteraction) { export async function handleLootdropInteraction(interaction: ButtonInteraction) {

View File

@@ -1,7 +1,7 @@
import { ButtonInteraction, MessageFlags } from "discord.js"; import { ButtonInteraction, MessageFlags } from "discord.js";
import { inventoryService } from "@/modules/inventory/inventory.service"; import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { userService } from "@/modules/user/user.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) { export async function handleShopInteraction(interaction: ButtonInteraction) {
if (!interaction.customId.startsWith("shop_buy_")) return; if (!interaction.customId.startsWith("shop_buy_")) return;

View File

@@ -1,10 +1,10 @@
import type { Interaction } from "discord.js"; import type { Interaction } from "discord.js";
import { TextChannel, MessageFlags } from "discord.js"; import { TextChannel, MessageFlags } from "discord.js";
import { config } from "@/lib/config"; import { config } from "@shared/lib/config";
import { AuroraClient } from "@/lib/BotClient"; import { AuroraClient } from "@/lib/BotClient";
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view"; import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types"; import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
import { UserError } from "@/lib/errors"; import { UserError } from "@shared/lib/errors";
export const handleFeedbackInteraction = async (interaction: Interaction) => { export const handleFeedbackInteraction = async (interaction: Interaction) => {
// Handle select menu for choosing feedback type // Handle select menu for choosing feedback type

View File

@@ -1,11 +1,11 @@
import { levelingService } from "@/modules/leveling/leveling.service"; import { levelingService } from "@shared/modules/leveling/leveling.service";
import { economyService } from "@/modules/economy/economy.service"; import { economyService } from "@shared/modules/economy/economy.service";
import { userTimers } from "@/db/schema"; import { userTimers } from "@db/schema";
import type { EffectHandler } from "./types"; import type { EffectHandler } from "./types";
import type { LootTableItem } from "@/lib/types"; import type { LootTableItem } from "@shared/lib/types";
import { inventoryService } from "@/modules/inventory/inventory.service"; import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { inventory, items } from "@/db/schema"; import { inventory, items } from "@db/schema";
import { TimerType, TransactionType, LootType } from "@/lib/constants"; import { TimerType, TransactionType, LootType } from "@shared/lib/constants";
// Helper to extract duration in seconds // Helper to extract duration in seconds
@@ -120,7 +120,7 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
// Try to fetch item name for the message // Try to fetch item name for the message
try { try {
const item = await txFn.query.items.findFirst({ const item = await txFn.query.items.findFirst({
where: (items, { eq }) => eq(items.id, winner.itemId!) where: (items: any, { eq }: any) => eq(items.id, winner.itemId!)
}); });
if (item) { if (item) {
return winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`; return winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`;

View File

@@ -1,4 +1,4 @@
import type { Transaction } from "@/lib/types"; import type { Transaction } from "@shared/lib/types";
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<string>; export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<string>;

View File

@@ -1,6 +1,6 @@
import { EmbedBuilder } from "discord.js"; import { EmbedBuilder } from "discord.js";
import type { ItemUsageData } from "@/lib/types"; import type { ItemUsageData } from "@shared/lib/types";
import { EffectType } from "@/lib/constants"; import { EffectType } from "@shared/lib/constants";
/** /**
* Inventory entry with item details * Inventory entry with item details

View File

@@ -1,4 +1,4 @@
import { CaseType } from "@/lib/constants"; import { CaseType } from "@shared/lib/constants";
export { CaseType }; export { CaseType };

Some files were not shown because too many files have changed in this diff Show More