71 Commits

Author SHA1 Message Date
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
245 changed files with 11270 additions and 1476 deletions

View File

@@ -0,0 +1,51 @@
---
name: create-ticket
description: Create a ticket for a task that needs to be worked on.
---
# Skill: create-ticket
## Purpose
Decompose high-level objectives into "Atomic Tickets" to maximize development velocity and minimize cognitive overhead.
## Execution Rules
1. **Directory Check:** Scan `.agent/work/tickets/`. Determine sequence number `NNN`.
2. **Naming:** `.agent/work/tickets/NNN-brief-description.md`.
3. **The Atomic/Velocity Test:** - Max 3 files modified.
- Max 80 lines of logic.
- Must be verifiable via a single command or test suite.
- If it exceeds these, **split it.**
4. **Context Injection:** Include relevant code snippets or interface definitions directly in the ticket to prevent "context hunting."
5. **No Breaking Changes:** If a ticket changes a shared interface, it must include the refactor for consumers or be split into a "Transition" ticket.
## Ticket Template
### Context & Goal
[Why this matters + the specific problem it solves.]
### Dependencies
- [e.g., Ticket #NNN]
### Affected Files
- `path/to/file_A.ext`: [Specific change description]
- `path/to/file_B.ext`: [Specific change description]
### Technical Constraints & Strategy
- [e.g., Implementation: Use the existing X wrapper instead of a new fetch call.]
- [e.g., Constraint: Maintain backward compatibility with Y.]
### Definition of Done (Binary)
- [ ] Criterion 1 (e.g., `npm test` passes for `X.test.ts`)
- [ ] Criterion 2 (e.g., UI component renders without hydration errors)
- [ ] Criterion 3 (e.g., API response matches the schema in `types.ts`)
### New Test Files
- `path/to/test_A.ext`: [What is being tested]

View File

@@ -0,0 +1,54 @@
---
name: code-review
description: A "Default-to-Fail" audit of codebase changes. Observation only; no file modifications.
---
# Skill: code-review
## Purpose
Protect the codebase from "feature creep," technical debt, and weak validation. This skill assumes the latest changes are flawed until they pass a rigorous audit.
## Execution Rules
1. **Read-Only Protocol:** This is a diagnostic skill. **Under no circumstances should any files be modified.** Provide feedback only.
2. **Default-to-Fail:** Assume the code is broken or insufficient. The burden of proof lies on the code and its tests.
3. **The Atomic Veto:** - Check the diff. If it exceeds 3 files or 80 lines of logic, **Veto immediately.**
- Reason: "Change exceeds atomic threshold; high risk of cognitive load."
4. **Strictness Audit (Tests):**
- **Veto** if assertions are fuzzy (e.g., `toBeTruthy()`).
- **Veto** if there is no "Red Path" (failure case) test.
- **Veto** if the test is "loose" (e.g., doesn't check specific property values).
5. **Direct Feedback:** No sycophancy. Use "Blockers" for issues and "Verdict: APPROVE" only when the code is bulletproof.
## Review Template
### Verdict: [FAIL / APPROVE]
**Primary Blocker:** [One sentence identifying the biggest reason for rejection.]
---
### 1. Atomic Constraint Check
- **Files Changed:** [Count] / 3
- **Logic Lines:** [Count] / 80
- **Status:** [PASS / FAIL (Veto if FAIL)]
### 2. Test Strictness Audit
- **Assertion Quality:** [List specific lines with fuzzy matchers. Demand strict equality.]
- **Failure Coverage:** [Does a test exist for the 'Error/Empty' state? If no, FAIL.]
- **Logic Sync:** [Does the test actually exercise the logic added, or just side effects?]
### 3. Logic & Resilience
- **Unchecked States:** [Identify unhandled nulls, undefineds, or missing error catches.]
- **Efficiency:** [Is there a faster path or a redundant operation?]
### 4. Direct Actionables
_Note: The reviewer does not apply these. The user/agent must create a ticket or apply fixes manually._
1. [Specific fix for Blocker 1]
2. [Specific fix for Blocker 2]

View File

@@ -0,0 +1,30 @@
### Context & Goal
Currently, every command manually performs checks like user existence or maintenance mode, or these are hardcoded into the `CommandHandler`. Standardizing these requirements in the command definition itself makes the code cleaner and more declarative.
### Dependencies
- None
### Affected Files
- `shared/lib/types.ts`: Update `Command` interface to include a optional `requirements` object.
- `bot/lib/handlers/CommandHandler.ts`: Update to read and enforce these requirements.
- `bot/commands/economy/balance.ts`: Refactor to use the new requirements (example).
### Technical Constraints & Strategy
- Implementation: Use a standardized `requirements` object in the `Command` interface.
- Requirements could include: `userExists: boolean`, `permissions: string[]`, `devOnly: boolean`.
- Ensure `CommandHandler` provides clear error messages to the user when a requirement fails.
### Definition of Done (Binary)
- [ ] `Command` interface updated in `types.ts`.
- [ ] `CommandHandler.ts` enforces requirements before executing command.
- [ ] At least one command (e.g., `balance`) is refactored to use the new system.
- [ ] Clear error embeds are shown to the user when requirements aren't met.
### New Test Files
- None (Verification via manual testing of command execution).

View File

@@ -0,0 +1,31 @@
### Context & Goal
The bot currently relies on `console.error` which is hard to track and lacks context. A centralized error logging service will allow for better debugging, persistent error logs, and future integration with services like Sentry or Discord webhooks for alerts.
### Dependencies
- None
### Affected Files
- `shared/lib/logger.ts`: New file for the unified logger service.
- `bot/lib/handlers/CommandHandler.ts`: Update to use the new logger for command errors.
- `web/src/server.ts`: Update to use the new logger for API and WebSocket errors.
### Technical Constraints & Strategy
- Implementation: Create a `Logger` class/object in `shared/lib`.
- Support log levels: `info`, `warn`, `error`, `debug`.
- Errors should capture: timestamp, source (bot/web), error message, and stack trace if available.
- For now, logging to console and a local log file (e.g., `logs/error.log`) is sufficient.
### Definition of Done (Binary)
- [ ] `Logger` service implemented in `shared/lib/logger.ts`.
- [ ] Command errors are logged via the new service.
- [ ] Web server errors are logged via the new service.
- [ ] Log output is formatted consistently.
### New Test Files
- `shared/lib/logger.test.ts`: Verify logger output and file writing.

View File

@@ -0,0 +1,32 @@
### Context & Goal
The current `web/src/server.ts` is a monolithic file with a very long `fetch` handler. This makes it difficult to read and maintain. Modularizing the logic into separate route handlers and middleware-like functions will improve code quality and scalability.
### Dependencies
- None
### Affected Files
- `web/src/server.ts`: Refactor to use modular handlers.
- `web/src/routes/api.ts`: New file for API route definitions.
- `web/src/routes/static.ts`: New file for static file serving logic.
- `web/src/routes/websocket.ts`: New file for WebSocket event handling.
### Technical Constraints & Strategy
- Implementation: Move different responsibilities (API, Static, WS) into separate files.
- The main `serve` configuration should just call these modules.
- Ensure the SPA fallback logic remains intact.
### Definition of Done (Binary)
- [ ] `web/src/server.ts` length reduced by at least 50%.
- [ ] API routes moved to dedicated module.
- [ ] Static file serving moved to dedicated module.
- [ ] WebSocket logic moved to dedicated module.
- [ ] Dashboard still loads and functions correctly.
### New Test Files
- None (Verification via manual testing of the dashboard).

View File

@@ -0,0 +1,28 @@
### Context & Goal
Enhance the user experience by standardizing the look and feel of Discord embeds. Adding consistent branding like a custom footer (with version info) and using the bot's accent color will make the bot feel more professional.
### Dependencies
- None
### Affected Files
- `bot/lib/embeds.ts`: Update standard embed creators.
- `shared/lib/constants.ts`: Add branding-related constants (colors, footer text).
### Technical Constraints & Strategy
- Implementation: Update `createBaseEmbed` and other helpers to automatically include footers and standard colors.
- Use info from `package.json` for versioning in the footer.
- Ensure the changes don't break existing layouts where custom colors might be needed.
### Definition of Done (Binary)
- [x] All standard embeds now include a consistent footer.
- [x] Embeds use a predefined brand color by default.
- [x] Version number is automatically pulled for the footer.
### New Test Files
- None.

View File

@@ -0,0 +1,29 @@
### Context & Goal
The `exam` command currently contains a lot of business logic, including reward calculations, timer management, and complex database transactions. Moving this logic to a dedicated `ExamService` will improve testability, maintainability, and keep the command file focused on user interaction.
### Dependencies
- None
### Affected Files
- `shared/modules/economy/exam.service.ts`: New file for the exam logic.
- `bot/commands/economy/exam.ts`: Refactor to use the new service.
### Technical Constraints & Strategy
- Implementation: Create an `ExamService` that handles `getExamStatus`, `takeExam`, and `registerForExam`.
- The command should only handle user input and formatting the response embeds based on the service's result.
- Ensure the Drizzle transactions are correctly handled within the service.
### Definition of Done (Binary)
- [ ] `ExamService` implemented with methods for all exam-related operations.
- [ ] `bot/commands/economy/exam.ts` refactored to use the service.
- [ ] Logic is covered by unit tests in a new test file.
- [ ] Manual verification shows the exam command still works as expected.
### New Test Files
- `shared/modules/economy/exam.service.test.ts`: Unit tests for reward calculations and state transitions.

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.

9
.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)
@@ -43,5 +45,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/

View File

@@ -2,16 +2,20 @@ FROM oven/bun:latest AS base
WORKDIR /app WORKDIR /app
# Install system dependencies # Install system dependencies
RUN apt-get update && apt-get install -y git RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
# Install dependencies # Install root project dependencies
COPY package.json bun.lock ./ COPY package.json bun.lock ./
RUN bun install --frozen-lockfile 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 source code
COPY . . COPY . .
# Expose port # Expose ports (3000 for web dashboard)
EXPOSE 3000 EXPOSE 3000
# Default command # Default command

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,176 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
import { UpdateService } from "@shared/modules/admin/update.service";
import {
getCheckingEmbed,
getNoUpdatesEmbed,
getUpdatesAvailableMessage,
getPreparingEmbed,
getUpdatingEmbed,
getCancelledEmbed,
getTimeoutEmbed,
getErrorEmbed,
getRollbackSuccessEmbed,
getRollbackFailedEmbed
} from "@/modules/admin/update.view";
export const update = createCommand({
data: new SlashCommandBuilder()
.setName("update")
.setDescription("Check for updates and restart the bot")
.addSubcommand(sub =>
sub.setName("check")
.setDescription("Check for and apply available updates")
.addBooleanOption(option =>
option.setName("force")
.setDescription("Force update even if no changes detected")
.setRequired(false)
)
)
.addSubcommand(sub =>
sub.setName("rollback")
.setDescription("Rollback to the previous version")
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
const subcommand = interaction.options.getSubcommand();
if (subcommand === "rollback") {
await handleRollback(interaction);
} else {
await handleUpdate(interaction);
}
}
});
async function handleUpdate(interaction: any) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const force = interaction.options.getBoolean("force") || false;
try {
// 1. Check for updates
await interaction.editReply({ embeds: [getCheckingEmbed()] });
const updateInfo = await UpdateService.checkForUpdates();
if (!updateInfo.hasUpdates && !force) {
await interaction.editReply({
embeds: [getNoUpdatesEmbed(updateInfo.currentCommit)]
});
return;
}
// 2. Analyze requirements
const requirements = await UpdateService.checkUpdateRequirements(updateInfo.branch);
const categories = UpdateService.categorizeChanges(requirements.changedFiles);
// 3. Show confirmation with details
const { embeds, components } = getUpdatesAvailableMessage(
updateInfo,
requirements,
categories,
force
);
const response = await interaction.editReply({ embeds, components });
// 4. Wait for confirmation
try {
const confirmation = await response.awaitMessageComponent({
filter: (i: any) => i.user.id === interaction.user.id,
componentType: ComponentType.Button,
time: 30000
});
if (confirmation.customId === "confirm_update") {
await confirmation.update({
embeds: [getPreparingEmbed()],
components: []
});
// 5. Save rollback point
const previousCommit = await UpdateService.saveRollbackPoint();
// 6. Prepare restart context
await UpdateService.prepareRestartContext({
channelId: interaction.channelId,
userId: interaction.user.id,
timestamp: Date.now(),
runMigrations: requirements.needsMigrations,
installDependencies: requirements.needsRootInstall || requirements.needsWebInstall,
previousCommit: previousCommit.substring(0, 7),
newCommit: updateInfo.latestCommit
});
// 7. Show updating status
await interaction.editReply({
embeds: [getUpdatingEmbed(requirements)]
});
// 8. Perform update
await UpdateService.performUpdate(updateInfo.branch);
// 9. Trigger restart
await UpdateService.triggerRestart();
} else {
await confirmation.update({
embeds: [getCancelledEmbed()],
components: []
});
}
} catch (e) {
if (e instanceof Error && e.message.includes("time")) {
await interaction.editReply({
embeds: [getTimeoutEmbed()],
components: []
});
} else {
throw e;
}
}
} catch (error) {
console.error("Update failed:", error);
await interaction.editReply({
embeds: [getErrorEmbed(error)],
components: []
});
}
}
async function handleRollback(interaction: any) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
try {
const hasRollback = await UpdateService.hasRollbackPoint();
if (!hasRollback) {
await interaction.editReply({
embeds: [getRollbackFailedEmbed("No rollback point available. Rollback is only possible after a recent update.")]
});
return;
}
const result = await UpdateService.rollback();
if (result.success) {
await interaction.editReply({
embeds: [getRollbackSuccessEmbed(result.message.split(" ").pop() || "unknown")]
});
// Restart after rollback
setTimeout(() => UpdateService.triggerRestart(), 1000);
} else {
await interaction.editReply({
embeds: [getRollbackFailedEmbed(result.message)]
});
}
} catch (error) {
console.error("Rollback failed:", error);
await interaction.editReply({
embeds: [getErrorEmbed(error)]
});
}
}

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

@@ -1,13 +1,13 @@
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 { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@/lib/errors"; import { UserError } from "@shared/lib/errors";
import { userTimers, users } from "@/db/schema"; import { userTimers, users } from "@db/schema";
import { eq, and, sql } from "drizzle-orm"; import { eq, and, sql } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { DrizzleClient } from "@shared/db/DrizzleClient";
import { config } from "@lib/config"; import { config } from "@shared/lib/config";
import { TimerType } from "@/lib/constants"; import { TimerType } from "@shared/lib/constants";
const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM; const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
const EXAM_TIMER_KEY = 'default'; const EXAM_TIMER_KEY = 'default';
@@ -195,10 +195,10 @@ export const exam = createCommand({
} 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 in exam command:", error); console.error("Error in exam command:", error);
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true }); await interaction.editReply({ embeds: [createErrorEmbed("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

@@ -1,6 +1,6 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, MessageFlags } from "discord.js"; import { SlashCommandBuilder, MessageFlags } from "discord.js";
import { questService } from "@/modules/quest/quest.service"; import { questService } from "@shared/modules/quest/quest.service";
import { createWarningEmbed } from "@lib/embeds"; import { createWarningEmbed } from "@lib/embeds";
import { getQuestListEmbed } from "@/modules/quest/quest.view"; import { getQuestListEmbed } from "@/modules/quest/quest.view";

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

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

@@ -0,0 +1,179 @@
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } 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;
});
}
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] });

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

49
bot/lib/clientStats.ts Normal file
View File

@@ -0,0 +1,49 @@
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,
},
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,33 @@
export interface RestartContext {
channelId: string;
userId: string;
timestamp: number;
runMigrations: boolean;
installDependencies: boolean;
previousCommit: string;
newCommit: string;
}
export interface UpdateCheckResult {
needsRootInstall: boolean;
needsWebInstall: boolean;
needsMigrations: boolean;
changedFiles: string[];
error?: Error;
}
export interface UpdateInfo {
hasUpdates: boolean;
branch: string;
currentCommit: string;
latestCommit: string;
commitCount: number;
commits: CommitInfo[];
}
export interface CommitInfo {
hash: string;
message: string;
author: string;
}

View File

@@ -0,0 +1,274 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js";
import { createInfoEmbed, createSuccessEmbed, createWarningEmbed, createErrorEmbed } from "@lib/embeds";
import type { UpdateInfo, UpdateCheckResult } from "./update.types";
// Constants for UI
const LOG_TRUNCATE_LENGTH = 800;
const OUTPUT_TRUNCATE_LENGTH = 400;
function truncate(text: string, maxLength: number): string {
if (!text) return "";
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
}
// ============ Pre-Update Embeds ============
export function getCheckingEmbed() {
return createInfoEmbed("🔍 Fetching latest changes from remote...", "Checking for Updates");
}
export function getNoUpdatesEmbed(currentCommit: string) {
return createSuccessEmbed(
`You're running the latest version.\n\n**Current:** \`${currentCommit}\``,
"✅ Already Up to Date"
);
}
export function getUpdatesAvailableMessage(
updateInfo: UpdateInfo,
requirements: UpdateCheckResult,
changeCategories: Record<string, number>,
force: boolean
) {
const { branch, currentCommit, latestCommit, commitCount, commits } = updateInfo;
const { needsRootInstall, needsWebInstall, needsMigrations } = requirements;
// Build commit list (max 5)
const commitList = commits
.slice(0, 5)
.map(c => `\`${c.hash}\` ${truncate(c.message, 50)}`)
.join("\n");
const moreCommits = commitCount > 5 ? `\n*...and ${commitCount - 5} more*` : "";
// Build change categories
const categoryList = Object.entries(changeCategories)
.map(([cat, count]) => `${cat}: ${count} file${count > 1 ? "s" : ""}`)
.join("\n");
// Build requirements list
const reqs: string[] = [];
if (needsRootInstall) reqs.push("📦 Install root dependencies");
if (needsWebInstall) reqs.push("🌐 Install web dependencies");
if (needsMigrations) reqs.push("🗃️ Run database migrations");
if (reqs.length === 0) reqs.push("⚡ Quick update (no extra steps)");
const embed = new EmbedBuilder()
.setTitle("📥 Updates Available")
.setColor(force ? 0xFF6B6B : 0x5865F2)
.addFields(
{
name: "Version",
value: `\`${currentCommit}\`\`${latestCommit}\``,
inline: true
},
{
name: "Branch",
value: `\`${branch}\``,
inline: true
},
{
name: "Commits",
value: `${commitCount} new commit${commitCount > 1 ? "s" : ""}`,
inline: true
},
{
name: "Recent Changes",
value: commitList + moreCommits || "No commits",
inline: false
},
{
name: "Files Changed",
value: categoryList || "Unknown",
inline: true
},
{
name: "Update Actions",
value: reqs.join("\n"),
inline: true
}
)
.setFooter({ text: force ? "⚠️ Force mode enabled" : "This will restart the bot" })
.setTimestamp();
const confirmButton = new ButtonBuilder()
.setCustomId("confirm_update")
.setLabel(force ? "Force Update" : "Update Now")
.setEmoji(force ? "⚠️" : "🚀")
.setStyle(force ? ButtonStyle.Danger : ButtonStyle.Success);
const cancelButton = new ButtonBuilder()
.setCustomId("cancel_update")
.setLabel("Cancel")
.setStyle(ButtonStyle.Secondary);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(confirmButton, cancelButton);
return { embeds: [embed], components: [row] };
}
// ============ Update Progress Embeds ============
export function getPreparingEmbed() {
return createInfoEmbed(
"🔒 Saving rollback point...\n📥 Preparing to download updates...",
"⏳ Preparing Update"
);
}
export function getUpdatingEmbed(requirements: UpdateCheckResult) {
const steps: string[] = ["✅ Rollback point saved"];
steps.push("📥 Downloading updates...");
if (requirements.needsRootInstall || requirements.needsWebInstall) {
steps.push("📦 Dependencies will be installed after restart");
}
if (requirements.needsMigrations) {
steps.push("🗃️ Migrations will run after restart");
}
steps.push("\n🔄 **Restarting now...**");
return createWarningEmbed(steps.join("\n"), "🚀 Updating");
}
export function getCancelledEmbed() {
return createInfoEmbed("Update cancelled. No changes were made.", "❌ Cancelled");
}
export function getTimeoutEmbed() {
return createWarningEmbed(
"No response received within 30 seconds.\nRun `/update` again when ready.",
"⏰ Timed Out"
);
}
export function getErrorEmbed(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return createErrorEmbed(
`The update could not be completed:\n\`\`\`\n${truncate(message, 500)}\n\`\`\``,
"❌ Update Failed"
);
}
// ============ Post-Restart Embeds ============
export interface PostRestartResult {
installSuccess: boolean;
installOutput: string;
migrationSuccess: boolean;
migrationOutput: string;
ranInstall: boolean;
ranMigrations: boolean;
previousCommit?: string;
newCommit?: string;
}
export function getPostRestartEmbed(result: PostRestartResult, hasRollback: boolean) {
const isSuccess = result.installSuccess && result.migrationSuccess;
const embed = new EmbedBuilder()
.setTitle(isSuccess ? "✅ Update Complete" : "⚠️ Update Completed with Issues")
.setColor(isSuccess ? 0x57F287 : 0xFEE75C)
.setTimestamp();
// Version info
if (result.previousCommit && result.newCommit) {
embed.addFields({
name: "Version",
value: `\`${result.previousCommit}\`\`${result.newCommit}\``,
inline: false
});
}
// Results summary
const results: string[] = [];
if (result.ranInstall) {
results.push(result.installSuccess
? "✅ Dependencies installed"
: "❌ Dependency installation failed"
);
}
if (result.ranMigrations) {
results.push(result.migrationSuccess
? "✅ Migrations applied"
: "❌ Migration failed"
);
}
if (results.length > 0) {
embed.addFields({
name: "Actions Performed",
value: results.join("\n"),
inline: false
});
}
// Output details (collapsed if too long)
if (result.installOutput && !result.installSuccess) {
embed.addFields({
name: "Install Output",
value: `\`\`\`\n${truncate(result.installOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
inline: false
});
}
if (result.migrationOutput && !result.migrationSuccess) {
embed.addFields({
name: "Migration Output",
value: `\`\`\`\n${truncate(result.migrationOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
inline: false
});
}
// Footer with rollback hint
if (!isSuccess && hasRollback) {
embed.setFooter({ text: "💡 Use /update rollback to revert if needed" });
}
// Build components
const components: ActionRowBuilder<ButtonBuilder>[] = [];
if (!isSuccess && hasRollback) {
const rollbackButton = new ButtonBuilder()
.setCustomId("rollback_update")
.setLabel("Rollback")
.setEmoji("↩️")
.setStyle(ButtonStyle.Danger);
components.push(new ActionRowBuilder<ButtonBuilder>().addComponents(rollbackButton));
}
return { embeds: [embed], components };
}
export function getInstallingDependenciesEmbed() {
return createInfoEmbed(
"📦 Installing dependencies for root and web projects...\nThis may take a moment.",
"⏳ Installing Dependencies"
);
}
export function getRunningMigrationsEmbed() {
return createInfoEmbed(
"🗃️ Applying database migrations...",
"⏳ Running Migrations"
);
}
export function getRollbackSuccessEmbed(commit: string) {
return createSuccessEmbed(
`Successfully rolled back to commit \`${commit}\`.\nThe bot will restart now.`,
"↩️ Rollback Complete"
);
}
export function getRollbackFailedEmbed(error: string) {
return createErrorEmbed(
`Could not rollback:\n\`\`\`\n${error}\n\`\`\``,
"❌ Rollback Failed"
);
}

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