forked from syntaxbullet/aurorabot
Compare commits
129 Commits
3a620a84c5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cc2f61db6 | ||
|
|
f5fecb59cb | ||
|
|
65f5663c97 | ||
| de83307adc | |||
|
|
15e01906a3 | ||
|
|
fed27c0227 | ||
|
|
9751e62e30 | ||
|
|
87d5aa259c | ||
|
|
f0bfaecb0b | ||
|
|
9471b6fdab | ||
|
|
04e5851387 | ||
| 1a59c9e796 | |||
|
|
251616fe15 | ||
|
|
fbb2e0f010 | ||
|
|
dc10ad5c37 | ||
|
|
2381f073ba | ||
|
|
121c242168 | ||
|
|
942875e8d0 | ||
|
|
878e3306eb | ||
|
|
aca5538d57 | ||
|
|
f822d90dd3 | ||
|
|
141c3098f8 | ||
|
|
0c67a8754f | ||
|
|
bf20c61190 | ||
|
|
099601ce6d | ||
|
|
55d2376ca1 | ||
|
|
6eb4a32a12 | ||
|
|
2d35a5eabb | ||
|
|
570cdc69c1 | ||
|
|
c2b1fb6db1 | ||
|
|
d15d53e839 | ||
|
|
58374d1746 | ||
|
|
ae6a068197 | ||
|
|
43d32918ab | ||
|
|
0bc254b728 | ||
|
|
610d97bde3 | ||
|
|
babccfd08a | ||
|
|
ee7d63df3e | ||
|
|
5f107d03a7 | ||
|
|
1ff24b0f7f | ||
|
|
a5e3534260 | ||
|
|
228005322e | ||
|
|
67a3aa4b0f | ||
|
|
64804f7066 | ||
|
|
73ad889018 | ||
|
|
9c7f1e4418 | ||
|
|
efb50916b2 | ||
|
|
6abb52694e | ||
|
|
76968e31a6 | ||
|
|
29bf0e6f1c | ||
|
|
8c306fbd23 | ||
|
|
b0c3baf5b7 | ||
|
|
f575588b9a | ||
|
|
553b9b4952 | ||
|
|
073348fa55 | ||
|
|
4232674494 | ||
|
|
fbf1e52c28 | ||
|
|
20284dc57b | ||
|
|
36f9c76fa9 | ||
|
|
46e95ce7b3 | ||
|
|
9acd3f3d76 | ||
|
|
5e8683a19f | ||
|
|
ee088ad84b | ||
|
|
b18b5fab62 | ||
|
|
0b56486ab2 | ||
|
|
11c589b01c | ||
|
|
e4169d9dd5 | ||
|
|
1929f0dd1f | ||
|
|
db4e7313c3 | ||
|
|
1ffe397fbb | ||
|
|
34958aa220 | ||
|
|
109b36ffe2 | ||
|
|
cd954afe36 | ||
|
|
2b60883173 | ||
|
|
c2d67d7435 | ||
|
|
e252d6e00a | ||
|
|
95f1b4e04a | ||
|
|
62c6ca5e87 | ||
|
|
aac9be19f2 | ||
|
|
bb823c86c1 | ||
|
|
119301f1c3 | ||
|
|
9a2fc101da | ||
|
|
7049cbfd9d | ||
|
|
db859e8f12 | ||
|
|
5ff3fa9ab5 | ||
|
|
c8bf69a969 | ||
|
|
fee4969910 | ||
|
|
dabcb4cab3 | ||
|
|
1a3f5c6654 | ||
|
|
422db6479b | ||
|
|
35ecea16f7 | ||
|
|
9ff679ee5c | ||
|
|
ebefd8c0df | ||
|
|
73531f38ae | ||
|
|
5a6356d271 | ||
|
|
f9dafeac3b | ||
|
|
1a2bbb011c | ||
|
|
2ead35789d | ||
|
|
c1da71227d | ||
|
|
17e636c4e5 | ||
|
|
d7543d9f48 | ||
|
|
afe82c449b | ||
|
|
3c1334b30e | ||
|
|
58f261562a | ||
|
|
4ecbffd617 | ||
|
|
5491551544 | ||
|
|
7d658bbef9 | ||
|
|
d117bcb697 | ||
|
|
94e332ba57 | ||
|
|
3ef9773990 | ||
|
|
d243a11bd3 | ||
|
|
47ce0f12e6 | ||
|
|
f2caa1a3ee | ||
|
|
2a72beb0ef | ||
|
|
2f73f38877 | ||
|
|
9e5c6b5ac3 | ||
|
|
eb108695d3 | ||
|
|
7d541825d8 | ||
|
|
52f8ab11f0 | ||
|
|
f8436e9755 | ||
|
|
194a032c7f | ||
|
|
94a5a183d0 | ||
|
|
c7730b9355 | ||
|
|
1e20a5a7a0 | ||
|
|
54944283a3 | ||
|
|
f79ee6fbc7 | ||
|
|
915f1bc4ad | ||
|
|
4af2690bab | ||
|
|
6e57ab07e4 |
@@ -1,63 +0,0 @@
|
||||
---
|
||||
description: Converts conversational brain dumps into structured, metric-driven Markdown tickets in the ./tickets directory.
|
||||
---
|
||||
|
||||
# WORKFLOW: PRAGMATIC ARCHITECT TICKET GENERATOR
|
||||
|
||||
## 1. High-Level Goal
|
||||
Transform informal user "brain dumps" into high-precision, metric-driven engineering tickets stored as Markdown files in the `./tickets/` directory. The workflow enforces a quality gate via targeted inquiry before any file persistence occurs, ensuring all tasks are observable, measurable, and actionable.
|
||||
|
||||
## 2. Assumptions & Clarifications
|
||||
- **Assumptions:** The agent has write access to the `./tickets/` and `/temp/` directories. The current date is accessible for naming conventions. "Metrics" refer to quantifiable constraints (latency, line counts, status codes).
|
||||
- **Ambiguities:** If the user provides a second brain dump while a ticket is in progress, the agent will prioritize the current workflow until completion or explicit cancellation.
|
||||
|
||||
## 3. Stage Breakdown
|
||||
|
||||
### Stage 1: Discovery & Quality Gate
|
||||
- **Stage Name:** Requirement Analysis
|
||||
- **Purpose:** Analyze input for vagueness and enforce the "Quality Gate" by extracting metrics.
|
||||
- **Inputs:** Raw user brain dump (text).
|
||||
- **Actions:** 1. Identify "Known Unknowns" (vague terms like "fast," "better," "clean").
|
||||
2. Formulate exactly three (3) targeted questions to convert vague goals into comparable metrics.
|
||||
3. Check for logical inconsistencies in the request.
|
||||
- **Outputs:** Three questions presented to the user.
|
||||
- **Persistence Strategy:** Save the original brain dump and the three questions to `/temp/pending_ticket_state.json`.
|
||||
|
||||
### Stage 2: Drafting & Refinement
|
||||
- **Stage Name:** Ticket Drafting
|
||||
- **Purpose:** Synthesize the original dump and user answers into a structured Markdown draft.
|
||||
- **Inputs:** User responses to the three questions; `/temp/pending_ticket_state.json`.
|
||||
- **Actions:** 1. Construct a Markdown draft using the provided template.
|
||||
2. Generate a slug-based filename: `YYYYMMDD-slug.md`.
|
||||
3. Present the draft and filename to the user for review.
|
||||
- **Outputs:** Formatted Markdown text and suggested filename displayed in the chat.
|
||||
- **Persistence Strategy:** Update `/temp/pending_ticket_state.json` with the full Markdown content and the proposed filename.
|
||||
|
||||
### Stage 3: Execution & Persistence
|
||||
- **Stage Name:** Finalization
|
||||
- **Purpose:** Commit the approved ticket to the permanent `./tickets/` directory.
|
||||
- **Inputs:** User confirmation (e.g., "Go," "Approved"); `/temp/pending_ticket_state.json`.
|
||||
- **Actions:** 1. Write the finalized Markdown content to `./tickets/[filename]`.
|
||||
2. Delete the temporary state file in `/temp/`.
|
||||
- **Outputs:** Confirmation message containing the relative path to the new file.
|
||||
- **Persistence Strategy:** Permanent write to `./tickets/`.
|
||||
|
||||
## 4. Data & File Contracts
|
||||
- **State File:** `/temp/pending_ticket_state.json`
|
||||
- Schema: `{ "original_input": string, "questions": string[], "answers": string[], "draft_content": string, "filename": string, "step": integer }`
|
||||
- **Output File:** `./tickets/YYYYMMDD-[slug].md`
|
||||
- Format: Markdown
|
||||
- Sections: `# Title`, `## Context`, `## Acceptance Criteria`, `## Suggested Affected Files`, `## Technical Constraints`.
|
||||
|
||||
## 5. Failure & Recovery Handling
|
||||
- **Incomplete Inputs:** If the user fails to answer the 3 questions, the agent must politely restate that metrics are required for high-precision engineering and repeat the questions.
|
||||
- **Inconsistencies:** If the user’s answers contradict the original dump, the agent must flag the contradiction and ask for a tie-break before drafting.
|
||||
- **Missing Directory:** If `./tickets/` does not exist during Stage 3, the agent must attempt to create it before writing the file.
|
||||
|
||||
## 6. Final Deliverable Specification
|
||||
- **Format:** A valid Markdown file in the `./tickets/` folder.
|
||||
- **Quality Bar:**
|
||||
- Zero fluff in the Context section.
|
||||
- All Acceptance Criteria must be binary (pass/fail) or metric-based.
|
||||
- Filename must strictly follow `YYYYMMDD-slug.md` (e.g., `20240520-auth-refactor.md`).
|
||||
- No "Status" or "Priority" fields.
|
||||
@@ -1,89 +0,0 @@
|
||||
---
|
||||
description: Analyzes the codebase to find dependencies and side effects related to a specific ticket.
|
||||
---
|
||||
|
||||
# WORKFLOW: Dependency Architect & Blast Radius Analysis
|
||||
|
||||
## 1. High-Level Goal
|
||||
Perform a deterministic "Blast Radius" analysis for a code change defined in a Jira/Linear-style ticket. The agent will identify direct consumers, side effects, and relevant test suites, then append a structured "Impact Analysis" section to the original ticket file to guide developers and ensure high-velocity execution without regressions.
|
||||
|
||||
## 2. Assumptions & Clarifications
|
||||
- **Location:** Tickets are stored in the `./tickets/` directory as Markdown files.
|
||||
- **Code Access:** The agent has full read access to the project root and subdirectories.
|
||||
- **Scope:** Dependency tracing is limited to "one level deep" (direct imports/references) unless a global configuration or core database schema change is detected.
|
||||
- **Ambiguity Handling:** If "Suggested Affected Files" are missing from the ticket, the agent will attempt to infer them from the "Acceptance Criteria" logic; if inference is impossible, the agent will halt and request the file list.
|
||||
|
||||
## 3. Stage Breakdown
|
||||
|
||||
### Stage 1: Ticket Parsing & Context Extraction
|
||||
- **Purpose:** Extract the specific files and logic constraints requiring analysis.
|
||||
- **Inputs:** A specific ticket filename (e.g., `./tickets/TASK-123.md`).
|
||||
- **Actions:**
|
||||
1. Read the ticket file.
|
||||
2. Extract the list of "Suggested Affected Files".
|
||||
3. Extract keywords and logic from the "Acceptance Criteria".
|
||||
4. Validate that all "Suggested Affected Files" exist in the current codebase.
|
||||
- **Outputs:** A JSON object containing the target file list and key logic requirements.
|
||||
- **Persistence Strategy:** Save extracted data to `/temp/context.json`.
|
||||
|
||||
### Stage 2: Recursive Dependency Mapping
|
||||
- **Purpose:** Identify which external modules rely on the target files.
|
||||
- **Inputs:** `/temp/context.json`.
|
||||
- **Actions:**
|
||||
1. For each file in the target list, perform a search (e.g., `grep` or AST walk) for import statements or references in the rest of the codebase.
|
||||
2. Filter out internal references within the same module (focus on external consumers).
|
||||
3. Detect if the change involves shared utilities (e.g., `utils/`, `common/`) or database schemas (e.g., `prisma/schema.prisma`).
|
||||
- **Outputs:** A list of unique consumer file paths and their specific usage context.
|
||||
- **Persistence Strategy:** Save findings to `/temp/dependencies.json`.
|
||||
|
||||
### Stage 3: Test Suite Identification
|
||||
- **Purpose:** Locate the specific test files required to validate the change.
|
||||
- **Inputs:** `/temp/context.json` and `/temp/dependencies.json`.
|
||||
- **Actions:**
|
||||
1. Search for files following patterns: `[filename].test.ts`, `[filename].spec.js`, or within `__tests__` folders related to affected files.
|
||||
2. Identify integration or E2E tests that cover the consumer paths identified in Stage 2.
|
||||
- **Outputs:** A list of relevant test file paths.
|
||||
- **Persistence Strategy:** Save findings to `/temp/tests.json`.
|
||||
|
||||
### Stage 4: Risk Hotspot Synthesis
|
||||
- **Purpose:** Interpret raw dependency data into actionable risk warnings.
|
||||
- **Inputs:** All files in `/temp/`.
|
||||
- **Actions:**
|
||||
1. Analyze the volume of consumers; if a file has >5 consumers, flag it as a "High Impact Hotspot."
|
||||
2. Check for breaking contract changes (e.g., interface modifications) based on the "Acceptance Criteria".
|
||||
3. Formulate specific "Risk Hotspot" warnings (e.g., "Changing Auth interface affects 12 files; consider a wrapper.").
|
||||
- **Outputs:** A structured Markdown-ready report object.
|
||||
- **Persistence Strategy:** Save final report data to `/temp/final_analysis.json`.
|
||||
|
||||
### Stage 5: Ticket Augmentation & Finalization
|
||||
- **Purpose:** Update the physical ticket file with findings.
|
||||
- **Inputs:** Original ticket file and `/temp/final_analysis.json`.
|
||||
- **Actions:**
|
||||
1. Read the current content of the ticket file.
|
||||
2. Generate a Markdown section titled `## Impact Analysis (Generated: 2026-01-09)`.
|
||||
3. Append the Direct Consumers, Test Coverage, and Risk Hotspots sections.
|
||||
4. Write the combined content back to the original file path.
|
||||
- **Outputs:** Updated Markdown ticket.
|
||||
- **Persistence Strategy:** None (Final Action).
|
||||
|
||||
## 4. Data & File Contracts
|
||||
- **State File (`/temp/state.json`):** - `affected_files`: string[]
|
||||
- `consumers`: { path: string, context: string }[]
|
||||
- `tests`: string[]
|
||||
- `risks`: string[]
|
||||
- **File Format:** All `/temp` files must be valid JSON.
|
||||
- **Ticket Format:** Standard Markdown. Use `###` for sub-headers in the generated section.
|
||||
|
||||
## 5. Failure & Recovery Handling
|
||||
- **Missing Ticket:** If the ticket path is invalid, exit immediately with error: "TICKET_NOT_FOUND".
|
||||
- **Zero Consumers Found:** If no external consumers are found, state "No external dependencies detected" in the report; do not fail.
|
||||
- **Broken Imports:** If AST parsing fails due to syntax errors in the codebase, fallback to `grep` for string-based matching.
|
||||
- **Write Permission:** If the ticket file is read-only, output the final Markdown to the console and provide a warning.
|
||||
|
||||
## 6. Final Deliverable Specification
|
||||
- **Format:** The original ticket file must be modified in-place.
|
||||
- **Content:**
|
||||
- **Direct Consumers:** Bulleted list of `[File Path]: [Usage description]`.
|
||||
- **Test Coverage:** Bulleted list of `[File Path]`.
|
||||
- **Risk Hotspots:** Clear, one-sentence warnings for high-risk areas.
|
||||
- **Quality Bar:** No hallucinations. Every file path listed must exist in the repository. No deletions of original ticket content.
|
||||
@@ -1,72 +0,0 @@
|
||||
---
|
||||
description: Performs a high-intensity, "hostile" technical audit of the provided code.
|
||||
---
|
||||
|
||||
# WORKFLOW: HOSTILE TECHNICAL AUDIT & SECURITY REVIEW
|
||||
|
||||
## 1. High-Level Goal
|
||||
Execute a multi-pass, hyper-critical technical audit of provided source code to identify fatal logic flaws, security vulnerabilities, and architectural debt. The agent acts as a hostile reviewer with a "guilty until proven innocent" mindset, aiming to justify a REJECTED verdict unless the code demonstrates exceptional robustness and simplicity.
|
||||
|
||||
## 2. Assumptions & Clarifications
|
||||
- **Assumption:** The user will provide either raw code snippets or paths to files within the agent's accessible environment.
|
||||
- **Assumption:** The agent has access to `/temp/` for multi-stage state persistence.
|
||||
- **Clarification:** If a "ticket description" or "requirement" is not provided, the agent will infer intent from the code but must flag "Lack of Context" as a potential risk.
|
||||
- **Clarification:** "Hostile" refers to a rigorous, zero-tolerance standard, not unprofessional language.
|
||||
|
||||
## 3. Stage Breakdown
|
||||
|
||||
### Stage 1: Contextual Ingestion & Dependency Mapping
|
||||
- **Purpose:** Map the attack surface and understand the logical flow before the audit.
|
||||
- **Inputs:** Target source code files.
|
||||
- **Actions:** - Identify all external dependencies and entry points.
|
||||
- Map data flow from input to storage/output.
|
||||
- Identify "High-Risk Zones" (e.g., auth logic, DB queries, memory management).
|
||||
- **Outputs:** A structured map of the code's architecture.
|
||||
- **Persistence Strategy:** Save `audit_map.json` to `/temp/` containing the file list and identified High-Risk Zones.
|
||||
|
||||
### Stage 2: Security & Logic Stress Test (The "Hostile" Pass)
|
||||
- **Purpose:** Identify reasons to reject the code based on security and logical integrity.
|
||||
- **Inputs:** `/temp/audit_map.json` and source code.
|
||||
- **Actions:**
|
||||
- Scan for injection, race conditions, and improper state handling.
|
||||
- Simulate edge cases: null inputs, buffer overflows, and malformed data.
|
||||
- Evaluate "Silent Failures": Does the code swallow exceptions or fail to log critical errors?
|
||||
- **Outputs:** List of fatal flaws and security risks.
|
||||
- **Persistence Strategy:** Save `vulnerabilities.json` to `/temp/`.
|
||||
|
||||
### Stage 3: Performance & Velocity Debt Assessment
|
||||
- **Purpose:** Evaluate the "Pragmatic Performance" and maintainability of the implementation.
|
||||
- **Inputs:** Source code and `/temp/vulnerabilities.json`.
|
||||
- **Actions:**
|
||||
- Identify redundant API calls or unnecessary allocations.
|
||||
- Flag "Over-Engineering" (unnecessary abstractions) vs. "Lazy Code" (hardcoded values).
|
||||
- Identify missing unit test scenarios for identified edge cases.
|
||||
- **Outputs:** List of optimization debt and missing test scenarios.
|
||||
- **Persistence Strategy:** Save `debt_and_tests.json` to `/temp/`.
|
||||
|
||||
### Stage 4: Synthesis & Verdict Generation
|
||||
- **Purpose:** Compile all findings into the final "Hostile Audit" report.
|
||||
- **Inputs:** `/temp/vulnerabilities.json` and `/temp/debt_and_tests.json`.
|
||||
- **Actions:**
|
||||
- Consolidate all findings into the mandated "Response Format."
|
||||
- Apply the "Burden of Proof" rule: If any Fatal Flaws or Security Risks exist, the verdict is REJECTED.
|
||||
- Ensure no sycophantic language is present.
|
||||
- **Outputs:** Final Audit Report.
|
||||
- **Persistence Strategy:** Final output is delivered to the user; `/temp/` files may be purged.
|
||||
|
||||
## 4. Data & File Contracts
|
||||
- **Filename:** `/temp/audit_context.json` | **Schema:** `{ "high_risk_zones": [], "entry_points": [] }`
|
||||
- **Filename:** `/temp/findings.json` | **Schema:** `{ "fatal_flaws": [], "security_risks": [], "debt": [], "missing_tests": [] }`
|
||||
- **Final Report Format:** Markdown with specific headers: `## 🛑 FATAL FLAWS`, `## ⚠️ SECURITY & VULNERABILITIES`, `## 📉 VELOCITY DEBT`, `## 🧪 MISSING TESTS`, and `### VERDICT`.
|
||||
|
||||
## 5. Failure & Recovery Handling
|
||||
- **Incomplete Input:** If the code is snippet-based and missing context, the agent must assume the worst-case scenario for the missing parts and flag them as "Critical Unknowns."
|
||||
- **Stage Failure:** If a specific file cannot be parsed, log the error in the `findings.json` and proceed with the remaining files.
|
||||
- **Clarification:** The agent will NOT ask for clarification mid-audit. It will make a "hostile assumption" and document it as a risk.
|
||||
|
||||
## 6. Final Deliverable Specification
|
||||
- **Tone:** Senior Security Auditor. Clinical, critical, and direct.
|
||||
- **Acceptance Criteria:** - No "Good job" or introductory filler.
|
||||
- Every flaw must include [Why it fails] and [How to fix it].
|
||||
- Verdict must be REJECTED unless the code is "solid" (simple, robust, and secure).
|
||||
- Must identify at least one specific edge case for the "Missing Tests" section.
|
||||
@@ -1,99 +0,0 @@
|
||||
---
|
||||
description: Work on a ticket
|
||||
---
|
||||
|
||||
# WORKFLOW: Automated Feature Implementation and Review Cycle
|
||||
|
||||
## 1. High-Level Goal
|
||||
The objective of this workflow is to autonomously ingest a task from a local `/tickets` directory, establish a dedicated development environment via Git branching, implement the requested changes with incremental commits, validate the work through an internal review process, and finalize the lifecycle by cleaning up ticket artifacts and seeking user authorization for the final merge.
|
||||
|
||||
---
|
||||
|
||||
## 2. Assumptions & Clarifications
|
||||
- **Assumptions:**
|
||||
- The `/tickets` directory contains one or more files representing tasks (e.g., `.md` or `.txt`).
|
||||
- The agent has authenticated access to the local Git repository.
|
||||
- A "Review Workflow" exists as an executable command or internal process.
|
||||
- The branch naming convention is `feature/[ticket-filename-slug]`.
|
||||
- **Ambiguities:**
|
||||
- If multiple tickets exist, the agent will select the one with the earliest "Last Modified" timestamp.
|
||||
- "Regular commits" are defined as committing after every logically complete file change or functional milestone.
|
||||
|
||||
---
|
||||
|
||||
## 3. Stage Breakdown
|
||||
|
||||
### Stage 1: Ticket Selection and Branch Initialization
|
||||
- **Purpose:** Identify the next task and prepare the workspace.
|
||||
- **Inputs:** Contents of the `/tickets` directory.
|
||||
- **Actions:**
|
||||
1. Scan `/tickets` and select the oldest file.
|
||||
2. Parse the ticket content to understand requirements.
|
||||
3. Ensure the current working directory is a Git repository.
|
||||
4. Create and switch to a new branch: `feature/[ticket-id]`.
|
||||
- **Outputs:** Active feature branch.
|
||||
- **Persistence Strategy:** Save `state.json` to `/temp` containing `ticket_path`, `branch_name`, and `start_time`.
|
||||
|
||||
### Stage 2: Implementation and Incremental Committing
|
||||
- **Purpose:** Execute the technical requirements of the ticket.
|
||||
- **Inputs:** `/temp/state.json`, Ticket requirements.
|
||||
- **Actions:**
|
||||
1. Modify codebase according to requirements.
|
||||
2. For every distinct file change or logical unit of work:
|
||||
- Run basic syntax checks.
|
||||
- Execute `git add [file]`.
|
||||
- Execute `git commit -m "feat: [brief description of change]"`
|
||||
3. Repeat until the feature is complete.
|
||||
- **Outputs:** Committed code changes on the feature branch.
|
||||
- **Persistence Strategy:** Update `state.json` with `implementation_complete: true` and a list of `modified_files`.
|
||||
|
||||
### Stage 3: Review Workflow Execution
|
||||
- **Purpose:** Validate the implementation against quality standards.
|
||||
- **Inputs:** `/temp/state.json`, Modified codebase.
|
||||
- **Actions:**
|
||||
1. Trigger the "Review Workflow" (static analysis, tests, or linter).
|
||||
2. If errors are found:
|
||||
- Log errors to `/temp/review_log.txt`.
|
||||
- Re-enter Stage 2 to apply fixes and commit.
|
||||
3. If review passes:
|
||||
- Proceed to Stage 4.
|
||||
- **Outputs:** Review results/logs.
|
||||
- **Persistence Strategy:** Update `state.json` with `review_passed: true`.
|
||||
|
||||
### Stage 4: Cleanup and User Handoff
|
||||
- **Purpose:** Finalize the ticket lifecycle and request merge permission.
|
||||
- **Inputs:** `/temp/state.json`.
|
||||
- **Actions:**
|
||||
1. Delete the ticket file from `/tickets` using the path stored in `state.json`.
|
||||
2. Format a summary of changes and a request for merge.
|
||||
- **Outputs:** Deletion of the ticket file; user-facing summary.
|
||||
- **Persistence Strategy:** Clear `/temp/state.json` upon successful completion.
|
||||
|
||||
---
|
||||
|
||||
## 4. Data & File Contracts
|
||||
- **State File:** `/temp/state.json`
|
||||
- Format: JSON
|
||||
- Schema: `{ "ticket_path": string, "branch_name": string, "implementation_complete": boolean, "review_passed": boolean }`
|
||||
- **Ticket Files:** Located in `/tickets/*` (Markdown or Plain Text).
|
||||
- **Logs:** `/temp/review_log.txt` (Plain Text) for capturing stderr from review tools.
|
||||
|
||||
---
|
||||
|
||||
## 5. Failure & Recovery Handling
|
||||
- **Empty Ticket Directory:** If no files are found in `/tickets`, the agent will output "NO_TICKETS_FOUND" and terminate the workflow.
|
||||
- **Commit Failures:** If a commit fails (e.g., pre-commit hooks), the agent must resolve the hook violation before retrying the commit.
|
||||
- **Review Failure Loop:** If the review fails more than 3 times for the same issue, the agent must halt and output a "BLOCKER_REPORT" detailing the persistent errors to the user.
|
||||
- **State Recovery:** On context reset, the agent must check `/temp/state.json` to resume the workflow from the last recorded stage.
|
||||
|
||||
---
|
||||
|
||||
## 6. Final Deliverable Specification
|
||||
- **Final Output:** A clear message to the user in the following format:
|
||||
> **Task Completed:** [Ticket Name]
|
||||
> **Branch:** [Branch Name]
|
||||
> **Changes:** [Brief list of modified files]
|
||||
> **Review Status:** Passed
|
||||
> **Cleanup:** Ticket file removed from /tickets.
|
||||
> **Action Required:** Would you like me to merge [Branch Name] into `main`? (Yes/No)
|
||||
- **Quality Bar:** Code must be committed with descriptive messages; the ticket file must be successfully deleted; the workspace must be left on the feature branch awaiting the merge command.
|
||||
7
.citrine
Normal file
7
.citrine
Normal file
@@ -0,0 +1,7 @@
|
||||
### Frontend
|
||||
[8bb0] [>] implement items page
|
||||
[de51] [ ] implement classes page
|
||||
[d108] [ ] implement quests page
|
||||
[8bbe] [ ] implement lootdrops page
|
||||
[094e] [ ] implement moderation page
|
||||
[220d] [ ] implement transactions page
|
||||
39
.dockerignore
Normal file
39
.dockerignore
Normal file
@@ -0,0 +1,39 @@
|
||||
# Dependencies - handled inside container
|
||||
node_modules
|
||||
web/node_modules
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Logs and data
|
||||
logs
|
||||
*.log
|
||||
shared/db/data
|
||||
shared/db/log
|
||||
|
||||
# Development tools
|
||||
.env
|
||||
.env.example
|
||||
.opencode
|
||||
.agent
|
||||
|
||||
# Documentation
|
||||
docs
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Build artifacts
|
||||
dist
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
25
.env.example
25
.env.example
@@ -1,12 +1,33 @@
|
||||
# =============================================================================
|
||||
# Aurora Environment Configuration
|
||||
# =============================================================================
|
||||
# Copy this file to .env and update with your values
|
||||
# For production, see .env.prod.example with security recommendations
|
||||
# =============================================================================
|
||||
|
||||
# Database
|
||||
# For production: use a strong password (openssl rand -base64 32)
|
||||
DB_USER=aurora
|
||||
DB_PASSWORD=aurora
|
||||
DB_NAME=aurora
|
||||
DB_PORT=5432
|
||||
DB_HOST=db
|
||||
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
|
||||
|
||||
# Discord
|
||||
# Get from: https://discord.com/developers/applications
|
||||
DISCORD_BOT_TOKEN=your-discord-bot-token
|
||||
DISCORD_CLIENT_ID=your-discord-client-id
|
||||
DISCORD_GUILD_ID=your-discord-guild-id
|
||||
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
|
||||
|
||||
VPS_USER=your-vps-user
|
||||
# Admin Panel (Discord OAuth)
|
||||
# Get client secret from: https://discord.com/developers/applications → OAuth2
|
||||
DISCORD_CLIENT_SECRET=your-discord-client-secret
|
||||
SESSION_SECRET=change-me-to-a-random-string
|
||||
ADMIN_USER_IDS=123456789012345678
|
||||
PANEL_BASE_URL=http://localhost:3000
|
||||
|
||||
# Server (for remote access scripts)
|
||||
# Use a non-root user (see shared/scripts/setup-server.sh)
|
||||
VPS_USER=deploy
|
||||
VPS_HOST=your-vps-ip
|
||||
|
||||
38
.env.prod.example
Normal file
38
.env.prod.example
Normal file
@@ -0,0 +1,38 @@
|
||||
# =============================================================================
|
||||
# Aurora Production Environment Template
|
||||
# =============================================================================
|
||||
# Copy this file to .env and fill in the values
|
||||
# IMPORTANT: Use strong, unique passwords in production!
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
# Generate strong password: openssl rand -base64 32
|
||||
DB_USER=aurora_prod
|
||||
DB_PASSWORD=CHANGE_ME_USE_STRONG_PASSWORD
|
||||
DB_NAME=aurora_prod
|
||||
DB_PORT=5432
|
||||
DB_HOST=localhost
|
||||
|
||||
# Constructed database URL (used by Drizzle)
|
||||
DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_NAME}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Discord Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
# Get these from Discord Developer Portal: https://discord.com/developers
|
||||
DISCORD_BOT_TOKEN=your_bot_token_here
|
||||
DISCORD_CLIENT_ID=your_client_id_here
|
||||
DISCORD_GUILD_ID=your_guild_id_here
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Server Configuration (for SSH deployment scripts)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Use a non-root user for security!
|
||||
VPS_USER=deploy
|
||||
VPS_HOST=your_server_ip_here
|
||||
|
||||
# Optional: Custom ports for remote access
|
||||
# DASHBOARD_PORT=3000
|
||||
# STUDIO_PORT=4983
|
||||
6
.env.test
Normal file
6
.env.test
Normal file
@@ -0,0 +1,6 @@
|
||||
DATABASE_URL="postgresql://auroradev:auroradev123@localhost:5432/aurora_test"
|
||||
DISCORD_BOT_TOKEN="test_token"
|
||||
DISCORD_CLIENT_ID="123456789"
|
||||
DISCORD_GUILD_ID="123456789"
|
||||
ADMIN_TOKEN="admin_token_123"
|
||||
LOG_LEVEL="error"
|
||||
100
.github/workflows/deploy.yml
vendored
Normal file
100
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
# Aurora CI/CD Pipeline
|
||||
# Builds, tests, and deploys to production server
|
||||
|
||||
name: Deploy to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
# ==========================================================================
|
||||
# Test Job
|
||||
# ==========================================================================
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: aurora_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install Dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Create Config File
|
||||
run: |
|
||||
mkdir -p shared/config
|
||||
cat <<EOF > shared/config/config.json
|
||||
{
|
||||
"leveling": { "base": 100, "exponent": 2.5, "chat": { "cooldownMs": 60000, "minXp": 15, "maxXp": 25 } },
|
||||
"economy": {
|
||||
"daily": { "amount": "100", "streakBonus": "10", "weeklyBonus": "50", "cooldownMs": 86400000 },
|
||||
"transfers": { "allowSelfTransfer": false, "minAmount": "1" },
|
||||
"exam": { "multMin": 0.05, "multMax": 0.03 }
|
||||
},
|
||||
"inventory": { "maxStackSize": "99", "maxSlots": 50 },
|
||||
"commands": {},
|
||||
"lootdrop": {
|
||||
"activityWindowMs": 120000, "minMessages": 1, "spawnChance": 1, "cooldownMs": 3000,
|
||||
"reward": { "min": 40, "max": 150, "currency": "Astral Units" }
|
||||
},
|
||||
"studentRole": "123", "visitorRole": "456", "colorRoles": [],
|
||||
"moderation": {
|
||||
"prune": { "maxAmount": 100, "confirmThreshold": 50, "batchSize": 100, "batchDelayMs": 1000 },
|
||||
"cases": { "dmOnWarn": false }
|
||||
},
|
||||
"trivia": {
|
||||
"entryFee": "50", "rewardMultiplier": 1.5, "timeoutSeconds": 30, "cooldownMs": 60000,
|
||||
"categories": [], "difficulty": "random"
|
||||
},
|
||||
"system": {}
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Setup Test Database
|
||||
run: bun run db:push:local
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/aurora_test
|
||||
# Create .env.test for implicit usage by bun
|
||||
DISCORD_BOT_TOKEN: test_token
|
||||
DISCORD_CLIENT_ID: 123
|
||||
DISCORD_GUILD_ID: 123
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
# Create .env.test for test-sequential.sh / bun test
|
||||
cat <<EOF > .env.test
|
||||
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/aurora_test"
|
||||
DISCORD_BOT_TOKEN="test_token"
|
||||
DISCORD_CLIENT_ID="123456789"
|
||||
DISCORD_GUILD_ID="123456789"
|
||||
ADMIN_TOKEN="admin_token_123"
|
||||
LOG_LEVEL="error"
|
||||
EOF
|
||||
bash shared/scripts/test-sequential.sh --integration
|
||||
env:
|
||||
NODE_ENV: test
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,7 +1,9 @@
|
||||
.env
|
||||
node_modules
|
||||
docker-compose.override.yml
|
||||
shared/db-logs
|
||||
shared/db/data
|
||||
shared/db/backups
|
||||
shared/db/loga
|
||||
.cursor
|
||||
# dependencies (bun install)
|
||||
@@ -44,4 +46,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
src/db/data
|
||||
src/db/log
|
||||
scratchpad/
|
||||
scratchpad/
|
||||
bot/assets/graphics/items
|
||||
tickets/
|
||||
.citrine.local
|
||||
|
||||
257
AGENTS.md
Normal file
257
AGENTS.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# AGENTS.md - AI Coding Agent Guidelines
|
||||
|
||||
## Project Overview
|
||||
|
||||
AuroraBot is a Discord bot with a REST API built using Bun, Discord.js, and PostgreSQL with Drizzle ORM.
|
||||
|
||||
## Build/Lint/Test Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
bun --watch bot/index.ts # Run bot + API server with hot reload
|
||||
|
||||
# Testing
|
||||
bun test # Run all tests
|
||||
bun test path/to/file.test.ts # Run single test file
|
||||
bun test --watch # Watch mode
|
||||
bun test shared/modules/economy # Run tests in directory
|
||||
|
||||
# Database
|
||||
bun run generate # Generate Drizzle migrations (Docker)
|
||||
bun run migrate # Run migrations (Docker)
|
||||
bun run db:push # Push schema changes (Docker)
|
||||
bun run db:push:local # Push schema changes (local)
|
||||
bun run db:studio # Open Drizzle Studio
|
||||
|
||||
# Docker (recommended for local dev)
|
||||
docker compose up # Start bot, API, and database
|
||||
docker compose up app # Start just the app (bot + API)
|
||||
docker compose up db # Start just the database
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
bot/ # Discord bot
|
||||
├── commands/ # Slash commands by category
|
||||
├── events/ # Discord event handlers
|
||||
├── lib/ # Bot core (BotClient, handlers, loaders)
|
||||
├── modules/ # Feature modules (views, interactions)
|
||||
└── graphics/ # Canvas image generation
|
||||
|
||||
shared/ # Shared between bot and web
|
||||
├── db/ # Database schema and migrations
|
||||
├── lib/ # Utils, config, errors, types
|
||||
└── modules/ # Domain services (economy, user, etc.)
|
||||
|
||||
web/ # API server
|
||||
└── src/routes/ # API route handlers
|
||||
```
|
||||
|
||||
## Import Conventions
|
||||
|
||||
Use path aliases defined in tsconfig.json:
|
||||
|
||||
```typescript
|
||||
// External packages first
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
// Path aliases second
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { users } from "@db/schema";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { handleTradeInteraction } from "@modules/trade/trade.interaction";
|
||||
|
||||
// Relative imports last
|
||||
import { localHelper } from "./helper";
|
||||
```
|
||||
|
||||
**Available Aliases:**
|
||||
|
||||
- `@/*` - bot/
|
||||
- `@shared/*` - shared/
|
||||
- `@db/*` - shared/db/
|
||||
- `@lib/*` - bot/lib/
|
||||
- `@modules/*` - bot/modules/
|
||||
- `@commands/*` - bot/commands/
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
| Element | Convention | Example |
|
||||
| ---------------- | ----------------------- | ---------------------------------------- |
|
||||
| Files | camelCase or kebab-case | `BotClient.ts`, `economy.service.ts` |
|
||||
| Classes | PascalCase | `CommandHandler`, `UserError` |
|
||||
| Functions | camelCase | `createCommand`, `handleShopInteraction` |
|
||||
| Constants | UPPER_SNAKE_CASE | `EVENTS`, `BRANDING` |
|
||||
| Enums | PascalCase | `TimerType`, `TransactionType` |
|
||||
| Services | camelCase singleton | `economyService`, `userService` |
|
||||
| Types/Interfaces | PascalCase | `Command`, `Event`, `GameConfigType` |
|
||||
| DB tables | snake_case | `users`, `moderation_cases` |
|
||||
| Custom IDs | snake_case with prefix | `shop_buy_`, `trade_accept_` |
|
||||
|
||||
## Code Patterns
|
||||
|
||||
### Command Definition
|
||||
|
||||
```typescript
|
||||
export const commandName = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("commandname")
|
||||
.setDescription("Description"),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
// Implementation
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Service Pattern (Singleton Object)
|
||||
|
||||
```typescript
|
||||
export const serviceName = {
|
||||
methodName: async (params: ParamType): Promise<ReturnType> => {
|
||||
return await withTransaction(async (tx) => {
|
||||
// Database operations
|
||||
});
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Module File Organization
|
||||
|
||||
- `*.view.ts` - Creates Discord embeds/components
|
||||
- `*.interaction.ts` - Handles button/select/modal interactions
|
||||
- `*.types.ts` - Module-specific TypeScript types
|
||||
- `*.service.ts` - Business logic (in shared/modules/)
|
||||
- `*.test.ts` - Test files (co-located with source)
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Custom Error Classes
|
||||
|
||||
```typescript
|
||||
import { UserError, SystemError } from "@shared/lib/errors";
|
||||
|
||||
// User-facing errors (shown to user)
|
||||
throw new UserError("You don't have enough coins!");
|
||||
|
||||
// System errors (logged, generic message shown)
|
||||
throw new SystemError("Database connection failed");
|
||||
```
|
||||
|
||||
### Recommended: `withCommandErrorHandling`
|
||||
|
||||
Use the `withCommandErrorHandling` utility from `@lib/commandUtils` to standardize
|
||||
error handling across all commands. It handles `deferReply`, `UserError` display,
|
||||
and unexpected error logging automatically.
|
||||
|
||||
```typescript
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const myCommand = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("mycommand")
|
||||
.setDescription("Does something"),
|
||||
execute: async (interaction) => {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const result = await service.method();
|
||||
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
||||
},
|
||||
{ ephemeral: true } // optional: makes the deferred reply ephemeral
|
||||
);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Options:
|
||||
- `ephemeral` — whether `deferReply` should be ephemeral
|
||||
- `successMessage` — a simple string to send on success
|
||||
- `onSuccess` — a callback invoked with the operation result
|
||||
```
|
||||
|
||||
## Database Patterns
|
||||
|
||||
### Transaction Usage
|
||||
|
||||
```typescript
|
||||
import { withTransaction } from "@/lib/db";
|
||||
|
||||
return await withTransaction(async (tx) => {
|
||||
const user = await tx.query.users.findFirst({
|
||||
where: eq(users.id, discordId),
|
||||
});
|
||||
|
||||
await tx
|
||||
.update(users)
|
||||
.set({ coins: newBalance })
|
||||
.where(eq(users.id, discordId));
|
||||
await tx.insert(transactions).values({ userId: discordId, amount, type });
|
||||
|
||||
return user;
|
||||
}, existingTx); // Pass existing tx if in nested transaction
|
||||
```
|
||||
|
||||
### Schema Notes
|
||||
|
||||
- Use `bigint` mode for Discord IDs and currency amounts
|
||||
- Relations defined separately from table definitions
|
||||
- Schema modules: `shared/db/schema/*.ts` (users, inventory, economy, quests, moderation)
|
||||
|
||||
## Testing
|
||||
|
||||
### Test File Structure
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||
|
||||
// Mock modules BEFORE imports
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: { query: mockQuery },
|
||||
}));
|
||||
|
||||
describe("serviceName", () => {
|
||||
beforeEach(() => {
|
||||
mockFn.mockClear();
|
||||
});
|
||||
|
||||
it("should handle expected case", async () => {
|
||||
// Arrange
|
||||
mockFn.mockResolvedValue(testData);
|
||||
|
||||
// Act
|
||||
const result = await service.method(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(expected);
|
||||
expect(mockFn).toHaveBeenCalledWith(expectedArgs);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime:** Bun 1.0+
|
||||
- **Bot:** Discord.js 14.x
|
||||
- **Web:** Bun HTTP Server (REST API)
|
||||
- **Database:** PostgreSQL 16+ with Drizzle ORM
|
||||
- **UI:** Discord embeds and components
|
||||
- **Validation:** Zod
|
||||
- **Testing:** Bun Test
|
||||
- **Container:** Docker
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
| Purpose | File |
|
||||
| ------------- | ---------------------- |
|
||||
| Bot entry | `bot/index.ts` |
|
||||
| DB schema | `shared/db/schema.ts` |
|
||||
| Error classes | `shared/lib/errors.ts` |
|
||||
| Config loader | `shared/lib/config.ts` |
|
||||
| Environment | `shared/lib/env.ts` |
|
||||
| Embed helpers | `bot/lib/embeds.ts` |
|
||||
| Command utils | `shared/lib/utils.ts` |
|
||||
| Error handler | `bot/lib/commandUtils.ts` |
|
||||
187
CLAUDE.md
Normal file
187
CLAUDE.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
bun --watch bot/index.ts # Run bot + API with hot reload
|
||||
docker compose up # Start all services (bot, API, database)
|
||||
docker compose up app # Start just the app (bot + API)
|
||||
docker compose up db # Start just the database
|
||||
|
||||
# Testing
|
||||
bun test # Run all tests
|
||||
bun test path/to/file.test.ts # Run a single test file
|
||||
bun test shared/modules/economy # Run tests in a directory
|
||||
bun test --watch # Watch mode
|
||||
|
||||
# Database
|
||||
bun run db:push:local # Push schema changes (local)
|
||||
bun run db:studio # Open Drizzle Studio (localhost:4983)
|
||||
bun run generate # Generate Drizzle migrations (Docker)
|
||||
bun run migrate # Apply migrations (Docker)
|
||||
|
||||
# Admin Panel
|
||||
bun run panel:dev # Start Vite dev server for dashboard
|
||||
bun run panel:build # Build React dashboard for production
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Aurora is a Discord RPG bot + REST API running as a **single Bun process**. The bot and API share the same database client and services.
|
||||
|
||||
```
|
||||
bot/ # Discord bot
|
||||
├── commands/ # Slash commands by category (admin, economy, inventory, etc.)
|
||||
├── events/ # Discord event handlers
|
||||
├── lib/ # BotClient, handlers, loaders, embed helpers, commandUtils
|
||||
├── modules/ # Feature modules (views, interactions per domain)
|
||||
└── graphics/ # Canvas-based image generation (@napi-rs/canvas)
|
||||
|
||||
shared/ # Shared between bot and API
|
||||
├── db/ # Drizzle ORM client + schema (users, economy, inventory, quests, etc.)
|
||||
├── lib/ # env, config, errors, logger, types, utils
|
||||
└── modules/ # Domain services (economy, user, inventory, quest, moderation, etc.)
|
||||
|
||||
api/ # REST API (Bun HTTP server)
|
||||
└── src/routes/ # Route handlers for each domain
|
||||
|
||||
panel/ # React admin dashboard (Vite + Tailwind + Radix UI)
|
||||
```
|
||||
|
||||
**Key architectural details:**
|
||||
- Bot and API both import from `shared/` — do not duplicate logic.
|
||||
- Services in `shared/modules/` are singleton objects, not classes.
|
||||
- The database uses PostgreSQL 16+ via Drizzle ORM with `bigint` mode for Discord IDs and currency.
|
||||
- Feature modules follow a strict file suffix convention (see below).
|
||||
|
||||
## Import Conventions
|
||||
|
||||
Use path aliases (defined in `tsconfig.json`). Order: external packages → aliases → relative.
|
||||
|
||||
```typescript
|
||||
import { SlashCommandBuilder } from "discord.js"; // external
|
||||
import { economyService } from "@shared/modules/economy/economy.service"; // alias
|
||||
import { users } from "@db/schema"; // alias
|
||||
import { createErrorEmbed } from "@lib/embeds"; // alias
|
||||
import { localHelper } from "./helper"; // relative
|
||||
```
|
||||
|
||||
**Aliases:**
|
||||
- `@/*` → `bot/`
|
||||
- `@shared/*` → `shared/`
|
||||
- `@db/*` → `shared/db/`
|
||||
- `@lib/*` → `bot/lib/`
|
||||
- `@modules/*` → `bot/modules/`
|
||||
- `@commands/*` → `bot/commands/`
|
||||
|
||||
## Code Patterns
|
||||
|
||||
### Module File Suffixes
|
||||
|
||||
- `*.view.ts` — Creates Discord embeds/components
|
||||
- `*.interaction.ts` — Handles button/select/modal interactions
|
||||
- `*.service.ts` — Business logic (lives in `shared/modules/`)
|
||||
- `*.types.ts` — Module-specific TypeScript types
|
||||
- `*.test.ts` — Tests (co-located with source)
|
||||
|
||||
### Command Definition
|
||||
|
||||
```typescript
|
||||
export const commandName = createCommand({
|
||||
data: new SlashCommandBuilder().setName("name").setDescription("desc"),
|
||||
execute: async (interaction) => {
|
||||
await withCommandErrorHandling(interaction, async () => {
|
||||
const result = await service.method();
|
||||
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
||||
}, { ephemeral: true });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`withCommandErrorHandling` (from `@lib/commandUtils`) handles `deferReply`, `UserError` display, and unexpected error logging automatically.
|
||||
|
||||
### Service Pattern
|
||||
|
||||
```typescript
|
||||
export const serviceName = {
|
||||
methodName: async (params: ParamType): Promise<ReturnType> => {
|
||||
return await withTransaction(async (tx) => {
|
||||
// database operations
|
||||
});
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
import { UserError, SystemError } from "@shared/lib/errors";
|
||||
|
||||
throw new UserError("You don't have enough coins!"); // shown to user
|
||||
throw new SystemError("DB connection failed"); // logged, generic message shown
|
||||
```
|
||||
|
||||
### Database Transactions
|
||||
|
||||
```typescript
|
||||
import { withTransaction } from "@/lib/db";
|
||||
|
||||
return await withTransaction(async (tx) => {
|
||||
const user = await tx.query.users.findFirst({ where: eq(users.id, id) });
|
||||
await tx.update(users).set({ coins: newBalance }).where(eq(users.id, id));
|
||||
return user;
|
||||
}, existingTx); // pass existing tx for nested transactions
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
Mock modules **before** imports. Use `bun:test`.
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: { query: mockQuery },
|
||||
}));
|
||||
|
||||
describe("serviceName", () => {
|
||||
beforeEach(() => mockFn.mockClear());
|
||||
it("should handle expected case", async () => {
|
||||
mockFn.mockResolvedValue(testData);
|
||||
const result = await service.method(input);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
| Element | Convention | Example |
|
||||
| ---------------- | ---------------------- | -------------------------------- |
|
||||
| Files | camelCase or kebab-case | `BotClient.ts`, `economy.service.ts` |
|
||||
| Classes | PascalCase | `CommandHandler`, `UserError` |
|
||||
| Functions | camelCase | `createCommand`, `handleShopInteraction` |
|
||||
| Constants | UPPER_SNAKE_CASE | `EVENTS`, `BRANDING` |
|
||||
| Enums | PascalCase | `TimerType`, `TransactionType` |
|
||||
| Services | camelCase singleton | `economyService`, `userService` |
|
||||
| Types/Interfaces | PascalCase | `Command`, `Event`, `GameConfigType` |
|
||||
| DB tables | snake_case | `users`, `moderation_cases` |
|
||||
| Custom IDs | snake_case with prefix | `shop_buy_`, `trade_accept_` |
|
||||
| API routes | kebab-case | `/api/guild-settings` |
|
||||
|
||||
## Key Files
|
||||
|
||||
| Purpose | File |
|
||||
| ----------------- | -------------------------- |
|
||||
| Bot entry point | `bot/index.ts` |
|
||||
| Discord client | `bot/lib/BotClient.ts` |
|
||||
| DB schema index | `shared/db/schema.ts` |
|
||||
| Error classes | `shared/lib/errors.ts` |
|
||||
| Environment vars | `shared/lib/env.ts` |
|
||||
| Config loader | `shared/lib/config.ts` |
|
||||
| Embed helpers | `bot/lib/embeds.ts` |
|
||||
| Command utils | `bot/lib/commandUtils.ts` |
|
||||
| API server | `api/src/server.ts` |
|
||||
73
Dockerfile
73
Dockerfile
@@ -1,22 +1,77 @@
|
||||
# ============================================
|
||||
# Base stage - shared configuration
|
||||
# ============================================
|
||||
FROM oven/bun:latest AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
||||
# Install system dependencies with cleanup in same layer
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends git && \
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# Install root project dependencies
|
||||
# ============================================
|
||||
# Dependencies stage - installs all deps
|
||||
# ============================================
|
||||
FROM base AS deps
|
||||
|
||||
# Copy only package files first (better layer caching)
|
||||
COPY package.json bun.lock ./
|
||||
COPY panel/package.json panel/
|
||||
|
||||
# Install dependencies
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Install web project dependencies
|
||||
COPY web/package.json web/bun.lock ./web/
|
||||
RUN cd web && bun install --frozen-lockfile
|
||||
# ============================================
|
||||
# Development stage - for local dev with volume mounts
|
||||
# ============================================
|
||||
FROM base AS development
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
# Copy dependencies from deps stage
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
# Expose ports (3000 for web dashboard)
|
||||
# Expose ports
|
||||
EXPOSE 3000
|
||||
|
||||
# Default command
|
||||
CMD ["bun", "run", "dev"]
|
||||
|
||||
# ============================================
|
||||
# Builder stage - copies source for production
|
||||
# ============================================
|
||||
FROM base AS builder
|
||||
|
||||
# Copy source code first, then deps on top (so node_modules aren't overwritten)
|
||||
COPY . .
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
# Install panel deps and build
|
||||
RUN cd panel && bun install --frozen-lockfile && bun run build
|
||||
|
||||
# ============================================
|
||||
# Production stage - minimal runtime image
|
||||
# ============================================
|
||||
FROM oven/bun:latest AS production
|
||||
WORKDIR /app
|
||||
|
||||
# Copy only what's needed for production
|
||||
COPY --from=builder --chown=bun:bun /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=bun:bun /app/api/src ./api/src
|
||||
COPY --from=builder --chown=bun:bun /app/bot ./bot
|
||||
COPY --from=builder --chown=bun:bun /app/shared ./shared
|
||||
COPY --from=builder --chown=bun:bun /app/panel/dist ./panel/dist
|
||||
COPY --from=builder --chown=bun:bun /app/package.json .
|
||||
COPY --from=builder --chown=bun:bun /app/drizzle.config.ts .
|
||||
COPY --from=builder --chown=bun:bun /app/tsconfig.json .
|
||||
|
||||
# Switch to non-root user
|
||||
USER bun
|
||||
|
||||
# Expose web dashboard port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD bun -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
|
||||
|
||||
# Run in production mode
|
||||
CMD ["bun", "run", "bot/index.ts"]
|
||||
|
||||
31
README.md
31
README.md
@@ -7,11 +7,10 @@
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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.
|
||||
**New in v1.0:** Aurora now includes a fully integrated **REST API** for accessing bot data, statistics, and configuration, running alongside the bot in a single process.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
@@ -25,26 +24,26 @@ Aurora is a powerful Discord bot designed to facilitate RPG-like elements within
|
||||
* **Lootdrops**: Random loot drops in channels to engage users.
|
||||
* **Admin Tools**: Administrative commands for server management.
|
||||
|
||||
### Web Dashboard
|
||||
* **Live Analytics**: View real-time activity charts (commands, transactions).
|
||||
* **Configuration Management**: Update bot settings without restarting.
|
||||
### REST API
|
||||
* **Live Analytics**: Real-time statistics endpoint (commands, transactions).
|
||||
* **Configuration Management**: Update bot settings via API.
|
||||
* **Database Inspection**: Integrated Drizzle Studio access.
|
||||
* **State Monitoring**: View internal bot state (Lootdrops, etc.).
|
||||
* **WebSocket Support**: Real-time event streaming for live updates.
|
||||
|
||||
## 🏗️ 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).
|
||||
* **Unified Runtime**: Both the Discord Client and the REST API run within the same Bun process.
|
||||
* **Shared State**: This allows the API to access live bot memory (caches, gateways) directly without complex inter-process communication (IPC).
|
||||
* **Simplified Deployment**: You only need to deploy a single Docker container.
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
* **Runtime**: [Bun](https://bun.sh/)
|
||||
* **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/)
|
||||
* **API Framework**: Bun HTTP Server (REST API)
|
||||
* **UI**: Discord embeds and components
|
||||
* **Database**: [PostgreSQL](https://www.postgresql.org/)
|
||||
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
||||
* **Validation**: [Zod](https://zod.dev/)
|
||||
@@ -94,14 +93,14 @@ Aurora uses a **Single Process Monolith** architecture to maximize performance a
|
||||
bun run db:push
|
||||
```
|
||||
|
||||
### Running the Bot & Dashboard
|
||||
### Running the Bot & API
|
||||
|
||||
**Development Mode** (with hot reload):
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
* Bot: Online in Discord
|
||||
* Dashboard: http://localhost:3000
|
||||
* API: http://localhost:3000
|
||||
|
||||
**Production Mode**:
|
||||
Build and run with Docker (recommended):
|
||||
@@ -111,7 +110,7 @@ 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.
|
||||
For security, the Production Database and API 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.
|
||||
|
||||
@@ -127,12 +126,12 @@ To access them from your local machine, use the included SSH tunnel script.
|
||||
```
|
||||
|
||||
This will establish secure tunnels for:
|
||||
* **Dashboard**: http://localhost:3000
|
||||
* **API**: http://localhost:3000
|
||||
* **Drizzle Studio**: http://localhost:4983
|
||||
|
||||
## 📜 Scripts
|
||||
|
||||
* `bun run dev`: Start the bot and dashboard in watch mode.
|
||||
* `bun run dev`: Start the bot and API server in watch mode.
|
||||
* `bun run remote`: Open SSH tunnel to production services.
|
||||
* `bun run generate`: Generate Drizzle migrations.
|
||||
* `bun run migrate`: Apply migrations (via Docker).
|
||||
@@ -143,7 +142,7 @@ This will establish secure tunnels for:
|
||||
|
||||
```
|
||||
├── bot # Discord Bot logic & entry point
|
||||
├── web # React Web Dashboard (Frontend + Server)
|
||||
├── web # REST API Server
|
||||
├── shared # Shared code (Database, Config, Types)
|
||||
├── drizzle # Drizzle migration files
|
||||
├── scripts # Utility scripts
|
||||
|
||||
0
web/.gitignore → api/.gitignore
vendored
0
web/.gitignore → api/.gitignore
vendored
30
api/README.md
Normal file
30
api/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Aurora Web API
|
||||
|
||||
The web API provides a REST interface and WebSocket support for accessing Aurora bot data and configuration.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `GET /api/stats` - Real-time bot statistics
|
||||
- `GET /api/settings` - Bot configuration
|
||||
- `GET /api/users` - User data
|
||||
- `GET /api/items` - Item catalog
|
||||
- `GET /api/quests` - Quest information
|
||||
- `GET /api/transactions` - Economy data
|
||||
- `GET /api/health` - Health check
|
||||
|
||||
## WebSocket
|
||||
|
||||
Connect to `/ws` for real-time updates:
|
||||
- Stats broadcasts every 5 seconds
|
||||
- Event notifications via system bus
|
||||
- PING/PONG heartbeat support
|
||||
|
||||
## Development
|
||||
|
||||
The API runs automatically when you start the bot:
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
The API will be available at `http://localhost:3000`
|
||||
0
web/bun-env.d.ts → api/bun-env.d.ts
vendored
0
web/bun-env.d.ts → api/bun-env.d.ts
vendored
106
api/src/routes/actions.routes.ts
Normal file
106
api/src/routes/actions.routes.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @fileoverview Administrative action endpoints for Aurora API.
|
||||
* Provides endpoints for system administration tasks like cache clearing
|
||||
* and maintenance mode toggling.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import { jsonResponse, errorResponse, parseBody, withErrorHandling } from "./utils";
|
||||
import { MaintenanceModeSchema } from "./schemas";
|
||||
|
||||
/**
|
||||
* Admin actions routes handler.
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /api/actions/reload-commands - Reload bot slash commands
|
||||
* - POST /api/actions/clear-cache - Clear internal caches
|
||||
* - POST /api/actions/maintenance-mode - Toggle maintenance mode
|
||||
*/
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method, req } = ctx;
|
||||
|
||||
// Only handle POST requests to /api/actions/*
|
||||
if (!pathname.startsWith("/api/actions/") || method !== "POST") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { actionService } = await import("@shared/modules/admin/action.service");
|
||||
|
||||
/**
|
||||
* @route POST /api/actions/reload-commands
|
||||
* @description Triggers a reload of all Discord slash commands.
|
||||
* Useful after modifying command configurations.
|
||||
* @response 200 - `{ success: true, message: string }`
|
||||
* @response 500 - Error reloading commands
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* POST /api/actions/reload-commands
|
||||
*
|
||||
* // Response
|
||||
* { "success": true, "message": "Commands reloaded" }
|
||||
*/
|
||||
if (pathname === "/api/actions/reload-commands") {
|
||||
return withErrorHandling(async () => {
|
||||
const result = await actionService.reloadCommands();
|
||||
return jsonResponse(result);
|
||||
}, "reload commands");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route POST /api/actions/clear-cache
|
||||
* @description Clears all internal application caches.
|
||||
* Useful for forcing fresh data fetches.
|
||||
* @response 200 - `{ success: true, message: string }`
|
||||
* @response 500 - Error clearing cache
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* POST /api/actions/clear-cache
|
||||
*
|
||||
* // Response
|
||||
* { "success": true, "message": "Cache cleared" }
|
||||
*/
|
||||
if (pathname === "/api/actions/clear-cache") {
|
||||
return withErrorHandling(async () => {
|
||||
const result = await actionService.clearCache();
|
||||
return jsonResponse(result);
|
||||
}, "clear cache");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route POST /api/actions/maintenance-mode
|
||||
* @description Toggles bot maintenance mode on or off.
|
||||
* When enabled, the bot will respond with a maintenance message.
|
||||
*
|
||||
* @body { enabled: boolean, reason?: string }
|
||||
* @response 200 - `{ success: true, enabled: boolean }`
|
||||
* @response 400 - Invalid payload with validation errors
|
||||
* @response 500 - Error toggling maintenance mode
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* POST /api/actions/maintenance-mode
|
||||
* Content-Type: application/json
|
||||
* { "enabled": true, "reason": "Deploying updates..." }
|
||||
*
|
||||
* // Response
|
||||
* { "success": true, "enabled": true }
|
||||
*/
|
||||
if (pathname === "/api/actions/maintenance-mode") {
|
||||
return withErrorHandling(async () => {
|
||||
const data = await parseBody(req, MaintenanceModeSchema);
|
||||
if (data instanceof Response) return data;
|
||||
|
||||
const result = await actionService.toggleMaintenanceMode(data.enabled, data.reason);
|
||||
return jsonResponse(result);
|
||||
}, "toggle maintenance mode");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const actionsRoutes: RouteModule = {
|
||||
name: "actions",
|
||||
handler
|
||||
};
|
||||
83
api/src/routes/assets.routes.ts
Normal file
83
api/src/routes/assets.routes.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @fileoverview Static asset serving for Aurora API.
|
||||
* Serves item images and other assets from the local filesystem.
|
||||
*/
|
||||
|
||||
import { join, resolve, dirname } from "path";
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
|
||||
// Resolve assets root directory
|
||||
const currentDir = dirname(new URL(import.meta.url).pathname);
|
||||
const assetsRoot = resolve(currentDir, "../../../bot/assets/graphics");
|
||||
|
||||
/** MIME types for supported image formats */
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
"png": "image/png",
|
||||
"jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
"webp": "image/webp",
|
||||
"gif": "image/gif",
|
||||
};
|
||||
|
||||
/**
|
||||
* Assets routes handler.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /assets/* - Serve static files from the assets directory
|
||||
*/
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method } = ctx;
|
||||
|
||||
/**
|
||||
* @route GET /assets/*
|
||||
* @description Serves static asset files (images) with caching headers.
|
||||
* Assets are served from the bot's graphics directory.
|
||||
*
|
||||
* Path security: Path traversal attacks are prevented by validating
|
||||
* that the resolved path stays within the assets root.
|
||||
*
|
||||
* @response 200 - File content with appropriate MIME type
|
||||
* @response 403 - Forbidden (path traversal attempt)
|
||||
* @response 404 - File not found
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* GET /assets/items/1.png
|
||||
*
|
||||
* // Response Headers
|
||||
* Content-Type: image/png
|
||||
* Cache-Control: public, max-age=86400
|
||||
*/
|
||||
if (pathname.startsWith("/assets/") && method === "GET") {
|
||||
const assetPath = pathname.replace("/assets/", "");
|
||||
|
||||
// Security: prevent path traversal attacks
|
||||
const safePath = join(assetsRoot, assetPath);
|
||||
if (!safePath.startsWith(assetsRoot)) {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const file = Bun.file(safePath);
|
||||
if (await file.exists()) {
|
||||
// Determine MIME type based on extension
|
||||
const ext = safePath.split(".").pop()?.toLowerCase();
|
||||
const contentType = MIME_TYPES[ext || ""] || "application/octet-stream";
|
||||
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const assetsRoutes: RouteModule = {
|
||||
name: "assets",
|
||||
handler
|
||||
};
|
||||
233
api/src/routes/auth.routes.ts
Normal file
233
api/src/routes/auth.routes.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* @fileoverview Discord OAuth2 authentication routes for the admin panel.
|
||||
* Handles login flow, callback, logout, and session management.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import { jsonResponse, errorResponse } from "./utils";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
|
||||
// In-memory session store: token → { discordId, username, avatar, expiresAt }
|
||||
export interface Session {
|
||||
discordId: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const sessions = new Map<string, Session>();
|
||||
const redirects = new Map<string, string>(); // redirect token -> return_to URL
|
||||
|
||||
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
function getEnv(key: string): string {
|
||||
const val = process.env[key];
|
||||
if (!val) throw new Error(`Missing env: ${key}`);
|
||||
return val;
|
||||
}
|
||||
|
||||
function getAdminIds(): string[] {
|
||||
const raw = process.env.ADMIN_USER_IDS ?? "";
|
||||
return raw.split(",").map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function generateToken(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function getBaseUrl(): string {
|
||||
return process.env.PANEL_BASE_URL ?? `http://localhost:3000`;
|
||||
}
|
||||
|
||||
function parseCookies(header: string | null): Record<string, string> {
|
||||
if (!header) return {};
|
||||
const cookies: Record<string, string> = {};
|
||||
for (const pair of header.split(";")) {
|
||||
const [key, ...rest] = pair.trim().split("=");
|
||||
if (key) cookies[key] = rest.join("=");
|
||||
}
|
||||
return cookies;
|
||||
}
|
||||
|
||||
/** Get session from request cookie */
|
||||
export function getSession(req: Request): Session | null {
|
||||
const cookies = parseCookies(req.headers.get("cookie"));
|
||||
const token = cookies["aurora_session"];
|
||||
if (!token) return null;
|
||||
const session = sessions.get(token);
|
||||
if (!session) return null;
|
||||
if (Date.now() > session.expiresAt) {
|
||||
sessions.delete(token);
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Check if request is authenticated as admin */
|
||||
export function isAuthenticated(req: Request): boolean {
|
||||
return getSession(req) !== null;
|
||||
}
|
||||
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method } = ctx;
|
||||
|
||||
// GET /auth/discord — redirect to Discord OAuth
|
||||
if (pathname === "/auth/discord" && method === "GET") {
|
||||
try {
|
||||
const clientId = getEnv("DISCORD_CLIENT_ID");
|
||||
const baseUrl = getBaseUrl();
|
||||
const redirectUri = encodeURIComponent(`${baseUrl}/auth/callback`);
|
||||
const scope = "identify+email";
|
||||
|
||||
// Store return_to URL if provided
|
||||
const returnTo = ctx.url.searchParams.get("return_to") || "/";
|
||||
const redirectToken = generateToken();
|
||||
redirects.set(redirectToken, returnTo);
|
||||
|
||||
const url = `https://discord.com/oauth2/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&scope=${scope}`;
|
||||
|
||||
// Set a temporary cookie with the redirect token
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: url,
|
||||
"Set-Cookie": `aurora_redirect=${redirectToken}; Path=/; Max-Age=600; SameSite=Lax`,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("auth", "Failed to initiate OAuth", e);
|
||||
return errorResponse("OAuth not configured", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /auth/callback — handle Discord OAuth callback
|
||||
if (pathname === "/auth/callback" && method === "GET") {
|
||||
const code = ctx.url.searchParams.get("code");
|
||||
if (!code) return errorResponse("Missing code parameter", 400);
|
||||
|
||||
try {
|
||||
const clientId = getEnv("DISCORD_CLIENT_ID");
|
||||
const clientSecret = getEnv("DISCORD_CLIENT_SECRET");
|
||||
const baseUrl = getBaseUrl();
|
||||
const redirectUri = `${baseUrl}/auth/callback`;
|
||||
|
||||
// Exchange code for token
|
||||
const tokenRes = await fetch("https://discord.com/api/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
logger.error("auth", `Token exchange failed: ${tokenRes.status}`);
|
||||
return errorResponse("OAuth token exchange failed", 401);
|
||||
}
|
||||
|
||||
const tokenData = await tokenRes.json() as { access_token: string };
|
||||
|
||||
// Fetch user info
|
||||
const userRes = await fetch("https://discord.com/api/users/@me", {
|
||||
headers: { Authorization: `Bearer ${tokenData.access_token}` },
|
||||
});
|
||||
|
||||
if (!userRes.ok) {
|
||||
return errorResponse("Failed to fetch Discord user", 401);
|
||||
}
|
||||
|
||||
const user = await userRes.json() as { id: string; username: string; avatar: string | null };
|
||||
|
||||
// Check allowlist
|
||||
const adminIds = getAdminIds();
|
||||
if (adminIds.length > 0 && !adminIds.includes(user.id)) {
|
||||
logger.warn("auth", `Unauthorized login attempt by ${user.username} (${user.id})`);
|
||||
return new Response(
|
||||
`<html><body><h1>Access Denied</h1><p>Your Discord account is not authorized.</p></body></html>`,
|
||||
{ status: 403, headers: { "Content-Type": "text/html" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Create session
|
||||
const token = generateToken();
|
||||
sessions.set(token, {
|
||||
discordId: user.id,
|
||||
username: user.username,
|
||||
avatar: user.avatar,
|
||||
expiresAt: Date.now() + SESSION_MAX_AGE,
|
||||
});
|
||||
|
||||
logger.info("auth", `Admin login: ${user.username} (${user.id})`);
|
||||
|
||||
// Get return_to URL from redirect token cookie
|
||||
const cookies = parseCookies(ctx.req.headers.get("cookie"));
|
||||
const redirectToken = cookies["aurora_redirect"];
|
||||
let returnTo = redirectToken && redirects.get(redirectToken) ? redirects.get(redirectToken)! : "/";
|
||||
if (redirectToken) redirects.delete(redirectToken);
|
||||
|
||||
// Only allow redirects to localhost or relative paths (prevent open redirect)
|
||||
try {
|
||||
const parsed = new URL(returnTo, baseUrl);
|
||||
if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1") {
|
||||
returnTo = "/";
|
||||
}
|
||||
} catch {
|
||||
returnTo = "/";
|
||||
}
|
||||
|
||||
// Redirect to panel with session cookie
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: returnTo,
|
||||
"Set-Cookie": `aurora_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE / 1000}`,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("auth", "OAuth callback error", e);
|
||||
return errorResponse("Authentication failed", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /auth/logout — clear session
|
||||
if (pathname === "/auth/logout" && method === "POST") {
|
||||
const cookies = parseCookies(ctx.req.headers.get("cookie"));
|
||||
const token = cookies["aurora_session"];
|
||||
if (token) sessions.delete(token);
|
||||
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Set-Cookie": "aurora_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// GET /auth/me — return current session info
|
||||
if (pathname === "/auth/me" && method === "GET") {
|
||||
const session = getSession(ctx.req);
|
||||
if (!session) return jsonResponse({ authenticated: false }, 401);
|
||||
return jsonResponse({
|
||||
authenticated: true,
|
||||
user: {
|
||||
discordId: session.discordId,
|
||||
username: session.username,
|
||||
avatar: session.avatar,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const authRoutes: RouteModule = {
|
||||
name: "auth",
|
||||
handler,
|
||||
};
|
||||
155
api/src/routes/classes.routes.ts
Normal file
155
api/src/routes/classes.routes.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @fileoverview Class management endpoints for Aurora API.
|
||||
* Provides CRUD operations for player classes/guilds.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import {
|
||||
jsonResponse,
|
||||
errorResponse,
|
||||
parseBody,
|
||||
parseStringIdFromPath,
|
||||
withErrorHandling
|
||||
} from "./utils";
|
||||
import { CreateClassSchema, UpdateClassSchema } from "./schemas";
|
||||
|
||||
/**
|
||||
* Classes routes handler.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/classes - List all classes
|
||||
* - POST /api/classes - Create a new class
|
||||
* - PUT /api/classes/:id - Update a class
|
||||
* - DELETE /api/classes/:id - Delete a class
|
||||
*/
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method, req } = ctx;
|
||||
|
||||
// Only handle requests to /api/classes*
|
||||
if (!pathname.startsWith("/api/classes")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { classService } = await import("@shared/modules/class/class.service");
|
||||
|
||||
/**
|
||||
* @route GET /api/classes
|
||||
* @description Returns all classes/guilds in the system.
|
||||
*
|
||||
* @response 200 - `{ classes: Class[] }`
|
||||
* @response 500 - Error fetching classes
|
||||
*
|
||||
* @example
|
||||
* // Response
|
||||
* {
|
||||
* "classes": [
|
||||
* { "id": "1", "name": "Warrior", "balance": "5000", "roleId": "123456789" }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
if (pathname === "/api/classes" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const classes = await classService.getAllClasses();
|
||||
return jsonResponse({ classes });
|
||||
}, "fetch classes");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route POST /api/classes
|
||||
* @description Creates a new class/guild.
|
||||
*
|
||||
* @body {
|
||||
* id: string | number (required) - Unique class identifier,
|
||||
* name: string (required) - Class display name,
|
||||
* balance?: string | number - Initial class balance (default: 0),
|
||||
* roleId?: string - Associated Discord role ID
|
||||
* }
|
||||
* @response 201 - `{ success: true, class: Class }`
|
||||
* @response 400 - Missing required fields
|
||||
* @response 500 - Error creating class
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* POST /api/classes
|
||||
* { "id": "2", "name": "Mage", "balance": "0", "roleId": "987654321" }
|
||||
*/
|
||||
if (pathname === "/api/classes" && method === "POST") {
|
||||
return withErrorHandling(async () => {
|
||||
const data = await req.json() as Record<string, any>;
|
||||
|
||||
if (!data.id || !data.name || typeof data.name !== 'string') {
|
||||
return errorResponse("Missing required fields: id and name are required", 400);
|
||||
}
|
||||
|
||||
const newClass = await classService.createClass({
|
||||
id: BigInt(data.id),
|
||||
name: data.name,
|
||||
balance: data.balance ? BigInt(data.balance) : 0n,
|
||||
roleId: data.roleId || null,
|
||||
});
|
||||
|
||||
return jsonResponse({ success: true, class: newClass }, 201);
|
||||
}, "create class");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route PUT /api/classes/:id
|
||||
* @description Updates an existing class.
|
||||
*
|
||||
* @param id - Class ID
|
||||
* @body {
|
||||
* name?: string - Updated class name,
|
||||
* balance?: string | number - Updated balance,
|
||||
* roleId?: string - Updated Discord role ID
|
||||
* }
|
||||
* @response 200 - `{ success: true, class: Class }`
|
||||
* @response 404 - Class not found
|
||||
* @response 500 - Error updating class
|
||||
*/
|
||||
if (pathname.match(/^\/api\/classes\/\d+$/) && method === "PUT") {
|
||||
const id = parseStringIdFromPath(pathname);
|
||||
if (!id) return null;
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const data = await req.json() as Record<string, any>;
|
||||
|
||||
const updateData: any = {};
|
||||
if (data.name !== undefined) updateData.name = data.name;
|
||||
if (data.balance !== undefined) updateData.balance = BigInt(data.balance);
|
||||
if (data.roleId !== undefined) updateData.roleId = data.roleId;
|
||||
|
||||
const updatedClass = await classService.updateClass(BigInt(id), updateData);
|
||||
|
||||
if (!updatedClass) {
|
||||
return errorResponse("Class not found", 404);
|
||||
}
|
||||
|
||||
return jsonResponse({ success: true, class: updatedClass });
|
||||
}, "update class");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route DELETE /api/classes/:id
|
||||
* @description Deletes a class. Users assigned to this class will need to be reassigned.
|
||||
*
|
||||
* @param id - Class ID
|
||||
* @response 204 - Class deleted (no content)
|
||||
* @response 500 - Error deleting class
|
||||
*/
|
||||
if (pathname.match(/^\/api\/classes\/\d+$/) && method === "DELETE") {
|
||||
const id = parseStringIdFromPath(pathname);
|
||||
if (!id) return null;
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
await classService.deleteClass(BigInt(id));
|
||||
return new Response(null, { status: 204 });
|
||||
}, "delete class");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const classesRoutes: RouteModule = {
|
||||
name: "classes",
|
||||
handler
|
||||
};
|
||||
64
api/src/routes/guild-settings.routes.ts
Normal file
64
api/src/routes/guild-settings.routes.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @fileoverview Guild settings endpoints for Aurora API.
|
||||
* Provides endpoints for reading and updating per-guild configuration
|
||||
* stored in the database.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
|
||||
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
|
||||
import { invalidateGuildConfigCache } from "@shared/lib/config";
|
||||
|
||||
const GUILD_SETTINGS_PATTERN = /^\/api\/guilds\/(\d+)\/settings$/;
|
||||
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method, req } = ctx;
|
||||
|
||||
const match = pathname.match(GUILD_SETTINGS_PATTERN);
|
||||
if (!match || !match[1]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const guildId = match[1];
|
||||
|
||||
if (method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const settings = await guildSettingsService.getSettings(guildId);
|
||||
if (!settings) {
|
||||
return jsonResponse({ guildId, configured: false });
|
||||
}
|
||||
return jsonResponse({ ...settings, guildId, configured: true });
|
||||
}, "fetch guild settings");
|
||||
}
|
||||
|
||||
if (method === "PUT" || method === "PATCH") {
|
||||
try {
|
||||
const body = await req.json() as Record<string, unknown>;
|
||||
const { guildId: _, ...settings } = body;
|
||||
const result = await guildSettingsService.upsertSettings({
|
||||
guildId,
|
||||
...settings,
|
||||
} as Parameters<typeof guildSettingsService.upsertSettings>[0]);
|
||||
invalidateGuildConfigCache(guildId);
|
||||
return jsonResponse(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return errorResponse("Failed to save guild settings", 400, message);
|
||||
}
|
||||
}
|
||||
|
||||
if (method === "DELETE") {
|
||||
return withErrorHandling(async () => {
|
||||
await guildSettingsService.deleteSettings(guildId);
|
||||
invalidateGuildConfigCache(guildId);
|
||||
return jsonResponse({ success: true });
|
||||
}, "delete guild settings");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const guildSettingsRoutes: RouteModule = {
|
||||
name: "guild-settings",
|
||||
handler
|
||||
};
|
||||
36
api/src/routes/health.routes.ts
Normal file
36
api/src/routes/health.routes.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @fileoverview Health check endpoint for Aurora API.
|
||||
* Provides a simple health status endpoint for monitoring and load balancers.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
|
||||
/**
|
||||
* Health routes handler.
|
||||
*
|
||||
* @route GET /api/health
|
||||
* @description Returns server health status with timestamp.
|
||||
* @response 200 - `{ status: "ok", timestamp: number }`
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* GET /api/health
|
||||
*
|
||||
* // Response
|
||||
* { "status": "ok", "timestamp": 1707408000000 }
|
||||
*/
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
if (ctx.pathname === "/api/health" && ctx.method === "GET") {
|
||||
return Response.json({
|
||||
status: "ok",
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const healthRoutes: RouteModule = {
|
||||
name: "health",
|
||||
handler
|
||||
};
|
||||
93
api/src/routes/index.ts
Normal file
93
api/src/routes/index.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @fileoverview Route registration module for Aurora API.
|
||||
* Aggregates all route handlers and provides a unified request handler.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import { authRoutes, isAuthenticated } from "./auth.routes";
|
||||
import { healthRoutes } from "./health.routes";
|
||||
import { statsRoutes } from "./stats.routes";
|
||||
import { actionsRoutes } from "./actions.routes";
|
||||
import { questsRoutes } from "./quests.routes";
|
||||
import { settingsRoutes } from "./settings.routes";
|
||||
import { guildSettingsRoutes } from "./guild-settings.routes";
|
||||
import { itemsRoutes } from "./items.routes";
|
||||
import { usersRoutes } from "./users.routes";
|
||||
import { classesRoutes } from "./classes.routes";
|
||||
import { moderationRoutes } from "./moderation.routes";
|
||||
import { transactionsRoutes } from "./transactions.routes";
|
||||
import { lootdropsRoutes } from "./lootdrops.routes";
|
||||
import { assetsRoutes } from "./assets.routes";
|
||||
import { errorResponse } from "./utils";
|
||||
|
||||
/** Routes that do NOT require authentication */
|
||||
const publicRoutes: RouteModule[] = [
|
||||
authRoutes,
|
||||
healthRoutes,
|
||||
];
|
||||
|
||||
/** Routes that require an authenticated admin session */
|
||||
const protectedRoutes: RouteModule[] = [
|
||||
statsRoutes,
|
||||
actionsRoutes,
|
||||
questsRoutes,
|
||||
settingsRoutes,
|
||||
guildSettingsRoutes,
|
||||
itemsRoutes,
|
||||
usersRoutes,
|
||||
classesRoutes,
|
||||
moderationRoutes,
|
||||
transactionsRoutes,
|
||||
lootdropsRoutes,
|
||||
assetsRoutes,
|
||||
];
|
||||
|
||||
/**
|
||||
* Main request handler that routes requests to appropriate handlers.
|
||||
*
|
||||
* @param req - The incoming HTTP request
|
||||
* @param url - Parsed URL object
|
||||
* @returns Response from matching route handler, or null if no match
|
||||
*
|
||||
* @example
|
||||
* const response = await handleRequest(req, url);
|
||||
* if (response) return response;
|
||||
* return new Response("Not Found", { status: 404 });
|
||||
*/
|
||||
export async function handleRequest(req: Request, url: URL): Promise<Response | null> {
|
||||
const ctx: RouteContext = {
|
||||
req,
|
||||
url,
|
||||
method: req.method,
|
||||
pathname: url.pathname,
|
||||
};
|
||||
|
||||
// Try public routes first (auth, health)
|
||||
for (const module of publicRoutes) {
|
||||
const response = await module.handler(ctx);
|
||||
if (response !== null) return response;
|
||||
}
|
||||
|
||||
// For API routes, enforce authentication
|
||||
if (ctx.pathname.startsWith("/api/")) {
|
||||
if (!isAuthenticated(req)) {
|
||||
return errorResponse("Unauthorized", 401);
|
||||
}
|
||||
}
|
||||
|
||||
// Try protected routes
|
||||
for (const module of protectedRoutes) {
|
||||
const response = await module.handler(ctx);
|
||||
if (response !== null) return response;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all registered route module names.
|
||||
* Useful for debugging and documentation.
|
||||
*/
|
||||
export function getRegisteredRoutes(): string[] {
|
||||
return [...publicRoutes, ...protectedRoutes].map(m => m.name);
|
||||
}
|
||||
371
api/src/routes/items.routes.ts
Normal file
371
api/src/routes/items.routes.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* @fileoverview Items management endpoints for Aurora API.
|
||||
* Provides CRUD operations for game items with image upload support.
|
||||
*/
|
||||
|
||||
import { join, resolve, dirname } from "path";
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import type { CreateItemDTO, UpdateItemDTO } from "@shared/modules/items/items.service";
|
||||
import {
|
||||
jsonResponse,
|
||||
errorResponse,
|
||||
parseBody,
|
||||
parseIdFromPath,
|
||||
parseQuery,
|
||||
withErrorHandling
|
||||
} from "./utils";
|
||||
import { CreateItemSchema, UpdateItemSchema, ItemQuerySchema } from "./schemas";
|
||||
|
||||
// Resolve assets directory path
|
||||
const currentDir = dirname(new URL(import.meta.url).pathname);
|
||||
const assetsDir = resolve(currentDir, "../../../bot/assets/graphics/items");
|
||||
|
||||
/**
|
||||
* Validates image file by checking magic bytes.
|
||||
* Supports PNG, JPEG, WebP, and GIF formats.
|
||||
*/
|
||||
function validateImageFormat(bytes: Uint8Array): boolean {
|
||||
const isPNG = bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47;
|
||||
const isJPEG = bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF;
|
||||
const isWebP = bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50;
|
||||
const isGIF = bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46;
|
||||
|
||||
return isPNG || isJPEG || isWebP || isGIF;
|
||||
}
|
||||
|
||||
/** Maximum image file size: 15MB */
|
||||
const MAX_IMAGE_SIZE = 15 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Items routes handler.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/items - List items with filters
|
||||
* - POST /api/items - Create item (JSON or multipart with image)
|
||||
* - GET /api/items/:id - Get single item
|
||||
* - PUT /api/items/:id - Update item
|
||||
* - DELETE /api/items/:id - Delete item and asset
|
||||
* - POST /api/items/:id/icon - Upload/replace item icon
|
||||
*/
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method, req, url } = ctx;
|
||||
|
||||
// Only handle requests to /api/items*
|
||||
if (!pathname.startsWith("/api/items")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { itemsService } = await import("@shared/modules/items/items.service");
|
||||
|
||||
/**
|
||||
* @route GET /api/items
|
||||
* @description Returns a paginated list of items with optional filtering.
|
||||
*
|
||||
* @query search - Filter by name/description (partial match)
|
||||
* @query type - Filter by item type (CONSUMABLE, EQUIPMENT, etc.)
|
||||
* @query rarity - Filter by rarity (C, R, SR, SSR)
|
||||
* @query limit - Max results per page (default: 100, max: 100)
|
||||
* @query offset - Pagination offset (default: 0)
|
||||
*
|
||||
* @response 200 - `{ items: Item[], total: number }`
|
||||
* @response 500 - Error fetching items
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* GET /api/items?type=CONSUMABLE&rarity=R&limit=10
|
||||
*
|
||||
* // Response
|
||||
* {
|
||||
* "items": [{ "id": 1, "name": "Health Potion", ... }],
|
||||
* "total": 25
|
||||
* }
|
||||
*/
|
||||
if (pathname === "/api/items" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const filters = {
|
||||
search: url.searchParams.get("search") || undefined,
|
||||
type: url.searchParams.get("type") || undefined,
|
||||
rarity: url.searchParams.get("rarity") || undefined,
|
||||
limit: url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 100,
|
||||
offset: url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0,
|
||||
};
|
||||
|
||||
const result = await itemsService.getAllItems(filters);
|
||||
return jsonResponse(result);
|
||||
}, "fetch items");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route POST /api/items
|
||||
* @description Creates a new item. Supports JSON or multipart/form-data with image.
|
||||
*
|
||||
* @body (JSON) {
|
||||
* name: string (required),
|
||||
* type: string (required),
|
||||
* description?: string,
|
||||
* rarity?: "C" | "R" | "SR" | "SSR",
|
||||
* price?: string | number,
|
||||
* usageData?: object
|
||||
* }
|
||||
*
|
||||
* @body (Multipart) {
|
||||
* data: JSON string with item fields,
|
||||
* image?: File (PNG, JPEG, WebP, GIF - max 15MB)
|
||||
* }
|
||||
*
|
||||
* @response 201 - `{ success: true, item: Item }`
|
||||
* @response 400 - Missing required fields or invalid image
|
||||
* @response 409 - Item name already exists
|
||||
* @response 500 - Error creating item
|
||||
*/
|
||||
if (pathname === "/api/items" && method === "POST") {
|
||||
return withErrorHandling(async () => {
|
||||
const contentType = req.headers.get("content-type") || "";
|
||||
|
||||
let itemData: CreateItemDTO | null = null;
|
||||
let imageFile: File | null = null;
|
||||
|
||||
if (contentType.includes("multipart/form-data")) {
|
||||
const formData = await req.formData();
|
||||
const jsonData = formData.get("data");
|
||||
imageFile = formData.get("image") as File | null;
|
||||
|
||||
if (typeof jsonData === "string") {
|
||||
itemData = JSON.parse(jsonData) as CreateItemDTO;
|
||||
} else {
|
||||
return errorResponse("Missing item data", 400);
|
||||
}
|
||||
} else {
|
||||
itemData = await req.json() as CreateItemDTO;
|
||||
}
|
||||
|
||||
if (!itemData) {
|
||||
return errorResponse("Missing item data", 400);
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!itemData.name || !itemData.type) {
|
||||
return errorResponse("Missing required fields: name and type are required", 400);
|
||||
}
|
||||
|
||||
// Check for duplicate name
|
||||
if (await itemsService.isNameTaken(itemData.name)) {
|
||||
return errorResponse("An item with this name already exists", 409);
|
||||
}
|
||||
|
||||
// Set placeholder URLs if image will be uploaded
|
||||
const placeholderUrl = "/assets/items/placeholder.png";
|
||||
const createData = {
|
||||
name: itemData.name,
|
||||
description: itemData.description || null,
|
||||
rarity: itemData.rarity || "C",
|
||||
type: itemData.type,
|
||||
price: itemData.price ? BigInt(itemData.price) : null,
|
||||
iconUrl: itemData.iconUrl || placeholderUrl,
|
||||
imageUrl: itemData.imageUrl || placeholderUrl,
|
||||
usageData: itemData.usageData || null,
|
||||
};
|
||||
|
||||
// Create the item
|
||||
const item = await itemsService.createItem(createData);
|
||||
|
||||
// If image was provided, save it and update the item
|
||||
if (imageFile && item) {
|
||||
const buffer = await imageFile.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
if (!validateImageFormat(bytes)) {
|
||||
await itemsService.deleteItem(item.id);
|
||||
return errorResponse("Invalid image file. Only PNG, JPEG, WebP, and GIF are allowed.", 400);
|
||||
}
|
||||
|
||||
if (buffer.byteLength > MAX_IMAGE_SIZE) {
|
||||
await itemsService.deleteItem(item.id);
|
||||
return errorResponse("Image file too large. Maximum size is 15MB.", 400);
|
||||
}
|
||||
|
||||
const fileName = `${item.id}.png`;
|
||||
const filePath = join(assetsDir, fileName);
|
||||
await Bun.write(filePath, buffer);
|
||||
|
||||
const assetUrl = `/assets/items/${fileName}`;
|
||||
await itemsService.updateItem(item.id, {
|
||||
iconUrl: assetUrl,
|
||||
imageUrl: assetUrl,
|
||||
});
|
||||
|
||||
const updatedItem = await itemsService.getItemById(item.id);
|
||||
return jsonResponse({ success: true, item: updatedItem }, 201);
|
||||
}
|
||||
|
||||
return jsonResponse({ success: true, item }, 201);
|
||||
}, "create item");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route GET /api/items/:id
|
||||
* @description Returns a single item by ID.
|
||||
*
|
||||
* @param id - Item ID (numeric)
|
||||
* @response 200 - Full item object
|
||||
* @response 404 - Item not found
|
||||
* @response 500 - Error fetching item
|
||||
*/
|
||||
if (pathname.match(/^\/api\/items\/\d+$/) && method === "GET") {
|
||||
const id = parseIdFromPath(pathname);
|
||||
if (!id) return null;
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const item = await itemsService.getItemById(id);
|
||||
if (!item) {
|
||||
return errorResponse("Item not found", 404);
|
||||
}
|
||||
return jsonResponse(item);
|
||||
}, "fetch item");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route PUT /api/items/:id
|
||||
* @description Updates an existing item.
|
||||
*
|
||||
* @param id - Item ID (numeric)
|
||||
* @body Partial item fields to update
|
||||
* @response 200 - `{ success: true, item: Item }`
|
||||
* @response 404 - Item not found
|
||||
* @response 409 - Name already taken by another item
|
||||
* @response 500 - Error updating item
|
||||
*/
|
||||
if (pathname.match(/^\/api\/items\/\d+$/) && method === "PUT") {
|
||||
const id = parseIdFromPath(pathname);
|
||||
if (!id) return null;
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const data = await req.json() as Partial<UpdateItemDTO>;
|
||||
|
||||
const existing = await itemsService.getItemById(id);
|
||||
if (!existing) {
|
||||
return errorResponse("Item not found", 404);
|
||||
}
|
||||
|
||||
// Check for duplicate name (if name is being changed)
|
||||
if (data.name && data.name !== existing.name) {
|
||||
if (await itemsService.isNameTaken(data.name, id)) {
|
||||
return errorResponse("An item with this name already exists", 409);
|
||||
}
|
||||
}
|
||||
|
||||
// Build update data
|
||||
const updateData: Partial<UpdateItemDTO> = {};
|
||||
if (data.name !== undefined) updateData.name = data.name;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
if (data.rarity !== undefined) updateData.rarity = data.rarity;
|
||||
if (data.type !== undefined) updateData.type = data.type;
|
||||
if (data.price !== undefined) updateData.price = data.price ? BigInt(data.price) : null;
|
||||
if (data.iconUrl !== undefined) updateData.iconUrl = data.iconUrl;
|
||||
if (data.imageUrl !== undefined) updateData.imageUrl = data.imageUrl;
|
||||
if (data.usageData !== undefined) updateData.usageData = data.usageData;
|
||||
|
||||
const updatedItem = await itemsService.updateItem(id, updateData);
|
||||
return jsonResponse({ success: true, item: updatedItem });
|
||||
}, "update item");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route DELETE /api/items/:id
|
||||
* @description Deletes an item and its associated asset file.
|
||||
*
|
||||
* @param id - Item ID (numeric)
|
||||
* @response 204 - Item deleted (no content)
|
||||
* @response 404 - Item not found
|
||||
* @response 500 - Error deleting item
|
||||
*/
|
||||
if (pathname.match(/^\/api\/items\/\d+$/) && method === "DELETE") {
|
||||
const id = parseIdFromPath(pathname);
|
||||
if (!id) return null;
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const existing = await itemsService.getItemById(id);
|
||||
if (!existing) {
|
||||
return errorResponse("Item not found", 404);
|
||||
}
|
||||
|
||||
await itemsService.deleteItem(id);
|
||||
|
||||
// Try to delete associated asset file
|
||||
const assetPath = join(assetsDir, `${id}.png`);
|
||||
try {
|
||||
const assetFile = Bun.file(assetPath);
|
||||
if (await assetFile.exists()) {
|
||||
const { unlink } = await import("node:fs/promises");
|
||||
await unlink(assetPath);
|
||||
}
|
||||
} catch (e) {
|
||||
// Non-critical: log but don't fail
|
||||
const { logger } = await import("@shared/lib/logger");
|
||||
logger.warn("web", `Could not delete asset file for item ${id}`, e);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}, "delete item");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route POST /api/items/:id/icon
|
||||
* @description Uploads or replaces an item's icon image.
|
||||
*
|
||||
* @param id - Item ID (numeric)
|
||||
* @body (Multipart) { image: File }
|
||||
* @response 200 - `{ success: true, item: Item }`
|
||||
* @response 400 - No image file or invalid format
|
||||
* @response 404 - Item not found
|
||||
* @response 500 - Error uploading icon
|
||||
*/
|
||||
if (pathname.match(/^\/api\/items\/\d+\/icon$/) && method === "POST") {
|
||||
const id = parseInt(pathname.split("/")[3] || "0");
|
||||
if (!id) return null;
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const existing = await itemsService.getItemById(id);
|
||||
if (!existing) {
|
||||
return errorResponse("Item not found", 404);
|
||||
}
|
||||
|
||||
const formData = await req.formData();
|
||||
const imageFile = formData.get("image") as File | null;
|
||||
|
||||
if (!imageFile) {
|
||||
return errorResponse("No image file provided", 400);
|
||||
}
|
||||
|
||||
const buffer = await imageFile.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
if (!validateImageFormat(bytes)) {
|
||||
return errorResponse("Invalid image file. Only PNG, JPEG, WebP, and GIF are allowed.", 400);
|
||||
}
|
||||
|
||||
if (buffer.byteLength > MAX_IMAGE_SIZE) {
|
||||
return errorResponse("Image file too large. Maximum size is 15MB.", 400);
|
||||
}
|
||||
|
||||
const fileName = `${id}.png`;
|
||||
const filePath = join(assetsDir, fileName);
|
||||
await Bun.write(filePath, buffer);
|
||||
|
||||
const assetUrl = `/assets/items/${fileName}`;
|
||||
const updatedItem = await itemsService.updateItem(id, {
|
||||
iconUrl: assetUrl,
|
||||
imageUrl: assetUrl,
|
||||
});
|
||||
|
||||
return jsonResponse({ success: true, item: updatedItem });
|
||||
}, "upload item icon");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const itemsRoutes: RouteModule = {
|
||||
name: "items",
|
||||
handler
|
||||
};
|
||||
130
api/src/routes/lootdrops.routes.ts
Normal file
130
api/src/routes/lootdrops.routes.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @fileoverview Lootdrop management endpoints for Aurora API.
|
||||
* Provides endpoints for viewing, spawning, and canceling lootdrops.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import {
|
||||
jsonResponse,
|
||||
errorResponse,
|
||||
parseStringIdFromPath,
|
||||
withErrorHandling
|
||||
} from "./utils";
|
||||
|
||||
/**
|
||||
* Lootdrops routes handler.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/lootdrops - List lootdrops
|
||||
* - POST /api/lootdrops - Spawn a lootdrop
|
||||
* - DELETE /api/lootdrops/:messageId - Cancel/delete a lootdrop
|
||||
*/
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method, req, url } = ctx;
|
||||
|
||||
// Only handle requests to /api/lootdrops*
|
||||
if (!pathname.startsWith("/api/lootdrops")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @route GET /api/lootdrops
|
||||
* @description Returns recent lootdrops, sorted by newest first.
|
||||
*
|
||||
* @query limit - Max results (default: 50)
|
||||
* @response 200 - `{ lootdrops: Lootdrop[] }`
|
||||
* @response 500 - Error fetching lootdrops
|
||||
*/
|
||||
if (pathname === "/api/lootdrops" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const { lootdrops } = await import("@shared/db/schema");
|
||||
const { DrizzleClient } = await import("@shared/db/DrizzleClient");
|
||||
const { desc } = await import("drizzle-orm");
|
||||
|
||||
const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
|
||||
|
||||
const result = await DrizzleClient.select()
|
||||
.from(lootdrops)
|
||||
.orderBy(desc(lootdrops.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
return jsonResponse({ lootdrops: result });
|
||||
}, "fetch lootdrops");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route POST /api/lootdrops
|
||||
* @description Spawns a new lootdrop in a Discord channel.
|
||||
* Requires a valid text channel ID where the bot has permissions.
|
||||
*
|
||||
* @body {
|
||||
* channelId: string (required) - Discord channel ID to spawn in,
|
||||
* amount?: number - Reward amount (random if not specified),
|
||||
* currency?: string - Currency type
|
||||
* }
|
||||
* @response 201 - `{ success: true }`
|
||||
* @response 400 - Invalid channel or missing channelId
|
||||
* @response 500 - Error spawning lootdrop
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* POST /api/lootdrops
|
||||
* { "channelId": "1234567890", "amount": 100, "currency": "Gold" }
|
||||
*/
|
||||
if (pathname === "/api/lootdrops" && method === "POST") {
|
||||
return withErrorHandling(async () => {
|
||||
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||
const { AuroraClient } = await import("../../../bot/lib/BotClient");
|
||||
const { TextChannel } = await import("discord.js");
|
||||
|
||||
const data = await req.json() as Record<string, any>;
|
||||
|
||||
if (!data.channelId) {
|
||||
return errorResponse("Missing required field: channelId", 400);
|
||||
}
|
||||
|
||||
const channel = await AuroraClient.channels.fetch(data.channelId);
|
||||
|
||||
if (!channel || !(channel instanceof TextChannel)) {
|
||||
return errorResponse("Invalid channel. Must be a TextChannel.", 400);
|
||||
}
|
||||
|
||||
await lootdropService.spawnLootdrop(channel, data.amount, data.currency);
|
||||
|
||||
return jsonResponse({ success: true }, 201);
|
||||
}, "spawn lootdrop");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route DELETE /api/lootdrops/:messageId
|
||||
* @description Cancels and deletes an active lootdrop.
|
||||
* The lootdrop is identified by its Discord message ID.
|
||||
*
|
||||
* @param messageId - Discord message ID of the lootdrop
|
||||
* @response 204 - Lootdrop deleted (no content)
|
||||
* @response 404 - Lootdrop not found
|
||||
* @response 500 - Error deleting lootdrop
|
||||
*/
|
||||
if (pathname.match(/^\/api\/lootdrops\/[^\/]+$/) && method === "DELETE") {
|
||||
const messageId = parseStringIdFromPath(pathname);
|
||||
if (!messageId) return null;
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||
const success = await lootdropService.deleteLootdrop(messageId);
|
||||
|
||||
if (!success) {
|
||||
return errorResponse("Lootdrop not found", 404);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}, "delete lootdrop");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const lootdropsRoutes: RouteModule = {
|
||||
name: "lootdrops",
|
||||
handler
|
||||
};
|
||||
217
api/src/routes/moderation.routes.ts
Normal file
217
api/src/routes/moderation.routes.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* @fileoverview Moderation case management endpoints for Aurora API.
|
||||
* Provides endpoints for viewing, creating, and resolving moderation cases.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import {
|
||||
jsonResponse,
|
||||
errorResponse,
|
||||
parseBody,
|
||||
withErrorHandling
|
||||
} from "./utils";
|
||||
import { CreateCaseSchema, ClearCaseSchema, CaseIdPattern } from "./schemas";
|
||||
|
||||
/**
|
||||
* Moderation routes handler.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/moderation - List cases with filters
|
||||
* - GET /api/moderation/:caseId - Get single case
|
||||
* - POST /api/moderation - Create new case
|
||||
* - PUT /api/moderation/:caseId/clear - Clear/resolve case
|
||||
*/
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method, req, url } = ctx;
|
||||
|
||||
// Only handle requests to /api/moderation*
|
||||
if (!pathname.startsWith("/api/moderation")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { moderationService } = await import("@shared/modules/moderation/moderation.service");
|
||||
|
||||
/**
|
||||
* @route GET /api/moderation
|
||||
* @description Returns moderation cases with optional filtering.
|
||||
*
|
||||
* @query userId - Filter by target user ID
|
||||
* @query moderatorId - Filter by moderator ID
|
||||
* @query type - Filter by case type (warn, timeout, kick, ban, note, prune)
|
||||
* @query active - Filter by active status (true/false)
|
||||
* @query limit - Max results (default: 50)
|
||||
* @query offset - Pagination offset (default: 0)
|
||||
*
|
||||
* @response 200 - `{ cases: ModerationCase[] }`
|
||||
* @response 500 - Error fetching cases
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* GET /api/moderation?type=warn&active=true&limit=10
|
||||
*
|
||||
* // Response
|
||||
* {
|
||||
* "cases": [
|
||||
* {
|
||||
* "id": "1",
|
||||
* "caseId": "CASE-0001",
|
||||
* "type": "warn",
|
||||
* "userId": "123456789",
|
||||
* "username": "User1",
|
||||
* "moderatorId": "987654321",
|
||||
* "moderatorName": "Mod1",
|
||||
* "reason": "Spam",
|
||||
* "active": true,
|
||||
* "createdAt": "2024-01-15T12:00:00Z"
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
if (pathname === "/api/moderation" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const filter: any = {};
|
||||
if (url.searchParams.get("userId")) filter.userId = url.searchParams.get("userId");
|
||||
if (url.searchParams.get("moderatorId")) filter.moderatorId = url.searchParams.get("moderatorId");
|
||||
if (url.searchParams.get("type")) filter.type = url.searchParams.get("type");
|
||||
const activeParam = url.searchParams.get("active");
|
||||
if (activeParam !== null) filter.active = activeParam === "true";
|
||||
filter.limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
|
||||
filter.offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
|
||||
|
||||
const cases = await moderationService.searchCases(filter);
|
||||
return jsonResponse({ cases });
|
||||
}, "fetch moderation cases");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route GET /api/moderation/:caseId
|
||||
* @description Returns a single moderation case by case ID.
|
||||
* Case IDs follow the format CASE-XXXX (e.g., CASE-0001).
|
||||
*
|
||||
* @param caseId - Case ID in CASE-XXXX format
|
||||
* @response 200 - Full case object
|
||||
* @response 404 - Case not found
|
||||
* @response 500 - Error fetching case
|
||||
*/
|
||||
if (pathname.match(/^\/api\/moderation\/CASE-\d+$/i) && method === "GET") {
|
||||
const caseId = pathname.split("/").pop()!.toUpperCase();
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const moderationCase = await moderationService.getCaseById(caseId);
|
||||
|
||||
if (!moderationCase) {
|
||||
return errorResponse("Case not found", 404);
|
||||
}
|
||||
|
||||
return jsonResponse(moderationCase);
|
||||
}, "fetch moderation case");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route POST /api/moderation
|
||||
* @description Creates a new moderation case.
|
||||
*
|
||||
* @body {
|
||||
* type: "warn" | "timeout" | "kick" | "ban" | "note" | "prune" (required),
|
||||
* userId: string (required) - Target user's Discord ID,
|
||||
* username: string (required) - Target user's username,
|
||||
* moderatorId: string (required) - Moderator's Discord ID,
|
||||
* moderatorName: string (required) - Moderator's username,
|
||||
* reason: string (required) - Reason for the action,
|
||||
* metadata?: object - Additional case metadata (e.g., duration)
|
||||
* }
|
||||
* @response 201 - `{ success: true, case: ModerationCase }`
|
||||
* @response 400 - Missing required fields
|
||||
* @response 500 - Error creating case
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* POST /api/moderation
|
||||
* {
|
||||
* "type": "warn",
|
||||
* "userId": "123456789",
|
||||
* "username": "User1",
|
||||
* "moderatorId": "987654321",
|
||||
* "moderatorName": "Mod1",
|
||||
* "reason": "Rule violation",
|
||||
* "metadata": { "duration": "24h" }
|
||||
* }
|
||||
*/
|
||||
if (pathname === "/api/moderation" && method === "POST") {
|
||||
return withErrorHandling(async () => {
|
||||
const data = await req.json() as Record<string, any>;
|
||||
|
||||
if (!data.type || !data.userId || !data.username || !data.moderatorId || !data.moderatorName || !data.reason) {
|
||||
return errorResponse(
|
||||
"Missing required fields: type, userId, username, moderatorId, moderatorName, reason",
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const newCase = await moderationService.createCase({
|
||||
type: data.type,
|
||||
userId: data.userId,
|
||||
username: data.username,
|
||||
moderatorId: data.moderatorId,
|
||||
moderatorName: data.moderatorName,
|
||||
reason: data.reason,
|
||||
metadata: data.metadata || {},
|
||||
});
|
||||
|
||||
return jsonResponse({ success: true, case: newCase }, 201);
|
||||
}, "create moderation case");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route PUT /api/moderation/:caseId/clear
|
||||
* @description Clears/resolves a moderation case.
|
||||
* Sets the case as inactive and records who cleared it.
|
||||
*
|
||||
* @param caseId - Case ID in CASE-XXXX format
|
||||
* @body {
|
||||
* clearedBy: string (required) - Discord ID of user clearing the case,
|
||||
* clearedByName: string (required) - Username of user clearing the case,
|
||||
* reason?: string - Reason for clearing (default: "Cleared via API")
|
||||
* }
|
||||
* @response 200 - `{ success: true, case: ModerationCase }`
|
||||
* @response 400 - Missing required fields
|
||||
* @response 404 - Case not found
|
||||
* @response 500 - Error clearing case
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* PUT /api/moderation/CASE-0001/clear
|
||||
* { "clearedBy": "987654321", "clearedByName": "Admin1", "reason": "Appeal accepted" }
|
||||
*/
|
||||
if (pathname.match(/^\/api\/moderation\/CASE-\d+\/clear$/i) && method === "PUT") {
|
||||
const caseId = (pathname.split("/")[3] || "").toUpperCase();
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const data = await req.json() as Record<string, any>;
|
||||
|
||||
if (!data.clearedBy || !data.clearedByName) {
|
||||
return errorResponse("Missing required fields: clearedBy, clearedByName", 400);
|
||||
}
|
||||
|
||||
const updatedCase = await moderationService.clearCase({
|
||||
caseId,
|
||||
clearedBy: data.clearedBy,
|
||||
clearedByName: data.clearedByName,
|
||||
reason: data.reason || "Cleared via API",
|
||||
});
|
||||
|
||||
if (!updatedCase) {
|
||||
return errorResponse("Case not found", 404);
|
||||
}
|
||||
|
||||
return jsonResponse({ success: true, case: updatedCase });
|
||||
}, "clear moderation case");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const moderationRoutes: RouteModule = {
|
||||
name: "moderation",
|
||||
handler
|
||||
};
|
||||
207
api/src/routes/quests.routes.ts
Normal file
207
api/src/routes/quests.routes.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @fileoverview Quest management endpoints for Aurora API.
|
||||
* Provides CRUD operations for game quests.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import { jsonResponse, errorResponse, parseIdFromPath, withErrorHandling } from "./utils";
|
||||
import { CreateQuestSchema, UpdateQuestSchema } from "@shared/modules/quest/quest.types";
|
||||
|
||||
/**
|
||||
* Quest routes handler.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/quests - List all quests
|
||||
* - POST /api/quests - Create a new quest
|
||||
* - PUT /api/quests/:id - Update an existing quest
|
||||
* - DELETE /api/quests/:id - Delete a quest
|
||||
*/
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method, req } = ctx;
|
||||
|
||||
// Only handle requests to /api/quests*
|
||||
if (!pathname.startsWith("/api/quests")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { questService } = await import("@shared/modules/quest/quest.service");
|
||||
|
||||
/**
|
||||
* @route GET /api/quests
|
||||
* @description Returns all quests in the system.
|
||||
* @response 200 - `{ success: true, data: Quest[] }`
|
||||
* @response 500 - Error fetching quests
|
||||
*
|
||||
* @example
|
||||
* // Response
|
||||
* {
|
||||
* "success": true,
|
||||
* "data": [
|
||||
* {
|
||||
* "id": 1,
|
||||
* "name": "Daily Login",
|
||||
* "description": "Login once to claim",
|
||||
* "triggerEvent": "login",
|
||||
* "requirements": { "target": 1 },
|
||||
* "rewards": { "xp": 50, "balance": 100 }
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
if (pathname === "/api/quests" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const quests = await questService.getAllQuests();
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
data: quests.map(q => ({
|
||||
id: q.id,
|
||||
name: q.name,
|
||||
description: q.description,
|
||||
triggerEvent: q.triggerEvent,
|
||||
requirements: q.requirements,
|
||||
rewards: q.rewards,
|
||||
})),
|
||||
});
|
||||
}, "fetch quests");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route POST /api/quests
|
||||
* @description Creates a new quest.
|
||||
*
|
||||
* @body {
|
||||
* name: string,
|
||||
* description?: string,
|
||||
* triggerEvent: string,
|
||||
* target: number,
|
||||
* xpReward: number,
|
||||
* balanceReward: number
|
||||
* }
|
||||
* @response 200 - `{ success: true, quest: Quest }`
|
||||
* @response 400 - Validation error
|
||||
* @response 500 - Error creating quest
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* POST /api/quests
|
||||
* {
|
||||
* "name": "Win 5 Battles",
|
||||
* "description": "Defeat 5 enemies in combat",
|
||||
* "triggerEvent": "battle_win",
|
||||
* "target": 5,
|
||||
* "xpReward": 200,
|
||||
* "balanceReward": 500
|
||||
* }
|
||||
*/
|
||||
if (pathname === "/api/quests" && method === "POST") {
|
||||
return withErrorHandling(async () => {
|
||||
const rawData = await req.json();
|
||||
const parseResult = CreateQuestSchema.safeParse(rawData);
|
||||
|
||||
if (!parseResult.success) {
|
||||
return Response.json({
|
||||
error: "Invalid payload",
|
||||
issues: parseResult.error.issues.map(i => ({ path: i.path, message: i.message }))
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const data = parseResult.data;
|
||||
const result = await questService.createQuest({
|
||||
name: data.name,
|
||||
description: data.description || "",
|
||||
triggerEvent: data.triggerEvent,
|
||||
requirements: { target: data.target },
|
||||
rewards: {
|
||||
xp: data.xpReward,
|
||||
balance: data.balanceReward
|
||||
}
|
||||
});
|
||||
|
||||
return jsonResponse({ success: true, quest: result[0] });
|
||||
}, "create quest");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route PUT /api/quests/:id
|
||||
* @description Updates an existing quest by ID.
|
||||
*
|
||||
* @param id - Quest ID (numeric)
|
||||
* @body Partial quest fields to update
|
||||
* @response 200 - `{ success: true, quest: Quest }`
|
||||
* @response 400 - Invalid quest ID or validation error
|
||||
* @response 404 - Quest not found
|
||||
* @response 500 - Error updating quest
|
||||
*/
|
||||
if (pathname.match(/^\/api\/quests\/\d+$/) && method === "PUT") {
|
||||
const id = parseIdFromPath(pathname);
|
||||
if (!id) {
|
||||
return errorResponse("Invalid quest ID", 400);
|
||||
}
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const rawData = await req.json();
|
||||
const parseResult = UpdateQuestSchema.safeParse(rawData);
|
||||
|
||||
if (!parseResult.success) {
|
||||
return Response.json({
|
||||
error: "Invalid payload",
|
||||
issues: parseResult.error.issues.map(i => ({ path: i.path, message: i.message }))
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const data = parseResult.data;
|
||||
const result = await questService.updateQuest(id, {
|
||||
...(data.name !== undefined && { name: data.name }),
|
||||
...(data.description !== undefined && { description: data.description }),
|
||||
...(data.triggerEvent !== undefined && { triggerEvent: data.triggerEvent }),
|
||||
...(data.target !== undefined && { requirements: { target: data.target } }),
|
||||
...((data.xpReward !== undefined || data.balanceReward !== undefined) && {
|
||||
rewards: {
|
||||
xp: data.xpReward ?? 0,
|
||||
balance: data.balanceReward ?? 0
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!result || result.length === 0) {
|
||||
return errorResponse("Quest not found", 404);
|
||||
}
|
||||
|
||||
return jsonResponse({ success: true, quest: result[0] });
|
||||
}, "update quest");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route DELETE /api/quests/:id
|
||||
* @description Deletes a quest by ID.
|
||||
*
|
||||
* @param id - Quest ID (numeric)
|
||||
* @response 200 - `{ success: true, deleted: number }`
|
||||
* @response 400 - Invalid quest ID
|
||||
* @response 404 - Quest not found
|
||||
* @response 500 - Error deleting quest
|
||||
*/
|
||||
if (pathname.match(/^\/api\/quests\/\d+$/) && method === "DELETE") {
|
||||
const id = parseIdFromPath(pathname);
|
||||
if (!id) {
|
||||
return errorResponse("Invalid quest ID", 400);
|
||||
}
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const result = await questService.deleteQuest(id);
|
||||
|
||||
if (!result || result.length === 0) {
|
||||
return errorResponse("Quest not found", 404);
|
||||
}
|
||||
|
||||
return jsonResponse({ success: true, deleted: (result[0] as { id: number }).id });
|
||||
}, "delete quest");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const questsRoutes: RouteModule = {
|
||||
name: "quests",
|
||||
handler
|
||||
};
|
||||
274
api/src/routes/schemas.ts
Normal file
274
api/src/routes/schemas.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* @fileoverview Centralized Zod validation schemas for all Aurora API endpoints.
|
||||
* Provides type-safe request/response validation for every entity in the system.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
// ============================================================================
|
||||
// Common Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Standard pagination query parameters.
|
||||
*/
|
||||
export const PaginationSchema = z.object({
|
||||
limit: z.coerce.number().min(1).max(100).optional().default(50),
|
||||
offset: z.coerce.number().min(0).optional().default(0),
|
||||
});
|
||||
|
||||
/**
|
||||
* Numeric ID parameter validation.
|
||||
*/
|
||||
export const NumericIdSchema = z.coerce.number().int().positive();
|
||||
|
||||
/**
|
||||
* Discord snowflake ID validation (string of digits).
|
||||
*/
|
||||
export const SnowflakeIdSchema = z.string().regex(/^\d{17,20}$/, "Invalid Discord ID format");
|
||||
|
||||
// ============================================================================
|
||||
// Items Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Valid item types in the system.
|
||||
*/
|
||||
export const ItemTypeEnum = z.enum([
|
||||
"CONSUMABLE",
|
||||
"EQUIPMENT",
|
||||
"MATERIAL",
|
||||
"LOOTBOX",
|
||||
"COLLECTIBLE",
|
||||
"KEY",
|
||||
"TOOL"
|
||||
]);
|
||||
|
||||
/**
|
||||
* Valid item rarities.
|
||||
*/
|
||||
export const ItemRarityEnum = z.enum(["C", "R", "SR", "SSR"]);
|
||||
|
||||
/**
|
||||
* Query parameters for listing items.
|
||||
*/
|
||||
export const ItemQuerySchema = PaginationSchema.extend({
|
||||
search: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
rarity: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for creating a new item.
|
||||
*/
|
||||
export const CreateItemSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").max(100),
|
||||
description: z.string().max(500).nullable().optional(),
|
||||
rarity: ItemRarityEnum.optional().default("C"),
|
||||
type: ItemTypeEnum,
|
||||
price: z.union([z.string(), z.number()]).nullable().optional(),
|
||||
iconUrl: z.string().optional(),
|
||||
imageUrl: z.string().optional(),
|
||||
usageData: z.any().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for updating an existing item.
|
||||
*/
|
||||
export const UpdateItemSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
description: z.string().max(500).nullable().optional(),
|
||||
rarity: ItemRarityEnum.optional(),
|
||||
type: ItemTypeEnum.optional(),
|
||||
price: z.union([z.string(), z.number()]).nullable().optional(),
|
||||
iconUrl: z.string().optional(),
|
||||
imageUrl: z.string().optional(),
|
||||
usageData: z.any().nullable().optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Users Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Query parameters for listing users.
|
||||
*/
|
||||
export const UserQuerySchema = PaginationSchema.extend({
|
||||
search: z.string().optional(),
|
||||
sortBy: z.enum(["balance", "level", "xp", "username"]).optional().default("balance"),
|
||||
sortOrder: z.enum(["asc", "desc"]).optional().default("desc"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for updating a user.
|
||||
*/
|
||||
export const UpdateUserSchema = z.object({
|
||||
username: z.string().min(1).max(32).optional(),
|
||||
balance: z.union([z.string(), z.number()]).optional(),
|
||||
xp: z.union([z.string(), z.number()]).optional(),
|
||||
level: z.coerce.number().int().min(0).optional(),
|
||||
dailyStreak: z.coerce.number().int().min(0).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
settings: z.record(z.string(), z.any()).optional(),
|
||||
classId: z.union([z.string(), z.number()]).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for adding an item to user inventory.
|
||||
*/
|
||||
export const InventoryAddSchema = z.object({
|
||||
itemId: z.coerce.number().int().positive("Item ID is required"),
|
||||
quantity: z.union([z.string(), z.number()]).refine(
|
||||
(val) => BigInt(val) > 0n,
|
||||
"Quantity must be positive"
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Query params for removing inventory items.
|
||||
*/
|
||||
export const InventoryRemoveQuerySchema = z.object({
|
||||
amount: z.coerce.number().int().min(1).optional().default(1),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Classes Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Schema for creating a new class.
|
||||
*/
|
||||
export const CreateClassSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
name: z.string().min(1, "Name is required").max(50),
|
||||
balance: z.union([z.string(), z.number()]).optional().default("0"),
|
||||
roleId: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for updating a class.
|
||||
*/
|
||||
export const UpdateClassSchema = z.object({
|
||||
name: z.string().min(1).max(50).optional(),
|
||||
balance: z.union([z.string(), z.number()]).optional(),
|
||||
roleId: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Moderation Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Valid moderation case types.
|
||||
*/
|
||||
export const ModerationTypeEnum = z.enum([
|
||||
"warn",
|
||||
"timeout",
|
||||
"kick",
|
||||
"ban",
|
||||
"note",
|
||||
"prune"
|
||||
]);
|
||||
|
||||
/**
|
||||
* Query parameters for searching moderation cases.
|
||||
*/
|
||||
export const CaseQuerySchema = PaginationSchema.extend({
|
||||
userId: z.string().optional(),
|
||||
moderatorId: z.string().optional(),
|
||||
type: ModerationTypeEnum.optional(),
|
||||
active: z.preprocess(
|
||||
(val) => val === "true" ? true : val === "false" ? false : undefined,
|
||||
z.boolean().optional()
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for creating a moderation case.
|
||||
*/
|
||||
export const CreateCaseSchema = z.object({
|
||||
type: ModerationTypeEnum,
|
||||
userId: z.string().min(1, "User ID is required"),
|
||||
username: z.string().min(1, "Username is required"),
|
||||
moderatorId: z.string().min(1, "Moderator ID is required"),
|
||||
moderatorName: z.string().min(1, "Moderator name is required"),
|
||||
reason: z.string().min(1, "Reason is required").max(1000),
|
||||
metadata: z.record(z.string(), z.any()).optional().default({}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for clearing/resolving a moderation case.
|
||||
*/
|
||||
export const ClearCaseSchema = z.object({
|
||||
clearedBy: z.string().min(1, "Cleared by ID is required"),
|
||||
clearedByName: z.string().min(1, "Cleared by name is required"),
|
||||
reason: z.string().max(500).optional().default("Cleared via API"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Case ID pattern validation (CASE-XXXX format).
|
||||
*/
|
||||
export const CaseIdPattern = /^CASE-\d+$/i;
|
||||
|
||||
// ============================================================================
|
||||
// Transactions Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Query parameters for listing transactions.
|
||||
*/
|
||||
export const TransactionQuerySchema = PaginationSchema.extend({
|
||||
userId: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Lootdrops Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Query parameters for listing lootdrops.
|
||||
*/
|
||||
export const LootdropQuerySchema = z.object({
|
||||
limit: z.coerce.number().min(1).max(100).optional().default(50),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for spawning a lootdrop.
|
||||
*/
|
||||
export const CreateLootdropSchema = z.object({
|
||||
channelId: z.string().min(1, "Channel ID is required"),
|
||||
amount: z.coerce.number().int().positive().optional(),
|
||||
currency: z.string().optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Admin Actions Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Schema for toggling maintenance mode.
|
||||
*/
|
||||
export const MaintenanceModeSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
reason: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Type Exports
|
||||
// ============================================================================
|
||||
|
||||
export type ItemQuery = z.infer<typeof ItemQuerySchema>;
|
||||
export type CreateItem = z.infer<typeof CreateItemSchema>;
|
||||
export type UpdateItem = z.infer<typeof UpdateItemSchema>;
|
||||
export type UserQuery = z.infer<typeof UserQuerySchema>;
|
||||
export type UpdateUser = z.infer<typeof UpdateUserSchema>;
|
||||
export type InventoryAdd = z.infer<typeof InventoryAddSchema>;
|
||||
export type CreateClass = z.infer<typeof CreateClassSchema>;
|
||||
export type UpdateClass = z.infer<typeof UpdateClassSchema>;
|
||||
export type CaseQuery = z.infer<typeof CaseQuerySchema>;
|
||||
export type CreateCase = z.infer<typeof CreateCaseSchema>;
|
||||
export type ClearCase = z.infer<typeof ClearCaseSchema>;
|
||||
export type TransactionQuery = z.infer<typeof TransactionQuerySchema>;
|
||||
export type CreateLootdrop = z.infer<typeof CreateLootdropSchema>;
|
||||
export type MaintenanceMode = z.infer<typeof MaintenanceModeSchema>;
|
||||
152
api/src/routes/settings.routes.ts
Normal file
152
api/src/routes/settings.routes.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* @fileoverview Bot settings endpoints for Aurora API.
|
||||
* Provides endpoints for reading and updating bot configuration,
|
||||
* as well as fetching Discord metadata.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
|
||||
|
||||
/**
|
||||
* Settings routes handler.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/settings - Get current bot configuration
|
||||
* - POST /api/settings - Update bot configuration (partial merge)
|
||||
* - GET /api/settings/meta - Get Discord metadata (roles, channels, commands)
|
||||
*/
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method, req } = ctx;
|
||||
|
||||
// Only handle requests to /api/settings*
|
||||
if (!pathname.startsWith("/api/settings")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @route GET /api/settings
|
||||
* @description Returns the current bot configuration from database.
|
||||
* Configuration includes economy settings, leveling settings,
|
||||
* command toggles, and other system settings.
|
||||
* @response 200 - Full configuration object (DB format with strings for BigInts)
|
||||
* @response 500 - Error fetching settings
|
||||
*
|
||||
* @example
|
||||
* // Response
|
||||
* {
|
||||
* "economy": { "daily": { "amount": "100", "streakBonus": "10" } },
|
||||
* "leveling": { "base": 100, "exponent": 1.5 },
|
||||
* "commands": { "disabled": [], "channelLocks": {} }
|
||||
* }
|
||||
*/
|
||||
if (pathname === "/api/settings" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
|
||||
const settings = await gameSettingsService.getSettings();
|
||||
|
||||
if (!settings) {
|
||||
// Return defaults if no settings in DB yet
|
||||
return jsonResponse(gameSettingsService.getDefaults());
|
||||
}
|
||||
|
||||
return jsonResponse(settings);
|
||||
}, "fetch settings");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route POST /api/settings
|
||||
* @description Updates bot configuration with partial merge.
|
||||
* Only the provided fields will be updated; other settings remain unchanged.
|
||||
* After updating, commands are automatically reloaded.
|
||||
*
|
||||
* @body Partial configuration object (DB format with strings for BigInts)
|
||||
* @response 200 - `{ success: true }`
|
||||
* @response 400 - Validation error
|
||||
* @response 500 - Error saving settings
|
||||
*
|
||||
* @example
|
||||
* // Request - Only update economy daily reward
|
||||
* POST /api/settings
|
||||
* { "economy": { "daily": { "amount": "150" } } }
|
||||
*/
|
||||
if (pathname === "/api/settings" && method === "POST") {
|
||||
try {
|
||||
const partialConfig = await req.json() as Record<string, unknown>;
|
||||
const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
|
||||
|
||||
// Use upsertSettings to merge partial update
|
||||
await gameSettingsService.upsertSettings(partialConfig as Record<string, unknown>);
|
||||
|
||||
const { systemEvents, EVENTS } = await import("@shared/lib/events");
|
||||
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
|
||||
|
||||
return jsonResponse({ success: true });
|
||||
} catch (error) {
|
||||
// Return 400 for validation errors
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return errorResponse("Failed to save settings", 400, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @route GET /api/settings/meta
|
||||
* @description Returns Discord server metadata for settings UI.
|
||||
* Provides lists of roles, channels, and registered commands.
|
||||
*
|
||||
* @response 200 - `{ roles: Role[], channels: Channel[], commands: Command[] }`
|
||||
* @response 500 - Error fetching metadata
|
||||
*
|
||||
* @example
|
||||
* // Response
|
||||
* {
|
||||
* "roles": [
|
||||
* { "id": "123456789", "name": "Admin", "color": "#FF0000" }
|
||||
* ],
|
||||
* "channels": [
|
||||
* { "id": "987654321", "name": "general", "type": 0 }
|
||||
* ],
|
||||
* "commands": [
|
||||
* { "name": "daily", "category": "economy" }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
if (pathname === "/api/settings/meta" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const { AuroraClient } = await import("../../../bot/lib/BotClient");
|
||||
const { env } = await import("@shared/lib/env");
|
||||
|
||||
if (!env.DISCORD_GUILD_ID) {
|
||||
return jsonResponse({ roles: [], channels: [], commands: [] });
|
||||
}
|
||||
|
||||
const guild = AuroraClient.guilds.cache.get(env.DISCORD_GUILD_ID);
|
||||
if (!guild) {
|
||||
return jsonResponse({ roles: [], channels: [], commands: [] });
|
||||
}
|
||||
|
||||
// Map roles and channels to a simplified format
|
||||
const roles = guild.roles.cache
|
||||
.sort((a, b) => b.position - a.position)
|
||||
.map(r => ({ id: r.id, name: r.name, color: r.hexColor }));
|
||||
|
||||
const channels = guild.channels.cache
|
||||
.map(c => ({ id: c.id, name: c.name, type: c.type }));
|
||||
|
||||
const commands = Array.from(AuroraClient.knownCommands.entries())
|
||||
.map(([name, category]) => ({ name, category }))
|
||||
.sort((a, b) => {
|
||||
if (a.category !== b.category) return a.category.localeCompare(b.category);
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return jsonResponse({ guildId: env.DISCORD_GUILD_ID, roles, channels, commands });
|
||||
}, "fetch settings meta");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const settingsRoutes: RouteModule = {
|
||||
name: "settings",
|
||||
handler
|
||||
};
|
||||
94
api/src/routes/stats.helper.ts
Normal file
94
api/src/routes/stats.helper.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @fileoverview Dashboard stats helper for Aurora API.
|
||||
* Provides the getFullDashboardStats function used by stats routes.
|
||||
*/
|
||||
|
||||
import { logger } from "@shared/lib/logger";
|
||||
|
||||
/**
|
||||
* Fetches comprehensive dashboard statistics.
|
||||
* Aggregates data from multiple services with error isolation.
|
||||
*
|
||||
* @returns Full dashboard stats object including bot info, user counts,
|
||||
* economy data, leaderboards, and system status.
|
||||
*/
|
||||
export async function getFullDashboardStats() {
|
||||
// Import services (dynamic to avoid circular deps)
|
||||
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||
const { getClientStats } = await import("../../../bot/lib/clientStats");
|
||||
|
||||
// Fetch all data in parallel with error isolation
|
||||
const results = await Promise.allSettled([
|
||||
Promise.resolve(getClientStats()),
|
||||
dashboardService.getActiveUserCount(),
|
||||
dashboardService.getTotalUserCount(),
|
||||
dashboardService.getEconomyStats(),
|
||||
dashboardService.getRecentEvents(10),
|
||||
dashboardService.getTotalItems(),
|
||||
dashboardService.getActiveLootdrops(),
|
||||
dashboardService.getLeaderboards(),
|
||||
Promise.resolve(lootdropService.getLootdropState()),
|
||||
]);
|
||||
|
||||
// Helper to unwrap result or return default
|
||||
const unwrap = <T>(result: PromiseSettledResult<T>, defaultValue: T, name: string): T => {
|
||||
if (result.status === 'fulfilled') return result.value;
|
||||
logger.error("web", `Failed to fetch ${name}`, result.reason);
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
const clientStats = unwrap(results[0], {
|
||||
bot: { name: 'Aurora', avatarUrl: null, status: null },
|
||||
guilds: 0,
|
||||
commandsRegistered: 0,
|
||||
commandsKnown: 0,
|
||||
cachedUsers: 0,
|
||||
ping: 0,
|
||||
uptime: 0,
|
||||
lastCommandTimestamp: null
|
||||
}, 'clientStats');
|
||||
|
||||
const activeUsers = unwrap(results[1], 0, 'activeUsers');
|
||||
const totalUsers = unwrap(results[2], 0, 'totalUsers');
|
||||
const economyStats = unwrap(results[3], { totalWealth: 0n, avgLevel: 0, topStreak: 0 }, 'economyStats');
|
||||
const recentEvents = unwrap(results[4], [], 'recentEvents');
|
||||
const totalItems = unwrap(results[5], 0, 'totalItems');
|
||||
const activeLootdrops = unwrap(results[6], [], 'activeLootdrops');
|
||||
const leaderboards = unwrap(results[7], { topLevels: [], topWealth: [], topNetWorth: [] }, 'leaderboards');
|
||||
const lootdropState = unwrap(results[8], undefined, 'lootdropState');
|
||||
|
||||
return {
|
||||
bot: clientStats.bot,
|
||||
guilds: { count: clientStats.guilds },
|
||||
users: { active: activeUsers, total: totalUsers },
|
||||
commands: {
|
||||
total: clientStats.commandsKnown,
|
||||
active: clientStats.commandsRegistered,
|
||||
disabled: clientStats.commandsKnown - clientStats.commandsRegistered
|
||||
},
|
||||
ping: { avg: clientStats.ping },
|
||||
economy: {
|
||||
totalWealth: economyStats.totalWealth.toString(),
|
||||
avgLevel: economyStats.avgLevel,
|
||||
topStreak: economyStats.topStreak,
|
||||
totalItems,
|
||||
},
|
||||
recentEvents: recentEvents.map(event => ({
|
||||
...event,
|
||||
timestamp: event.timestamp instanceof Date ? event.timestamp.toISOString() : event.timestamp,
|
||||
})),
|
||||
activeLootdrops: activeLootdrops.map(drop => ({
|
||||
rewardAmount: drop.rewardAmount,
|
||||
currency: drop.currency,
|
||||
createdAt: drop.createdAt.toISOString(),
|
||||
expiresAt: drop.expiresAt ? drop.expiresAt.toISOString() : null,
|
||||
// Explicitly excluding channelId/messageId to prevent sniping
|
||||
})),
|
||||
lootdropState,
|
||||
leaderboards,
|
||||
uptime: clientStats.uptime,
|
||||
lastCommandTimestamp: clientStats.lastCommandTimestamp,
|
||||
maintenanceMode: (await import("../../../bot/lib/BotClient")).AuroraClient.maintenanceMode,
|
||||
};
|
||||
}
|
||||
85
api/src/routes/stats.routes.ts
Normal file
85
api/src/routes/stats.routes.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @fileoverview Statistics endpoints for Aurora API.
|
||||
* Provides dashboard statistics and activity aggregation data.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
|
||||
|
||||
// Cache for activity stats (heavy aggregation)
|
||||
let activityPromise: Promise<import("@shared/modules/dashboard/dashboard.types").ActivityData[]> | null = null;
|
||||
let lastActivityFetch: number = 0;
|
||||
const ACTIVITY_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Stats routes handler.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/stats - Full dashboard statistics
|
||||
* - GET /api/stats/activity - Activity aggregation with caching
|
||||
*/
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method } = ctx;
|
||||
|
||||
/**
|
||||
* @route GET /api/stats
|
||||
* @description Returns comprehensive dashboard statistics including
|
||||
* bot info, user counts, economy data, and leaderboards.
|
||||
* @response 200 - Full dashboard stats object
|
||||
* @response 500 - Error fetching statistics
|
||||
*/
|
||||
if (pathname === "/api/stats" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
// Import the stats function from wherever it's defined
|
||||
// This will be passed in during initialization
|
||||
const { getFullDashboardStats } = await import("./stats.helper.ts");
|
||||
const stats = await getFullDashboardStats();
|
||||
return jsonResponse(stats);
|
||||
}, "fetch dashboard stats");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route GET /api/stats/activity
|
||||
* @description Returns activity aggregation data with 5-minute caching.
|
||||
* Heavy query, results are cached to reduce database load.
|
||||
* @response 200 - Array of activity data points
|
||||
* @response 500 - Error fetching activity statistics
|
||||
*
|
||||
* @example
|
||||
* // Response
|
||||
* [
|
||||
* { "date": "2024-02-08", "commands": 150, "users": 25 },
|
||||
* { "date": "2024-02-07", "commands": 200, "users": 30 }
|
||||
* ]
|
||||
*/
|
||||
if (pathname === "/api/stats/activity" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const now = Date.now();
|
||||
|
||||
// If we have a valid cache, return it
|
||||
if (activityPromise && (now - lastActivityFetch < ACTIVITY_CACHE_TTL)) {
|
||||
const data = await activityPromise;
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
// Otherwise, trigger a new fetch (deduplicated by the promise)
|
||||
if (!activityPromise || (now - lastActivityFetch >= ACTIVITY_CACHE_TTL)) {
|
||||
activityPromise = (async () => {
|
||||
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||
return await dashboardService.getActivityAggregation();
|
||||
})();
|
||||
lastActivityFetch = now;
|
||||
}
|
||||
|
||||
const activity = await activityPromise;
|
||||
return jsonResponse(activity);
|
||||
}, "fetch activity stats");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const statsRoutes: RouteModule = {
|
||||
name: "stats",
|
||||
handler
|
||||
};
|
||||
91
api/src/routes/transactions.routes.ts
Normal file
91
api/src/routes/transactions.routes.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @fileoverview Transaction listing endpoints for Aurora API.
|
||||
* Provides read access to economy transaction history.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import { jsonResponse, withErrorHandling } from "./utils";
|
||||
|
||||
/**
|
||||
* Transactions routes handler.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/transactions - List transactions with filters
|
||||
*/
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method, url } = ctx;
|
||||
|
||||
/**
|
||||
* @route GET /api/transactions
|
||||
* @description Returns economy transactions with optional filtering.
|
||||
*
|
||||
* @query userId - Filter by user ID (Discord snowflake)
|
||||
* @query type - Filter by transaction type
|
||||
* @query limit - Max results (default: 50)
|
||||
* @query offset - Pagination offset (default: 0)
|
||||
*
|
||||
* @response 200 - `{ transactions: Transaction[] }`
|
||||
* @response 500 - Error fetching transactions
|
||||
*
|
||||
* Transaction Types:
|
||||
* - DAILY_REWARD - Daily claim reward
|
||||
* - TRANSFER_IN - Received from another user
|
||||
* - TRANSFER_OUT - Sent to another user
|
||||
* - LOOTDROP_CLAIM - Claimed lootdrop
|
||||
* - SHOP_BUY - Item purchase
|
||||
* - QUEST_REWARD - Quest completion reward
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* GET /api/transactions?userId=123456789&type=DAILY_REWARD&limit=10
|
||||
*
|
||||
* // Response
|
||||
* {
|
||||
* "transactions": [
|
||||
* {
|
||||
* "id": "1",
|
||||
* "userId": "123456789",
|
||||
* "amount": "100",
|
||||
* "type": "DAILY_REWARD",
|
||||
* "description": "Daily reward (Streak: 3)",
|
||||
* "createdAt": "2024-01-15T12:00:00Z"
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
if (pathname === "/api/transactions" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const { transactions } = await import("@shared/db/schema");
|
||||
const { DrizzleClient } = await import("@shared/db/DrizzleClient");
|
||||
const { eq, desc } = await import("drizzle-orm");
|
||||
|
||||
const userId = url.searchParams.get("userId");
|
||||
const type = url.searchParams.get("type");
|
||||
const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
|
||||
const offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
|
||||
|
||||
let query = DrizzleClient.select().from(transactions);
|
||||
|
||||
if (userId) {
|
||||
query = query.where(eq(transactions.userId, BigInt(userId))) as typeof query;
|
||||
}
|
||||
if (type) {
|
||||
query = query.where(eq(transactions.type, type)) as typeof query;
|
||||
}
|
||||
|
||||
const result = await query
|
||||
.orderBy(desc(transactions.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return jsonResponse({ transactions: result });
|
||||
}, "fetch transactions");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const transactionsRoutes: RouteModule = {
|
||||
name: "transactions",
|
||||
handler
|
||||
};
|
||||
94
api/src/routes/types.ts
Normal file
94
api/src/routes/types.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @fileoverview Shared types for the Aurora API routing system.
|
||||
* Provides type definitions for route handlers, responses, and errors.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Standard API error response structure.
|
||||
*/
|
||||
export interface ApiErrorResponse {
|
||||
error: string;
|
||||
details?: string;
|
||||
issues?: Array<{ path: (string | number)[]; message: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard API success response with optional data wrapper.
|
||||
*/
|
||||
export interface ApiSuccessResponse<T = unknown> {
|
||||
success: true;
|
||||
[key: string]: T | true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route context passed to all route handlers.
|
||||
* Contains parsed URL information and the original request.
|
||||
*/
|
||||
export interface RouteContext {
|
||||
/** The original HTTP request */
|
||||
req: Request;
|
||||
/** Parsed URL object */
|
||||
url: URL;
|
||||
/** HTTP method (GET, POST, PUT, DELETE, etc.) */
|
||||
method: string;
|
||||
/** URL pathname without query string */
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A route handler function that processes a request and returns a response.
|
||||
* Returns null if the route doesn't match, allowing the next handler to try.
|
||||
*/
|
||||
export type RouteHandler = (ctx: RouteContext) => Promise<Response | null> | Response | null;
|
||||
|
||||
/**
|
||||
* A route module that exports a handler function.
|
||||
*/
|
||||
export interface RouteModule {
|
||||
/** Human-readable name for debugging */
|
||||
name: string;
|
||||
/** The route handler function */
|
||||
handler: RouteHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom API error class with HTTP status code support.
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number = 500,
|
||||
public readonly details?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a 400 Bad Request error.
|
||||
*/
|
||||
static badRequest(message: string, details?: string): ApiError {
|
||||
return new ApiError(message, 400, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a 404 Not Found error.
|
||||
*/
|
||||
static notFound(resource: string): ApiError {
|
||||
return new ApiError(`${resource} not found`, 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a 409 Conflict error.
|
||||
*/
|
||||
static conflict(message: string): ApiError {
|
||||
return new ApiError(message, 409);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a 500 Internal Server Error.
|
||||
*/
|
||||
static internal(message: string, details?: string): ApiError {
|
||||
return new ApiError(message, 500, details);
|
||||
}
|
||||
}
|
||||
263
api/src/routes/users.routes.ts
Normal file
263
api/src/routes/users.routes.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* @fileoverview User management endpoints for Aurora API.
|
||||
* Provides CRUD operations for users and user inventory.
|
||||
*/
|
||||
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import {
|
||||
jsonResponse,
|
||||
errorResponse,
|
||||
parseBody,
|
||||
parseIdFromPath,
|
||||
parseStringIdFromPath,
|
||||
withErrorHandling
|
||||
} from "./utils";
|
||||
import { UpdateUserSchema, InventoryAddSchema } from "./schemas";
|
||||
|
||||
/**
|
||||
* Users routes handler.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/users - List users with filters
|
||||
* - GET /api/users/:id - Get single user
|
||||
* - PUT /api/users/:id - Update user
|
||||
* - GET /api/users/:id/inventory - Get user inventory
|
||||
* - POST /api/users/:id/inventory - Add item to inventory
|
||||
* - DELETE /api/users/:id/inventory/:itemId - Remove item from inventory
|
||||
*/
|
||||
async function handler(ctx: RouteContext): Promise<Response | null> {
|
||||
const { pathname, method, req, url } = ctx;
|
||||
|
||||
// Only handle requests to /api/users*
|
||||
if (!pathname.startsWith("/api/users")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @route GET /api/users
|
||||
* @description Returns a paginated list of users with optional filtering and sorting.
|
||||
*
|
||||
* @query search - Filter by username (partial match)
|
||||
* @query sortBy - Sort field: balance, level, xp, username (default: balance)
|
||||
* @query sortOrder - Sort direction: asc, desc (default: desc)
|
||||
* @query limit - Max results (default: 50)
|
||||
* @query offset - Pagination offset (default: 0)
|
||||
*
|
||||
* @response 200 - `{ users: User[], total: number }`
|
||||
* @response 500 - Error fetching users
|
||||
*
|
||||
* @example
|
||||
* // Request
|
||||
* GET /api/users?sortBy=level&sortOrder=desc&limit=10
|
||||
*/
|
||||
if (pathname === "/api/users" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const { users } = await import("@shared/db/schema");
|
||||
const { DrizzleClient } = await import("@shared/db/DrizzleClient");
|
||||
const { ilike, desc, asc, sql } = await import("drizzle-orm");
|
||||
|
||||
const search = url.searchParams.get("search") || undefined;
|
||||
const sortBy = url.searchParams.get("sortBy") || "balance";
|
||||
const sortOrder = url.searchParams.get("sortOrder") || "desc";
|
||||
const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
|
||||
const offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
|
||||
|
||||
let query = DrizzleClient.select().from(users);
|
||||
|
||||
if (search) {
|
||||
query = query.where(ilike(users.username, `%${search}%`)) as typeof query;
|
||||
}
|
||||
|
||||
const sortColumn = sortBy === "level" ? users.level :
|
||||
sortBy === "xp" ? users.xp :
|
||||
sortBy === "username" ? users.username : users.balance;
|
||||
const orderFn = sortOrder === "asc" ? asc : desc;
|
||||
|
||||
const result = await query
|
||||
.orderBy(orderFn(sortColumn))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const countResult = await DrizzleClient.select({ count: sql<number>`count(*)` }).from(users);
|
||||
const total = Number(countResult[0]?.count || 0);
|
||||
|
||||
return jsonResponse({ users: result, total });
|
||||
}, "fetch users");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route GET /api/users/:id
|
||||
* @description Returns a single user by Discord ID.
|
||||
* Includes related class information if the user has a class assigned.
|
||||
*
|
||||
* @param id - Discord User ID (snowflake)
|
||||
* @response 200 - Full user object with class relation
|
||||
* @response 404 - User not found
|
||||
* @response 500 - Error fetching user
|
||||
*
|
||||
* @example
|
||||
* // Response
|
||||
* {
|
||||
* "id": "123456789012345678",
|
||||
* "username": "Player1",
|
||||
* "balance": "1000",
|
||||
* "level": 5,
|
||||
* "class": { "id": "1", "name": "Warrior" }
|
||||
* }
|
||||
*/
|
||||
if (pathname.match(/^\/api\/users\/\d+$/) && method === "GET") {
|
||||
const id = parseStringIdFromPath(pathname);
|
||||
if (!id) return null;
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const { userService } = await import("@shared/modules/user/user.service");
|
||||
const user = await userService.getUserById(id);
|
||||
|
||||
if (!user) {
|
||||
return errorResponse("User not found", 404);
|
||||
}
|
||||
|
||||
return jsonResponse(user);
|
||||
}, "fetch user");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route PUT /api/users/:id
|
||||
* @description Updates user fields. Only provided fields will be updated.
|
||||
*
|
||||
* @param id - Discord User ID (snowflake)
|
||||
* @body {
|
||||
* username?: string,
|
||||
* balance?: string | number,
|
||||
* xp?: string | number,
|
||||
* level?: number,
|
||||
* dailyStreak?: number,
|
||||
* isActive?: boolean,
|
||||
* settings?: object,
|
||||
* classId?: string | number
|
||||
* }
|
||||
* @response 200 - `{ success: true, user: User }`
|
||||
* @response 404 - User not found
|
||||
* @response 500 - Error updating user
|
||||
*/
|
||||
if (pathname.match(/^\/api\/users\/\d+$/) && method === "PUT") {
|
||||
const id = parseStringIdFromPath(pathname);
|
||||
if (!id) return null;
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const { userService } = await import("@shared/modules/user/user.service");
|
||||
const data = await req.json() as Record<string, any>;
|
||||
|
||||
const existing = await userService.getUserById(id);
|
||||
if (!existing) {
|
||||
return errorResponse("User not found", 404);
|
||||
}
|
||||
|
||||
// Build update data (only allow safe fields)
|
||||
const updateData: any = {};
|
||||
if (data.username !== undefined) updateData.username = data.username;
|
||||
if (data.balance !== undefined) updateData.balance = BigInt(data.balance);
|
||||
if (data.xp !== undefined) updateData.xp = BigInt(data.xp);
|
||||
if (data.level !== undefined) updateData.level = parseInt(data.level);
|
||||
if (data.dailyStreak !== undefined) updateData.dailyStreak = parseInt(data.dailyStreak);
|
||||
if (data.isActive !== undefined) updateData.isActive = Boolean(data.isActive);
|
||||
if (data.settings !== undefined) updateData.settings = data.settings;
|
||||
if (data.classId !== undefined) updateData.classId = BigInt(data.classId);
|
||||
|
||||
const updatedUser = await userService.updateUser(id, updateData);
|
||||
return jsonResponse({ success: true, user: updatedUser });
|
||||
}, "update user");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route GET /api/users/:id/inventory
|
||||
* @description Returns user's inventory with item details.
|
||||
*
|
||||
* @param id - Discord User ID (snowflake)
|
||||
* @response 200 - `{ inventory: InventoryEntry[] }`
|
||||
* @response 500 - Error fetching inventory
|
||||
*
|
||||
* @example
|
||||
* // Response
|
||||
* {
|
||||
* "inventory": [
|
||||
* {
|
||||
* "userId": "123456789",
|
||||
* "itemId": 1,
|
||||
* "quantity": "5",
|
||||
* "item": { "id": 1, "name": "Health Potion", ... }
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
if (pathname.match(/^\/api\/users\/\d+\/inventory$/) && method === "GET") {
|
||||
const id = pathname.split("/")[3] || "0";
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
|
||||
const inventory = await inventoryService.getInventory(id);
|
||||
return jsonResponse({ inventory });
|
||||
}, "fetch inventory");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route POST /api/users/:id/inventory
|
||||
* @description Adds an item to user's inventory.
|
||||
*
|
||||
* @param id - Discord User ID (snowflake)
|
||||
* @body { itemId: number, quantity: string | number }
|
||||
* @response 201 - `{ success: true, entry: InventoryEntry }`
|
||||
* @response 400 - Missing required fields
|
||||
* @response 500 - Error adding item
|
||||
*/
|
||||
if (pathname.match(/^\/api\/users\/\d+\/inventory$/) && method === "POST") {
|
||||
const id = pathname.split("/")[3] || "0";
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
|
||||
const data = await req.json() as Record<string, any>;
|
||||
|
||||
if (!data.itemId || !data.quantity) {
|
||||
return errorResponse("Missing required fields: itemId, quantity", 400);
|
||||
}
|
||||
|
||||
const entry = await inventoryService.addItem(id, data.itemId, BigInt(data.quantity));
|
||||
return jsonResponse({ success: true, entry }, 201);
|
||||
}, "add item to inventory");
|
||||
}
|
||||
|
||||
/**
|
||||
* @route DELETE /api/users/:id/inventory/:itemId
|
||||
* @description Removes an item from user's inventory.
|
||||
*
|
||||
* @param id - Discord User ID (snowflake)
|
||||
* @param itemId - Item ID to remove
|
||||
* @query amount - Quantity to remove (default: 1)
|
||||
* @response 204 - Item removed (no content)
|
||||
* @response 500 - Error removing item
|
||||
*/
|
||||
if (pathname.match(/^\/api\/users\/\d+\/inventory\/\d+$/) && method === "DELETE") {
|
||||
const parts = pathname.split("/");
|
||||
const userId = parts[3] || "";
|
||||
const itemId = parseInt(parts[5] || "0");
|
||||
|
||||
if (!userId) return null;
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
|
||||
|
||||
const amount = url.searchParams.get("amount");
|
||||
const quantity = amount ? BigInt(amount) : 1n;
|
||||
|
||||
await inventoryService.removeItem(userId, itemId, quantity);
|
||||
return new Response(null, { status: 204 });
|
||||
}, "remove item from inventory");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const usersRoutes: RouteModule = {
|
||||
name: "users",
|
||||
handler
|
||||
};
|
||||
213
api/src/routes/utils.ts
Normal file
213
api/src/routes/utils.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* @fileoverview Utility functions for Aurora API route handlers.
|
||||
* Provides helpers for response formatting, parameter parsing, and validation.
|
||||
*/
|
||||
|
||||
import { z, ZodError, type ZodSchema } from "zod";
|
||||
import type { ApiErrorResponse } from "./types";
|
||||
|
||||
/**
|
||||
* JSON replacer function that handles BigInt serialization.
|
||||
* Converts BigInt values to strings for JSON compatibility.
|
||||
*/
|
||||
export function jsonReplacer(_key: string, value: unknown): unknown {
|
||||
return typeof value === "bigint" ? value.toString() : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a JSON response with proper content-type header and BigInt handling.
|
||||
*
|
||||
* @param data - The data to serialize as JSON
|
||||
* @param status - HTTP status code (default: 200)
|
||||
* @returns A Response object with JSON content
|
||||
*
|
||||
* @example
|
||||
* return jsonResponse({ items: [...], total: 10 });
|
||||
* return jsonResponse({ success: true, item }, 201);
|
||||
*/
|
||||
export function jsonResponse<T>(data: T, status: number = 200): Response {
|
||||
return new Response(JSON.stringify(data, jsonReplacer), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a standardized error response.
|
||||
*
|
||||
* @param error - Error message
|
||||
* @param status - HTTP status code (default: 500)
|
||||
* @param details - Optional additional error details
|
||||
* @returns A Response object with error JSON
|
||||
*
|
||||
* @example
|
||||
* return errorResponse("Item not found", 404);
|
||||
* return errorResponse("Validation failed", 400, "Name is required");
|
||||
*/
|
||||
export function errorResponse(
|
||||
error: string,
|
||||
status: number = 500,
|
||||
details?: string
|
||||
): Response {
|
||||
const body: ApiErrorResponse = { error };
|
||||
if (details) body.details = details;
|
||||
|
||||
return Response.json(body, { status });
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a validation error response from a ZodError.
|
||||
*
|
||||
* @param zodError - The ZodError from a failed parse
|
||||
* @returns A 400 Response with validation issue details
|
||||
*/
|
||||
export function validationErrorResponse(zodError: ZodError): Response {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Invalid payload",
|
||||
issues: zodError.issues.map(issue => ({
|
||||
path: issue.path,
|
||||
message: issue.message
|
||||
}))
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and validates a request body against a Zod schema.
|
||||
*
|
||||
* @param req - The HTTP request
|
||||
* @param schema - Zod schema to validate against
|
||||
* @returns Validated data or an error Response
|
||||
*
|
||||
* @example
|
||||
* const result = await parseBody(req, CreateItemSchema);
|
||||
* if (result instanceof Response) return result; // Validation failed
|
||||
* const data = result; // Type-safe validated data
|
||||
*/
|
||||
export async function parseBody<T extends ZodSchema>(
|
||||
req: Request,
|
||||
schema: T
|
||||
): Promise<z.infer<T> | Response> {
|
||||
try {
|
||||
const rawBody = await req.json();
|
||||
const parsed = schema.safeParse(rawBody);
|
||||
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error);
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
} catch (e) {
|
||||
return errorResponse("Invalid JSON body", 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses query parameters against a Zod schema.
|
||||
*
|
||||
* @param url - The URL containing query parameters
|
||||
* @param schema - Zod schema to validate against
|
||||
* @returns Validated query params or an error Response
|
||||
*/
|
||||
export function parseQuery<T extends ZodSchema>(
|
||||
url: URL,
|
||||
schema: T
|
||||
): z.infer<T> | Response {
|
||||
const params: Record<string, string> = {};
|
||||
url.searchParams.forEach((value, key) => {
|
||||
params[key] = value;
|
||||
});
|
||||
|
||||
const parsed = schema.safeParse(params);
|
||||
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error);
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a numeric ID from a URL path segment.
|
||||
*
|
||||
* @param pathname - The URL pathname
|
||||
* @param position - Position from the end (0 = last segment, 1 = second-to-last, etc.)
|
||||
* @returns The parsed integer ID or null if invalid
|
||||
*
|
||||
* @example
|
||||
* parseIdFromPath("/api/items/123") // returns 123
|
||||
* parseIdFromPath("/api/items/abc") // returns null
|
||||
* parseIdFromPath("/api/users/456/inventory", 1) // returns 456
|
||||
*/
|
||||
export function parseIdFromPath(pathname: string, position: number = 0): number | null {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const segment = segments[segments.length - 1 - position];
|
||||
|
||||
if (!segment) return null;
|
||||
|
||||
const id = parseInt(segment, 10);
|
||||
return isNaN(id) ? null : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a string ID (like Discord snowflake) from a URL path segment.
|
||||
*
|
||||
* @param pathname - The URL pathname
|
||||
* @param position - Position from the end (0 = last segment)
|
||||
* @returns The string ID or null if segment doesn't exist
|
||||
*/
|
||||
export function parseStringIdFromPath(pathname: string, position: number = 0): string | null {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const segment = segments[segments.length - 1 - position];
|
||||
return segment || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a pathname matches a pattern with optional parameter placeholders.
|
||||
*
|
||||
* @param pathname - The actual URL pathname
|
||||
* @param pattern - The pattern to match (use :id for numeric params, :param for string params)
|
||||
* @returns True if the pattern matches
|
||||
*
|
||||
* @example
|
||||
* matchPath("/api/items/123", "/api/items/:id") // true
|
||||
* matchPath("/api/items", "/api/items/:id") // false
|
||||
*/
|
||||
export function matchPath(pathname: string, pattern: string): boolean {
|
||||
const pathParts = pathname.split("/").filter(Boolean);
|
||||
const patternParts = pattern.split("/").filter(Boolean);
|
||||
|
||||
if (pathParts.length !== patternParts.length) return false;
|
||||
|
||||
return patternParts.every((part, i) => {
|
||||
if (part.startsWith(":")) return true; // Matches any value
|
||||
return part === pathParts[i];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an async route handler with consistent error handling.
|
||||
* Catches all errors and returns appropriate error responses.
|
||||
*
|
||||
* @param handler - The async handler function
|
||||
* @param logContext - Context string for error logging
|
||||
* @returns A wrapped handler with error handling
|
||||
*/
|
||||
export function withErrorHandling(
|
||||
handler: () => Promise<Response>,
|
||||
logContext: string
|
||||
): Promise<Response> {
|
||||
return handler().catch((error: unknown) => {
|
||||
// Dynamic import to avoid circular dependencies
|
||||
return import("@shared/lib/logger").then(({ logger }) => {
|
||||
logger.error("web", `Error in ${logContext}`, error);
|
||||
return errorResponse(
|
||||
`Failed to ${logContext.toLowerCase()}`,
|
||||
500,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
446
api/src/server.items.test.ts
Normal file
446
api/src/server.items.test.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import { describe, test, expect, afterAll, beforeAll, mock } from "bun:test";
|
||||
import type { WebServerInstance } from "./server";
|
||||
import { createWebServer } from "./server";
|
||||
|
||||
/**
|
||||
* Items API Integration Tests
|
||||
*
|
||||
* Tests the full CRUD functionality for the Items management API.
|
||||
* Uses mocked database and service layers.
|
||||
*/
|
||||
|
||||
// --- Mock Types ---
|
||||
interface MockItem {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
rarity: string;
|
||||
type: string;
|
||||
price: bigint | null;
|
||||
iconUrl: string;
|
||||
imageUrl: string;
|
||||
usageData: { consume: boolean; effects: any[] } | null;
|
||||
}
|
||||
|
||||
// --- Mock Data ---
|
||||
let mockItems: MockItem[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Health Potion",
|
||||
description: "Restores health",
|
||||
rarity: "C",
|
||||
type: "CONSUMABLE",
|
||||
price: 100n,
|
||||
iconUrl: "/assets/items/1.png",
|
||||
imageUrl: "/assets/items/1.png",
|
||||
usageData: { consume: true, effects: [] },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Iron Sword",
|
||||
description: "A basic sword",
|
||||
rarity: "R",
|
||||
type: "EQUIPMENT",
|
||||
price: 500n,
|
||||
iconUrl: "/assets/items/2.png",
|
||||
imageUrl: "/assets/items/2.png",
|
||||
usageData: null,
|
||||
},
|
||||
];
|
||||
|
||||
let mockIdCounter = 3;
|
||||
|
||||
// --- Mock Items Service ---
|
||||
mock.module("@shared/modules/items/items.service", () => ({
|
||||
itemsService: {
|
||||
getAllItems: mock(async (filters: any = {}) => {
|
||||
let filtered = [...mockItems];
|
||||
|
||||
if (filters.search) {
|
||||
const search = filters.search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(search) ||
|
||||
(item.description?.toLowerCase().includes(search) ?? false)
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.type) {
|
||||
filtered = filtered.filter((item) => item.type === filters.type);
|
||||
}
|
||||
|
||||
if (filters.rarity) {
|
||||
filtered = filtered.filter((item) => item.rarity === filters.rarity);
|
||||
}
|
||||
|
||||
return {
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
};
|
||||
}),
|
||||
|
||||
getItemById: mock(async (id: number) => {
|
||||
return mockItems.find((item) => item.id === id) ?? null;
|
||||
}),
|
||||
|
||||
isNameTaken: mock(async (name: string, excludeId?: number) => {
|
||||
return mockItems.some(
|
||||
(item) =>
|
||||
item.name.toLowerCase() === name.toLowerCase() &&
|
||||
item.id !== excludeId
|
||||
);
|
||||
}),
|
||||
|
||||
createItem: mock(async (data: any) => {
|
||||
const newItem: MockItem = {
|
||||
id: mockIdCounter++,
|
||||
name: data.name,
|
||||
description: data.description ?? null,
|
||||
rarity: data.rarity ?? "C",
|
||||
type: data.type,
|
||||
price: data.price ?? null,
|
||||
iconUrl: data.iconUrl,
|
||||
imageUrl: data.imageUrl,
|
||||
usageData: data.usageData ?? null,
|
||||
};
|
||||
mockItems.push(newItem);
|
||||
return newItem;
|
||||
}),
|
||||
|
||||
updateItem: mock(async (id: number, data: any) => {
|
||||
const index = mockItems.findIndex((item) => item.id === id);
|
||||
if (index === -1) return null;
|
||||
|
||||
mockItems[index] = { ...mockItems[index], ...data };
|
||||
return mockItems[index];
|
||||
}),
|
||||
|
||||
deleteItem: mock(async (id: number) => {
|
||||
const index = mockItems.findIndex((item) => item.id === id);
|
||||
if (index === -1) return null;
|
||||
|
||||
const [deleted] = mockItems.splice(index, 1);
|
||||
return deleted;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// --- Mock Utilities ---
|
||||
mock.module("@shared/lib/utils", () => ({
|
||||
deepMerge: (target: any, source: any) => ({ ...target, ...source }),
|
||||
jsonReplacer: (key: string, value: any) =>
|
||||
typeof value === "bigint" ? value.toString() : value,
|
||||
}));
|
||||
|
||||
// --- Mock Auth (bypass authentication) ---
|
||||
mock.module("./routes/auth.routes", () => ({
|
||||
authRoutes: { name: "auth", handler: () => null },
|
||||
isAuthenticated: () => true,
|
||||
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
|
||||
}));
|
||||
|
||||
// --- Mock Logger ---
|
||||
mock.module("@shared/lib/logger", () => ({
|
||||
logger: {
|
||||
info: () => { },
|
||||
warn: () => { },
|
||||
error: () => { },
|
||||
debug: () => { },
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Items API", () => {
|
||||
const port = 3002;
|
||||
const hostname = "127.0.0.1";
|
||||
const baseUrl = `http://${hostname}:${port}`;
|
||||
let serverInstance: WebServerInstance | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Reset mock data before all tests
|
||||
mockItems = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Health Potion",
|
||||
description: "Restores health",
|
||||
rarity: "C",
|
||||
type: "CONSUMABLE",
|
||||
price: 100n,
|
||||
iconUrl: "/assets/items/1.png",
|
||||
imageUrl: "/assets/items/1.png",
|
||||
usageData: { consume: true, effects: [] },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Iron Sword",
|
||||
description: "A basic sword",
|
||||
rarity: "R",
|
||||
type: "EQUIPMENT",
|
||||
price: 500n,
|
||||
iconUrl: "/assets/items/2.png",
|
||||
imageUrl: "/assets/items/2.png",
|
||||
usageData: null,
|
||||
},
|
||||
];
|
||||
mockIdCounter = 3;
|
||||
|
||||
serverInstance = await createWebServer({ port, hostname });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (serverInstance) {
|
||||
await serverInstance.stop();
|
||||
}
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// GET /api/items Tests
|
||||
// ===========================================
|
||||
describe("GET /api/items", () => {
|
||||
test("should return all items", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = (await response.json()) as { items: MockItem[]; total: number };
|
||||
expect(data.items).toBeInstanceOf(Array);
|
||||
expect(data.total).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test("should filter items by search query", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items?search=potion`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = (await response.json()) as { items: MockItem[]; total: number };
|
||||
expect(data.items.every((item) =>
|
||||
item.name.toLowerCase().includes("potion") ||
|
||||
(item.description?.toLowerCase().includes("potion") ?? false)
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
test("should filter items by type", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items?type=CONSUMABLE`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = (await response.json()) as { items: MockItem[]; total: number };
|
||||
expect(data.items.every((item) => item.type === "CONSUMABLE")).toBe(true);
|
||||
});
|
||||
|
||||
test("should filter items by rarity", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items?rarity=C`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = (await response.json()) as { items: MockItem[]; total: number };
|
||||
expect(data.items.every((item) => item.rarity === "C")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// GET /api/items/:id Tests
|
||||
// ===========================================
|
||||
describe("GET /api/items/:id", () => {
|
||||
test("should return a single item by ID", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items/1`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = (await response.json()) as MockItem;
|
||||
expect(data.id).toBe(1);
|
||||
expect(data.name).toBe("Health Potion");
|
||||
});
|
||||
|
||||
test("should return 404 for non-existent item", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items/9999`);
|
||||
expect(response.status).toBe(404);
|
||||
|
||||
const data = (await response.json()) as { error: string };
|
||||
expect(data.error).toBe("Item not found");
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// POST /api/items Tests
|
||||
// ===========================================
|
||||
describe("POST /api/items", () => {
|
||||
test("should create a new item", async () => {
|
||||
const newItem = {
|
||||
name: "Magic Staff",
|
||||
description: "A powerful staff",
|
||||
rarity: "SR",
|
||||
type: "EQUIPMENT",
|
||||
price: "1000",
|
||||
iconUrl: "/assets/items/placeholder.png",
|
||||
imageUrl: "/assets/items/placeholder.png",
|
||||
};
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/items`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(newItem),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
const data = (await response.json()) as { success: boolean; item: MockItem };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.item.name).toBe("Magic Staff");
|
||||
expect(data.item.id).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("should reject item without required fields", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ description: "No name or type" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const data = (await response.json()) as { error: string };
|
||||
expect(data.error).toContain("required");
|
||||
});
|
||||
|
||||
test("should reject duplicate item name", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: "Health Potion", // Already exists
|
||||
type: "CONSUMABLE",
|
||||
iconUrl: "/assets/items/placeholder.png",
|
||||
imageUrl: "/assets/items/placeholder.png",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
|
||||
const data = (await response.json()) as { error: string };
|
||||
expect(data.error).toContain("already exists");
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// PUT /api/items/:id Tests
|
||||
// ===========================================
|
||||
describe("PUT /api/items/:id", () => {
|
||||
test("should update an existing item", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items/1`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
description: "Updated description",
|
||||
price: "200",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = (await response.json()) as { success: boolean; item: MockItem };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.item.description).toBe("Updated description");
|
||||
});
|
||||
|
||||
test("should return 404 for updating non-existent item", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items/9999`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "New Name" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
test("should reject duplicate name when updating", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items/2`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: "Health Potion", // ID 1 has this name
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// DELETE /api/items/:id Tests
|
||||
// ===========================================
|
||||
describe("DELETE /api/items/:id", () => {
|
||||
test("should delete an existing item", async () => {
|
||||
// First, create an item to delete
|
||||
const createResponse = await fetch(`${baseUrl}/api/items`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: "Item to Delete",
|
||||
type: "MATERIAL",
|
||||
iconUrl: "/assets/items/placeholder.png",
|
||||
imageUrl: "/assets/items/placeholder.png",
|
||||
}),
|
||||
});
|
||||
|
||||
const { item } = (await createResponse.json()) as { item: MockItem };
|
||||
|
||||
// Now delete it
|
||||
const deleteResponse = await fetch(`${baseUrl}/api/items/${item.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
expect(deleteResponse.status).toBe(204);
|
||||
|
||||
// Verify it's gone
|
||||
const getResponse = await fetch(`${baseUrl}/api/items/${item.id}`);
|
||||
expect(getResponse.status).toBe(404);
|
||||
});
|
||||
|
||||
test("should return 404 for deleting non-existent item", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items/9999`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// Static Asset Serving Tests
|
||||
// ===========================================
|
||||
describe("Static Asset Serving (/assets/*)", () => {
|
||||
test("should return 404 for non-existent asset", async () => {
|
||||
const response = await fetch(`${baseUrl}/assets/items/nonexistent.png`);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
test("should prevent path traversal attacks", async () => {
|
||||
// Note: fetch() and HTTP servers normalize ".." segments before the handler sees them,
|
||||
// so we can't send raw traversal paths over HTTP. Instead, test that a suspicious
|
||||
// asset path (with encoded sequences) doesn't serve sensitive file content.
|
||||
const response = await fetch(`${baseUrl}/assets/..%2f..%2f..%2fetc%2fpasswd`);
|
||||
// Should not serve actual file content — expect 403 or 404
|
||||
expect([403, 404]).toContain(response.status);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// Validation Edge Cases
|
||||
// ===========================================
|
||||
describe("Validation Edge Cases", () => {
|
||||
test("should handle empty search query gracefully", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items?search=`);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("should handle invalid pagination values", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items?limit=abc&offset=xyz`);
|
||||
// Should not crash, may use defaults
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("should handle missing content-type header", async () => {
|
||||
const response = await fetch(`${baseUrl}/api/items`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "Test", type: "MATERIAL" }),
|
||||
});
|
||||
// May fail due to no content-type, but shouldn't crash
|
||||
expect([200, 201, 400, 415]).toContain(response.status);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,40 +1,64 @@
|
||||
import { describe, expect, it, mock, beforeEach, afterEach, jest } from "bun:test";
|
||||
import { type WebServerInstance } from "./server";
|
||||
|
||||
// Mock the dependencies
|
||||
const mockConfig = {
|
||||
// Mock gameSettingsService — the route now uses this instead of config/saveConfig
|
||||
const mockSettings = {
|
||||
leveling: {
|
||||
base: 100,
|
||||
exponent: 1.5,
|
||||
chat: { minXp: 10, maxXp: 20, cooldownMs: 60000 }
|
||||
},
|
||||
economy: {
|
||||
daily: { amount: 100n, streakBonus: 10n, weeklyBonus: 50n, cooldownMs: 86400000 },
|
||||
transfers: { allowSelfTransfer: false, minAmount: 50n },
|
||||
daily: { amount: "100", streakBonus: "10", weeklyBonus: "50", cooldownMs: 86400000 },
|
||||
transfers: { allowSelfTransfer: false, minAmount: "1" },
|
||||
exam: { multMin: 1.5, multMax: 2.5 }
|
||||
},
|
||||
inventory: { maxStackSize: 99n, maxSlots: 20 },
|
||||
inventory: { maxStackSize: "99", maxSlots: 20 },
|
||||
lootdrop: {
|
||||
spawnChance: 0.1,
|
||||
cooldownMs: 3600000,
|
||||
minMessages: 10,
|
||||
activityWindowMs: 300000,
|
||||
reward: { min: 100, max: 500, currency: "gold" }
|
||||
},
|
||||
commands: { "help": true },
|
||||
system: {},
|
||||
moderation: {
|
||||
prune: { maxAmount: 100, confirmThreshold: 50, batchSize: 100, batchDelayMs: 1000 },
|
||||
cases: { dmOnWarn: true }
|
||||
},
|
||||
trivia: {
|
||||
entryFee: "50",
|
||||
rewardMultiplier: 1.5,
|
||||
timeoutSeconds: 30,
|
||||
cooldownMs: 60000,
|
||||
categories: [],
|
||||
difficulty: "random"
|
||||
}
|
||||
};
|
||||
|
||||
const mockSaveConfig = jest.fn();
|
||||
const mockGetSettings = jest.fn(() => Promise.resolve(mockSettings));
|
||||
const mockUpsertSettings = jest.fn(() => Promise.resolve(mockSettings));
|
||||
const mockGetDefaults = jest.fn(() => mockSettings);
|
||||
|
||||
// Mock @shared/lib/config using mock.module
|
||||
mock.module("@shared/lib/config", () => ({
|
||||
config: mockConfig,
|
||||
saveConfig: mockSaveConfig,
|
||||
GameConfigType: {}
|
||||
mock.module("@shared/modules/game-settings/game-settings.service", () => ({
|
||||
gameSettingsService: {
|
||||
getSettings: mockGetSettings,
|
||||
upsertSettings: mockUpsertSettings,
|
||||
getDefaults: mockGetDefaults,
|
||||
invalidateCache: jest.fn(),
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock DrizzleClient (dependency potentially imported transitively)
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: {}
|
||||
}));
|
||||
|
||||
// Mock @shared/lib/utils (deepMerge is used by settings API)
|
||||
mock.module("@shared/lib/utils", () => ({
|
||||
deepMerge: (target: any, source: any) => ({ ...target, ...source }),
|
||||
jsonReplacer: (key: string, value: any) =>
|
||||
typeof value === "bigint" ? value.toString() : value,
|
||||
}));
|
||||
|
||||
// Mock BotClient
|
||||
@@ -86,17 +110,27 @@ mock.module("bun", () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock auth (bypass authentication)
|
||||
mock.module("./routes/auth.routes", () => ({
|
||||
authRoutes: { name: "auth", handler: () => null },
|
||||
isAuthenticated: () => true,
|
||||
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
|
||||
}));
|
||||
|
||||
// Import createWebServer after mocks
|
||||
import { createWebServer } from "./server";
|
||||
|
||||
describe("Settings API", () => {
|
||||
let serverInstance: WebServerInstance;
|
||||
const PORT = 3009;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
const HOSTNAME = "127.0.0.1";
|
||||
const BASE_URL = `http://${HOSTNAME}:${PORT}`;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
serverInstance = await createWebServer({ port: PORT });
|
||||
mockGetSettings.mockImplementation(() => Promise.resolve(mockSettings));
|
||||
mockUpsertSettings.mockImplementation(() => Promise.resolve(mockSettings));
|
||||
serverInstance = await createWebServer({ port: PORT, hostname: HOSTNAME });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -109,18 +143,14 @@ describe("Settings API", () => {
|
||||
const res = await fetch(`${BASE_URL}/api/settings`);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
// Check if BigInts are converted to strings
|
||||
const data = await res.json() as any;
|
||||
// Check values come through correctly
|
||||
expect(data.economy.daily.amount).toBe("100");
|
||||
expect(data.leveling.base).toBe(100);
|
||||
});
|
||||
|
||||
it("POST /api/settings should save valid configuration via merge", async () => {
|
||||
// We only send a partial update, expecting the server to merge it
|
||||
// Note: For now the server implementation might still default to overwrite if we haven't updated it yet.
|
||||
// But the user requested "partial vs full" fix.
|
||||
// Let's assume we implement the merge logic.
|
||||
const partialConfig = { studentRole: "new-role-partial" };
|
||||
const partialConfig = { economy: { daily: { amount: "200" } } };
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/settings`, {
|
||||
method: "POST",
|
||||
@@ -129,26 +159,27 @@ describe("Settings API", () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Expect saveConfig to be called with the MERGED result
|
||||
expect(mockSaveConfig).toHaveBeenCalledWith(expect.objectContaining({
|
||||
studentRole: "new-role-partial",
|
||||
leveling: mockConfig.leveling // Should keep existing values
|
||||
}));
|
||||
// upsertSettings should be called with the partial config
|
||||
expect(mockUpsertSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
economy: { daily: { amount: "200" } }
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /api/settings should return 400 when save fails", async () => {
|
||||
mockSaveConfig.mockImplementationOnce(() => {
|
||||
mockUpsertSettings.mockImplementationOnce(() => {
|
||||
throw new Error("Validation failed");
|
||||
});
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/settings`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}) // Empty might be valid partial, but mocks throw
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
const data = await res.json() as any;
|
||||
expect(data.details).toBe("Validation failed");
|
||||
});
|
||||
|
||||
@@ -156,7 +187,7 @@ describe("Settings API", () => {
|
||||
const res = await fetch(`${BASE_URL}/api/settings/meta`);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
const data = await res.json() as any;
|
||||
expect(data.roles).toHaveLength(2);
|
||||
expect(data.roles[0]).toEqual({ id: "role1", name: "Admin", color: "#ffffff" });
|
||||
expect(data.channels[0]).toEqual({ id: "chan1", name: "general", type: 0 });
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, test, expect, afterAll, mock } from "bun:test";
|
||||
import type { WebServerInstance } from "./server";
|
||||
import { createWebServer } from "./server";
|
||||
|
||||
interface MockBotStats {
|
||||
bot: { name: string; avatarUrl: string | null };
|
||||
@@ -13,21 +12,21 @@ interface MockBotStats {
|
||||
}
|
||||
|
||||
// 1. Mock DrizzleClient (dependency of dashboardService)
|
||||
// Must provide full chainable builder for select().from().leftJoin().groupBy().orderBy().limit()
|
||||
mock.module("@shared/db/DrizzleClient", () => {
|
||||
const mockBuilder = {
|
||||
where: mock(() => Promise.resolve([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }])),
|
||||
then: (onfulfilled: any) => onfulfilled([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]),
|
||||
orderBy: mock(() => mockBuilder), // Chainable
|
||||
limit: mock(() => Promise.resolve([])), // Terminal
|
||||
};
|
||||
|
||||
const mockFrom = {
|
||||
from: mock(() => mockBuilder),
|
||||
};
|
||||
const mockBuilder: Record<string, any> = {};
|
||||
// Every chainable method returns mock builder; terminal calls return resolved promise
|
||||
mockBuilder.where = mock(() => Promise.resolve([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]));
|
||||
mockBuilder.then = (onfulfilled: any) => onfulfilled([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]);
|
||||
mockBuilder.orderBy = mock(() => mockBuilder);
|
||||
mockBuilder.limit = mock(() => Promise.resolve([]));
|
||||
mockBuilder.leftJoin = mock(() => mockBuilder);
|
||||
mockBuilder.groupBy = mock(() => mockBuilder);
|
||||
mockBuilder.from = mock(() => mockBuilder);
|
||||
|
||||
return {
|
||||
DrizzleClient: {
|
||||
select: mock(() => mockFrom),
|
||||
select: mock(() => mockBuilder),
|
||||
query: {
|
||||
transactions: { findMany: mock(() => Promise.resolve([])) },
|
||||
moderationCases: { findMany: mock(() => Promise.resolve([])) },
|
||||
@@ -54,10 +53,42 @@ mock.module("../../bot/lib/clientStats", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
// 3. System Events (No mock needed, use real events)
|
||||
// 3. Mock config (used by lootdrop.service.getLootdropState)
|
||||
mock.module("@shared/lib/config", () => ({
|
||||
config: {
|
||||
lootdrop: {
|
||||
activityWindowMs: 120000,
|
||||
minMessages: 1,
|
||||
spawnChance: 1,
|
||||
cooldownMs: 3000,
|
||||
reward: { min: 40, max: 150, currency: "Astral Units" }
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// 4. Mock auth (bypass authentication for testing)
|
||||
mock.module("./routes/auth.routes", () => ({
|
||||
authRoutes: { name: "auth", handler: () => null },
|
||||
isAuthenticated: () => true,
|
||||
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
|
||||
}));
|
||||
|
||||
// 5. Mock BotClient (used by stats helper for maintenanceMode)
|
||||
mock.module("../../bot/lib/BotClient", () => ({
|
||||
AuroraClient: {
|
||||
maintenanceMode: false,
|
||||
guilds: { cache: { get: () => null } },
|
||||
commands: [],
|
||||
knownCommands: new Map(),
|
||||
}
|
||||
}));
|
||||
|
||||
// Import after all mocks are set up
|
||||
import { createWebServer } from "./server";
|
||||
|
||||
describe("WebServer Security & Limits", () => {
|
||||
const port = 3001;
|
||||
const hostname = "127.0.0.1";
|
||||
let serverInstance: WebServerInstance | null = null;
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -67,8 +98,8 @@ describe("WebServer Security & Limits", () => {
|
||||
});
|
||||
|
||||
test("should reject more than 10 concurrent WebSocket connections", async () => {
|
||||
serverInstance = await createWebServer({ port, hostname: "localhost" });
|
||||
const wsUrl = `ws://localhost:${port}/ws`;
|
||||
serverInstance = await createWebServer({ port, hostname });
|
||||
const wsUrl = `ws://${hostname}:${port}/ws`;
|
||||
const sockets: WebSocket[] = [];
|
||||
|
||||
try {
|
||||
@@ -95,9 +126,9 @@ describe("WebServer Security & Limits", () => {
|
||||
|
||||
test("should return 200 for health check", async () => {
|
||||
if (!serverInstance) {
|
||||
serverInstance = await createWebServer({ port, hostname: "localhost" });
|
||||
serverInstance = await createWebServer({ port, hostname });
|
||||
}
|
||||
const response = await fetch(`http://localhost:${port}/api/health`);
|
||||
const response = await fetch(`http://${hostname}:${port}/api/health`);
|
||||
expect(response.status).toBe(200);
|
||||
const data = (await response.json()) as { status: string };
|
||||
expect(data.status).toBe("ok");
|
||||
@@ -105,7 +136,7 @@ describe("WebServer Security & Limits", () => {
|
||||
|
||||
describe("Administrative Actions", () => {
|
||||
test("should allow administrative actions without token", async () => {
|
||||
const response = await fetch(`http://localhost:${port}/api/actions/reload-commands`, {
|
||||
const response = await fetch(`http://${hostname}:${port}/api/actions/reload-commands`, {
|
||||
method: "POST"
|
||||
});
|
||||
// Should be 200 (OK) or 500 (if underlying service fails, but NOT 401)
|
||||
@@ -114,7 +145,7 @@ describe("WebServer Security & Limits", () => {
|
||||
});
|
||||
|
||||
test("should reject maintenance mode with invalid payload", async () => {
|
||||
const response = await fetch(`http://localhost:${port}/api/actions/maintenance-mode`, {
|
||||
const response = await fetch(`http://${hostname}:${port}/api/actions/maintenance-mode`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
243
api/src/server.ts
Normal file
243
api/src/server.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* @fileoverview API server factory module.
|
||||
* Exports a function to create and start the API server.
|
||||
* This allows the server to be started in-process from the main application.
|
||||
*
|
||||
* Routes are organized into modular files in the ./routes directory.
|
||||
* Each route module handles its own validation, business logic, and responses.
|
||||
*/
|
||||
|
||||
import { serve, file } from "bun";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
import { handleRequest } from "./routes";
|
||||
import { getFullDashboardStats } from "./routes/stats.helper";
|
||||
import { join } from "path";
|
||||
|
||||
export interface WebServerConfig {
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
export interface WebServerInstance {
|
||||
server: ReturnType<typeof serve>;
|
||||
stop: () => Promise<void>;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and starts the API server.
|
||||
*
|
||||
* @param config - Server configuration options
|
||||
* @param config.port - Port to listen on (default: 3000)
|
||||
* @param config.hostname - Hostname to bind to (default: "localhost")
|
||||
* @returns Promise resolving to server instance with stop() method
|
||||
*
|
||||
* @example
|
||||
* const server = await createWebServer({ port: 3000, hostname: "0.0.0.0" });
|
||||
* console.log(`Server running at ${server.url}`);
|
||||
*
|
||||
* // To stop the server:
|
||||
* await server.stop();
|
||||
*/
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
".html": "text/html",
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
};
|
||||
|
||||
/**
|
||||
* Serve static files from the panel dist directory.
|
||||
* Falls back to index.html for SPA routing.
|
||||
*/
|
||||
async function servePanelStatic(pathname: string, distDir: string): Promise<Response | null> {
|
||||
// Don't serve panel for API/auth/ws/assets routes
|
||||
if (pathname.startsWith("/api/") || pathname.startsWith("/auth/") || pathname === "/ws" || pathname.startsWith("/assets/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to serve the exact file
|
||||
const filePath = join(distDir, pathname);
|
||||
const bunFile = file(filePath);
|
||||
if (await bunFile.exists()) {
|
||||
const ext = pathname.substring(pathname.lastIndexOf("."));
|
||||
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
||||
return new Response(bunFile, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// SPA fallback: serve index.html for all non-file routes
|
||||
const indexFile = file(join(distDir, "index.html"));
|
||||
if (await indexFile.exists()) {
|
||||
return new Response(indexFile, {
|
||||
headers: { "Content-Type": "text/html", "Cache-Control": "no-cache" },
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function createWebServer(config: WebServerConfig = {}): Promise<WebServerInstance> {
|
||||
const { port = 3000, hostname = "localhost" } = config;
|
||||
|
||||
// Configuration constants
|
||||
const MAX_CONNECTIONS = 10;
|
||||
const MAX_PAYLOAD_BYTES = 16384; // 16KB
|
||||
const IDLE_TIMEOUT_SECONDS = 60;
|
||||
|
||||
// Interval for broadcasting stats to all connected WS clients
|
||||
let statsBroadcastInterval: Timer | undefined;
|
||||
|
||||
const server = serve({
|
||||
port,
|
||||
hostname,
|
||||
async fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
// WebSocket upgrade handling
|
||||
if (url.pathname === "/ws") {
|
||||
const currentConnections = server.pendingWebSockets;
|
||||
if (currentConnections >= MAX_CONNECTIONS) {
|
||||
logger.warn("web", `Connection rejected: limit reached (${currentConnections}/${MAX_CONNECTIONS})`);
|
||||
return new Response("Connection limit reached", { status: 429 });
|
||||
}
|
||||
|
||||
const success = server.upgrade(req);
|
||||
if (success) return undefined;
|
||||
return new Response("WebSocket upgrade failed", { status: 400 });
|
||||
}
|
||||
|
||||
// Delegate to modular route handlers
|
||||
const response = await handleRequest(req, url);
|
||||
if (response) return response;
|
||||
|
||||
// Serve panel static files (production)
|
||||
const panelDistDir = join(import.meta.dir, "../../panel/dist");
|
||||
const staticResponse = await servePanelStatic(url.pathname, panelDistDir);
|
||||
if (staticResponse) return staticResponse;
|
||||
|
||||
// No matching route found
|
||||
return new Response("Not Found", { status: 404 });
|
||||
},
|
||||
|
||||
websocket: {
|
||||
/**
|
||||
* Called when a WebSocket client connects.
|
||||
* Subscribes the client to the dashboard channel and sends initial stats.
|
||||
*/
|
||||
open(ws) {
|
||||
ws.subscribe("dashboard");
|
||||
logger.debug("web", `Client connected. Total: ${server.pendingWebSockets}`);
|
||||
|
||||
// Send initial stats
|
||||
getFullDashboardStats().then(stats => {
|
||||
ws.send(JSON.stringify({ type: "STATS_UPDATE", data: stats }));
|
||||
});
|
||||
|
||||
// Start broadcast interval if this is the first client
|
||||
if (!statsBroadcastInterval) {
|
||||
statsBroadcastInterval = setInterval(async () => {
|
||||
try {
|
||||
const stats = await getFullDashboardStats();
|
||||
server.publish("dashboard", JSON.stringify({ type: "STATS_UPDATE", data: stats }));
|
||||
} catch (error) {
|
||||
logger.error("web", "Error in stats broadcast", error);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Called when a WebSocket message is received.
|
||||
* Handles PING/PONG heartbeat messages.
|
||||
*/
|
||||
async message(ws, message) {
|
||||
try {
|
||||
const messageStr = message.toString();
|
||||
|
||||
// Defense-in-depth: redundant length check before parsing
|
||||
if (messageStr.length > MAX_PAYLOAD_BYTES) {
|
||||
logger.error("web", "Payload exceeded maximum limit");
|
||||
return;
|
||||
}
|
||||
|
||||
const rawData = JSON.parse(messageStr);
|
||||
const { WsMessageSchema } = await import("@shared/modules/dashboard/dashboard.types");
|
||||
const parsed = WsMessageSchema.safeParse(rawData);
|
||||
|
||||
if (!parsed.success) {
|
||||
logger.error("web", "Invalid message format", parsed.error.issues);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.data.type === "PING") {
|
||||
ws.send(JSON.stringify({ type: "PONG" }));
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("web", "Failed to handle message", e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Called when a WebSocket client disconnects.
|
||||
* Stops the broadcast interval if no clients remain.
|
||||
*/
|
||||
close(ws) {
|
||||
ws.unsubscribe("dashboard");
|
||||
logger.debug("web", `Client disconnected. Total remaining: ${server.pendingWebSockets}`);
|
||||
|
||||
// Stop broadcast interval if no clients left
|
||||
if (server.pendingWebSockets === 0 && statsBroadcastInterval) {
|
||||
clearInterval(statsBroadcastInterval);
|
||||
statsBroadcastInterval = undefined;
|
||||
}
|
||||
},
|
||||
maxPayloadLength: MAX_PAYLOAD_BYTES,
|
||||
idleTimeout: IDLE_TIMEOUT_SECONDS,
|
||||
},
|
||||
});
|
||||
|
||||
// Listen for real-time events from the system bus
|
||||
const { systemEvents, EVENTS } = await import("@shared/lib/events");
|
||||
systemEvents.on(EVENTS.DASHBOARD.NEW_EVENT, (event) => {
|
||||
server.publish("dashboard", JSON.stringify({ type: "NEW_EVENT", data: event }));
|
||||
});
|
||||
|
||||
const url = `http://${hostname}:${port}`;
|
||||
|
||||
return {
|
||||
server,
|
||||
url,
|
||||
stop: async () => {
|
||||
if (statsBroadcastInterval) {
|
||||
clearInterval(statsBroadcastInterval);
|
||||
}
|
||||
server.stop(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the web server from the main application root.
|
||||
* Kept for backward compatibility.
|
||||
*
|
||||
* @param webProjectPath - Deprecated, no longer used
|
||||
* @param config - Server configuration options
|
||||
* @returns Promise resolving to server instance
|
||||
*/
|
||||
export async function startWebServerFromRoot(
|
||||
webProjectPath: string,
|
||||
config: WebServerConfig = {}
|
||||
): Promise<WebServerInstance> {
|
||||
return createWebServer(config);
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"DOM"
|
||||
],
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
@@ -38,8 +34,5 @@
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
},
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
0
bot/assets/graphics/items/.gitkeep
Normal file
0
bot/assets/graphics/items/.gitkeep
Normal file
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const moderationCase = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -16,39 +17,35 @@ export const moderationCase = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||
|
||||
try {
|
||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||
// Validate case ID format
|
||||
if (!caseId.match(/^CASE-\d+$/)) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate case ID format
|
||||
if (!caseId.match(/^CASE-\d+$/)) {
|
||||
// Get the case
|
||||
const moderationCase = await moderationService.getCaseById(caseId);
|
||||
|
||||
if (!moderationCase) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Display the case
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
||||
embeds: [getCaseEmbed(moderationCase)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the case
|
||||
const moderationCase = await ModerationService.getCaseById(caseId);
|
||||
|
||||
if (!moderationCase) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Display the case
|
||||
await interaction.editReply({
|
||||
embeds: [getCaseEmbed(moderationCase)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Case command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching the case.")]
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCasesListEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const cases = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -22,33 +23,29 @@ export const cases = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
const activeOnly = interaction.options.getBoolean("active_only") || false;
|
||||
|
||||
try {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
const activeOnly = interaction.options.getBoolean("active_only") || false;
|
||||
// Get cases for the user
|
||||
const userCases = await moderationService.getUserCases(targetUser.id, activeOnly);
|
||||
|
||||
// Get cases for the user
|
||||
const userCases = await ModerationService.getUserCases(targetUser.id, activeOnly);
|
||||
const title = activeOnly
|
||||
? `⚠️ Active Cases for ${targetUser.username}`
|
||||
: `📋 All Cases for ${targetUser.username}`;
|
||||
|
||||
const title = activeOnly
|
||||
? `⚠️ Active Cases for ${targetUser.username}`
|
||||
: `📋 All Cases for ${targetUser.username}`;
|
||||
const description = userCases.length === 0
|
||||
? undefined
|
||||
: `Total cases: **${userCases.length}**`;
|
||||
|
||||
const description = userCases.length === 0
|
||||
? undefined
|
||||
: `Total cases: **${userCases.length}**`;
|
||||
|
||||
// Display the cases
|
||||
await interaction.editReply({
|
||||
embeds: [getCasesListEmbed(userCases, title, description)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Cases command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching cases.")]
|
||||
});
|
||||
}
|
||||
// Display the cases
|
||||
await interaction.editReply({
|
||||
embeds: [getCasesListEmbed(userCases, title, description)]
|
||||
});
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const clearwarning = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -23,62 +24,58 @@ export const clearwarning = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||
const reason = interaction.options.getString("reason") || "Cleared by moderator";
|
||||
|
||||
try {
|
||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||
const reason = interaction.options.getString("reason") || "Cleared by moderator";
|
||||
// Validate case ID format
|
||||
if (!caseId.match(/^CASE-\d+$/)) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate case ID format
|
||||
if (!caseId.match(/^CASE-\d+$/)) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
||||
// Check if case exists and is active
|
||||
const existingCase = await moderationService.getCaseById(caseId);
|
||||
|
||||
if (!existingCase) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existingCase.active) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** is already resolved.`)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingCase.type !== 'warn') {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** is not a warning. Only warnings can be cleared.`)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the warning
|
||||
await moderationService.clearCase({
|
||||
caseId,
|
||||
clearedBy: interaction.user.id,
|
||||
clearedByName: interaction.user.username,
|
||||
reason
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if case exists and is active
|
||||
const existingCase = await ModerationService.getCaseById(caseId);
|
||||
|
||||
if (!existingCase) {
|
||||
// Send success message
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
||||
embeds: [getClearSuccessEmbed(caseId)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existingCase.active) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** is already resolved.`)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingCase.type !== 'warn') {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** is not a warning. Only warnings can be cleared.`)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the warning
|
||||
await ModerationService.clearCase({
|
||||
caseId,
|
||||
clearedBy: interaction.user.id,
|
||||
clearedByName: interaction.user.username,
|
||||
reason
|
||||
});
|
||||
|
||||
// Send success message
|
||||
await interaction.editReply({
|
||||
embeds: [getClearSuccessEmbed(caseId)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Clear warning command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while clearing the warning.")]
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ModalSubmitInteraction } from "discord.js";
|
||||
import { config, saveConfig } from "@shared/lib/config";
|
||||
import type { GameConfigType } from "@shared/lib/config";
|
||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
||||
|
||||
export const configCommand = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("config")
|
||||
.setDescription("Edit the bot configuration")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
console.log(`Config command executed by ${interaction.user.tag}`);
|
||||
const replacer = (key: string, value: any) => {
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const currentConfigJson = JSON.stringify(config, replacer, 4);
|
||||
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId("config-modal")
|
||||
.setTitle("Edit Configuration");
|
||||
|
||||
const jsonInput = new TextInputBuilder()
|
||||
.setCustomId("json-input")
|
||||
.setLabel("Configuration JSON")
|
||||
.setStyle(TextInputStyle.Paragraph)
|
||||
.setValue(currentConfigJson)
|
||||
.setRequired(true);
|
||||
|
||||
const actionRow = new ActionRowBuilder<TextInputBuilder>().addComponents(jsonInput);
|
||||
modal.addComponents(actionRow);
|
||||
|
||||
await interaction.showModal(modal);
|
||||
|
||||
try {
|
||||
const submitted = await interaction.awaitModalSubmit({
|
||||
time: 300000, // 5 minutes
|
||||
filter: (i) => i.customId === "config-modal" && i.user.id === interaction.user.id
|
||||
});
|
||||
|
||||
const jsonString = submitted.fields.getTextInputValue("json-input");
|
||||
|
||||
try {
|
||||
const newConfig = JSON.parse(jsonString);
|
||||
saveConfig(newConfig as GameConfigType);
|
||||
|
||||
await submitted.reply({
|
||||
embeds: [createSuccessEmbed("Configuration updated successfully.", "Config Saved")]
|
||||
});
|
||||
} catch (parseError) {
|
||||
await submitted.reply({
|
||||
embeds: [createErrorEmbed(`Invalid JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`, "Config Update Failed")],
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Timeout or other error handling if needed, usually just ignore timeouts for modals
|
||||
if (error instanceof Error && error.message.includes('time')) {
|
||||
// specific timeout handling if desired
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js";
|
||||
import { config, saveConfig } from "@shared/lib/config";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
|
||||
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { items } from "@db/schema";
|
||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const createColor = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -31,64 +33,60 @@ export const createColor = createCommand({
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const name = interaction.options.getString("name", true);
|
||||
const colorInput = interaction.options.getString("color", true);
|
||||
const price = interaction.options.getNumber("price") || 500;
|
||||
const imageUrl = interaction.options.getString("image") || "https://cdn.discordapp.com/attachments/1450061247365124199/1453122950822760559/Main_Chip_1.png";
|
||||
|
||||
const name = interaction.options.getString("name", true);
|
||||
const colorInput = interaction.options.getString("color", true);
|
||||
const price = interaction.options.getNumber("price") || 500;
|
||||
const imageUrl = interaction.options.getString("image") || "https://cdn.discordapp.com/attachments/1450061247365124199/1453122950822760559/Main_Chip_1.png";
|
||||
// 1. Validate Color
|
||||
const colorRegex = /^#([0-9A-F]{3}){1,2}$/i;
|
||||
if (!colorRegex.test(colorInput)) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Hex Color code. Format: #RRGGBB")] });
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Validate Color
|
||||
const colorRegex = /^#([0-9A-F]{3}){1,2}$/i;
|
||||
if (!colorRegex.test(colorInput)) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Hex Color code. Format: #RRGGBB")] });
|
||||
return;
|
||||
}
|
||||
// 2. Create Role
|
||||
const role = await interaction.guild?.roles.create({
|
||||
name: name,
|
||||
color: colorInput as any,
|
||||
reason: `Created via /createcolor by ${interaction.user.tag}`
|
||||
});
|
||||
|
||||
try {
|
||||
// 2. Create Role
|
||||
const role = await interaction.guild?.roles.create({
|
||||
name: name,
|
||||
color: colorInput as any, // Discord.js types are a bit strict on ColorResolvable, but string generally works or needs parsing
|
||||
reason: `Created via /createcolor by ${interaction.user.tag}`
|
||||
});
|
||||
if (!role) {
|
||||
throw new Error("Failed to create role.");
|
||||
}
|
||||
|
||||
if (!role) {
|
||||
throw new Error("Failed to create role.");
|
||||
}
|
||||
// 3. Add to guild settings
|
||||
await guildSettingsService.addColorRole(interaction.guildId!, role.id);
|
||||
invalidateGuildConfigCache(interaction.guildId!);
|
||||
|
||||
// 3. Update Config
|
||||
if (!config.colorRoles.includes(role.id)) {
|
||||
config.colorRoles.push(role.id);
|
||||
saveConfig(config);
|
||||
}
|
||||
// 4. Create Item
|
||||
await DrizzleClient.insert(items).values({
|
||||
name: `Color Role - ${name}`,
|
||||
description: `Use this item to apply the ${name} color to your name.`,
|
||||
type: "CONSUMABLE",
|
||||
rarity: "Common",
|
||||
price: BigInt(price),
|
||||
iconUrl: "",
|
||||
imageUrl: imageUrl,
|
||||
usageData: {
|
||||
consume: false,
|
||||
effects: [{ type: "COLOR_ROLE", roleId: role.id }]
|
||||
} as any
|
||||
});
|
||||
|
||||
// 4. Create Item
|
||||
await DrizzleClient.insert(items).values({
|
||||
name: `Color Role - ${name}`,
|
||||
description: `Use this item to apply the ${name} color to your name.`,
|
||||
type: "CONSUMABLE",
|
||||
rarity: "Common",
|
||||
price: BigInt(price),
|
||||
iconUrl: "",
|
||||
imageUrl: imageUrl,
|
||||
usageData: {
|
||||
consume: false,
|
||||
effects: [{ type: "COLOR_ROLE", roleId: role.id }]
|
||||
} as any
|
||||
});
|
||||
|
||||
// 5. Success
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`**Role:** <@&${role.id}> (${colorInput})\n**Item:** Color Role - ${name}\n**Price:** ${price} 🪙`,
|
||||
"✅ Color Role & Item Created"
|
||||
)]
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error in createcolor:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(`Failed to create color role: ${error.message}`)] });
|
||||
}
|
||||
// 5. Success
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`**Role:** <@&${role.id}> (${colorInput})\n**Item:** Color Role - ${name}\n**Price:** ${price} 🪙`,
|
||||
"✅ Color Role & Item Created"
|
||||
)]
|
||||
});
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
293
bot/commands/admin/featureflags.ts
Normal file
293
bot/commands/admin/featureflags.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, Colors, userMention, roleMention, ChatInputCommandInteraction } from "discord.js";
|
||||
import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service";
|
||||
import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const featureflags = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("featureflags")
|
||||
.setDescription("Manage feature flags for beta testing")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("list")
|
||||
.setDescription("List all feature flags")
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("create")
|
||||
.setDescription("Create a new feature flag")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("name")
|
||||
.setDescription("Name of the feature flag")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addStringOption(opt =>
|
||||
opt.setName("description")
|
||||
.setDescription("Description of the feature flag")
|
||||
.setRequired(false)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("delete")
|
||||
.setDescription("Delete a feature flag")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("name")
|
||||
.setDescription("Name of the feature flag")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("enable")
|
||||
.setDescription("Enable a feature flag")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("name")
|
||||
.setDescription("Name of the feature flag")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("disable")
|
||||
.setDescription("Disable a feature flag")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("name")
|
||||
.setDescription("Name of the feature flag")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("grant")
|
||||
.setDescription("Grant access to a feature flag")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("name")
|
||||
.setDescription("Name of the feature flag")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
)
|
||||
.addUserOption(opt =>
|
||||
opt.setName("user")
|
||||
.setDescription("User to grant access to")
|
||||
.setRequired(false)
|
||||
)
|
||||
.addRoleOption(opt =>
|
||||
opt.setName("role")
|
||||
.setDescription("Role to grant access to")
|
||||
.setRequired(false)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("revoke")
|
||||
.setDescription("Revoke access from a feature flag")
|
||||
.addIntegerOption(opt =>
|
||||
opt.setName("id")
|
||||
.setDescription("Access record ID to revoke")
|
||||
.setRequired(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("access")
|
||||
.setDescription("List access records for a feature flag")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("name")
|
||||
.setDescription("Name of the feature flag")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
)
|
||||
),
|
||||
autocomplete: async (interaction) => {
|
||||
const focused = interaction.options.getFocused(true);
|
||||
|
||||
if (focused.name === "name") {
|
||||
const flags = await featureFlagsService.listFlags();
|
||||
const filtered = flags
|
||||
.filter(f => f.name.toLowerCase().includes(focused.value.toLowerCase()))
|
||||
.slice(0, 25);
|
||||
|
||||
await interaction.respond(
|
||||
filtered.map(f => ({ name: `${f.name} (${f.enabled ? "enabled" : "disabled"})`, value: f.name }))
|
||||
);
|
||||
}
|
||||
},
|
||||
execute: async (interaction) => {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
switch (subcommand) {
|
||||
case "list":
|
||||
await handleList(interaction);
|
||||
break;
|
||||
case "create":
|
||||
await handleCreate(interaction);
|
||||
break;
|
||||
case "delete":
|
||||
await handleDelete(interaction);
|
||||
break;
|
||||
case "enable":
|
||||
await handleEnable(interaction);
|
||||
break;
|
||||
case "disable":
|
||||
await handleDisable(interaction);
|
||||
break;
|
||||
case "grant":
|
||||
await handleGrant(interaction);
|
||||
break;
|
||||
case "revoke":
|
||||
await handleRevoke(interaction);
|
||||
break;
|
||||
case "access":
|
||||
await handleAccess(interaction);
|
||||
break;
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
async function handleList(interaction: ChatInputCommandInteraction) {
|
||||
const flags = await featureFlagsService.listFlags();
|
||||
|
||||
if (flags.length === 0) {
|
||||
await interaction.editReply({ embeds: [createBaseEmbed("Feature Flags", "No feature flags have been created yet.", Colors.Blue)] });
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = createBaseEmbed("Feature Flags", undefined, Colors.Blue)
|
||||
.addFields(
|
||||
flags.map(f => ({
|
||||
name: f.name,
|
||||
value: `${f.enabled ? "✅ Enabled" : "❌ Disabled"}\n${f.description || "*No description*"}`,
|
||||
inline: false,
|
||||
}))
|
||||
);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
|
||||
async function handleCreate(interaction: ChatInputCommandInteraction) {
|
||||
const name = interaction.options.getString("name", true);
|
||||
const description = interaction.options.getString("description");
|
||||
|
||||
const flag = await featureFlagsService.createFlag(name, description ?? undefined);
|
||||
|
||||
if (!flag) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to create feature flag.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" created successfully. Use \`/featureflags enable\` to enable it.`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDelete(interaction: ChatInputCommandInteraction) {
|
||||
const name = interaction.options.getString("name", true);
|
||||
|
||||
const flag = await featureFlagsService.deleteFlag(name);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been deleted.`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleEnable(interaction: ChatInputCommandInteraction) {
|
||||
const name = interaction.options.getString("name", true);
|
||||
|
||||
const flag = await featureFlagsService.setFlagEnabled(name, true);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been enabled.`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDisable(interaction: ChatInputCommandInteraction) {
|
||||
const name = interaction.options.getString("name", true);
|
||||
|
||||
const flag = await featureFlagsService.setFlagEnabled(name, false);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been disabled.`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleGrant(interaction: ChatInputCommandInteraction) {
|
||||
const name = interaction.options.getString("name", true);
|
||||
const user = interaction.options.getUser("user");
|
||||
const role = interaction.options.getRole("role");
|
||||
|
||||
if (!user && !role) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("You must specify either a user or a role to grant access to.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const access = await featureFlagsService.grantAccess(name, {
|
||||
userId: user?.id,
|
||||
roleId: role?.id,
|
||||
guildId: interaction.guildId!,
|
||||
});
|
||||
|
||||
if (!access) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to grant access.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
let target: string;
|
||||
if (user) {
|
||||
target = userMention(user.id);
|
||||
} else if (role) {
|
||||
target = roleMention(role.id);
|
||||
} else {
|
||||
target = "Unknown";
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Access to "**${name}**" granted to ${target} (ID: ${access.id})`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRevoke(interaction: ChatInputCommandInteraction) {
|
||||
const id = interaction.options.getInteger("id", true);
|
||||
|
||||
const access = await featureFlagsService.revokeAccess(id);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Access record #${access.id} has been revoked.`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAccess(interaction: ChatInputCommandInteraction) {
|
||||
const name = interaction.options.getString("name", true);
|
||||
|
||||
const accessRecords = await featureFlagsService.listAccess(name);
|
||||
|
||||
if (accessRecords.length === 0) {
|
||||
await interaction.editReply({
|
||||
embeds: [createBaseEmbed("Feature Flag Access", `No access records for "**${name}**".`, Colors.Blue)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = accessRecords.map(a => {
|
||||
let target = "Unknown";
|
||||
if (a.userId) target = `User: ${userMention(a.userId.toString())}`;
|
||||
else if (a.roleId) target = `Role: ${roleMention(a.roleId.toString())}`;
|
||||
else if (a.guildId) target = `Guild: ${a.guildId.toString()}`;
|
||||
|
||||
return {
|
||||
name: `ID: ${a.id}`,
|
||||
value: target,
|
||||
inline: true,
|
||||
};
|
||||
});
|
||||
|
||||
const embed = createBaseEmbed(`Feature Flag Access: ${name}`, undefined, Colors.Blue)
|
||||
.addFields(fields);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createBaseEmbed } from "@lib/embeds";
|
||||
import { config, reloadConfig, toggleCommand } from "@shared/lib/config";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
|
||||
export const features = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("features")
|
||||
.setDescription("Manage bot features and commands")
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("list")
|
||||
.setDescription("List all commands and their status")
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("toggle")
|
||||
.setDescription("Enable or disable a command")
|
||||
.addStringOption(option =>
|
||||
option.setName("command")
|
||||
.setDescription("The name of the command")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addBooleanOption(option =>
|
||||
option.setName("enabled")
|
||||
.setDescription("Whether the command should be enabled")
|
||||
.setRequired(true)
|
||||
)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
if (subcommand === "list") {
|
||||
const activeCommands = AuroraClient.commands;
|
||||
const categories = new Map<string, string[]>();
|
||||
|
||||
// Group active commands
|
||||
activeCommands.forEach(cmd => {
|
||||
const cat = cmd.category || 'Uncategorized';
|
||||
if (!categories.has(cat)) categories.set(cat, []);
|
||||
categories.get(cat)!.push(cmd.data.name);
|
||||
});
|
||||
|
||||
// Config overrides
|
||||
const overrides = Object.entries(config.commands)
|
||||
.map(([name, enabled]) => `• **${name}**: ${enabled ? "✅ Enabled (Override)" : "❌ Disabled"}`);
|
||||
|
||||
const embed = createBaseEmbed("Command Features", undefined, "Blue");
|
||||
|
||||
// Add fields for each category
|
||||
const sortedCategories = [...categories.keys()].sort();
|
||||
for (const cat of sortedCategories) {
|
||||
const cmds = categories.get(cat)!.sort();
|
||||
const cmdList = cmds.map(name => {
|
||||
const isOverride = config.commands[name] !== undefined;
|
||||
return isOverride ? `**${name}** (See Overrides)` : `**${name}**`;
|
||||
}).join(", ");
|
||||
|
||||
embed.addFields({ name: `📂 ${cat.toUpperCase()}`, value: cmdList || "None" });
|
||||
}
|
||||
|
||||
if (overrides.length > 0) {
|
||||
embed.addFields({ name: "⚙️ Configuration Overrides", value: overrides.join("\n") });
|
||||
} else {
|
||||
embed.addFields({ name: "⚙️ Configuration Overrides", value: "No overrides set." });
|
||||
}
|
||||
|
||||
// Check permissions manually as a fallback (though defaultMemberPermissions handles it at the API level)
|
||||
if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) {
|
||||
await interaction.reply({ content: "❌ You need Administrator permissions to use this command.", flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
|
||||
} else if (subcommand === "toggle") {
|
||||
const commandName = interaction.options.getString("command", true);
|
||||
const enabled = interaction.options.getBoolean("enabled", true);
|
||||
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
toggleCommand(commandName, enabled);
|
||||
|
||||
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Reloading configuration...` });
|
||||
|
||||
// Reload config from disk (which was updated by toggleCommand)
|
||||
reloadConfig();
|
||||
|
||||
await AuroraClient.loadCommands(true);
|
||||
await AuroraClient.deployCommands();
|
||||
|
||||
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Commands reloaded!` });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,20 +1,18 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import {
|
||||
SlashCommandBuilder,
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
type BaseGuildTextChannel,
|
||||
PermissionFlagsBits,
|
||||
MessageFlags
|
||||
} from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { items } from "@db/schema";
|
||||
import { ilike, isNotNull, and } from "drizzle-orm";
|
||||
import { ilike, isNotNull, and, inArray } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { getShopListingMessage } from "@/modules/economy/shop.view";
|
||||
import { EffectType, LootType } from "@shared/lib/constants";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const listing = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -33,44 +31,67 @@ export const listing = createCommand({
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const itemId = interaction.options.getNumber("item", true);
|
||||
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
|
||||
|
||||
const itemId = interaction.options.getNumber("item", true);
|
||||
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
|
||||
if (!targetChannel || !targetChannel.isSendable()) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetChannel || !targetChannel.isSendable()) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] });
|
||||
return;
|
||||
}
|
||||
const item = await inventoryService.getItem(itemId);
|
||||
if (!item) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] });
|
||||
return;
|
||||
}
|
||||
|
||||
const item = await inventoryService.getItem(itemId);
|
||||
if (!item) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] });
|
||||
return;
|
||||
}
|
||||
if (!item.price) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item "${item.name}" is not for sale (no price set).`)] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!item.price) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item "${item.name}" is not for sale (no price set).`)] });
|
||||
return;
|
||||
}
|
||||
// Prepare context for lootboxes
|
||||
const context: { referencedItems: Map<number, { name: string; rarity: string }> } = { referencedItems: new Map() };
|
||||
|
||||
const listingMessage = getShopListingMessage({
|
||||
...item,
|
||||
formattedPrice: `${item.price} 🪙`,
|
||||
price: item.price
|
||||
});
|
||||
const usageData = item.usageData as any;
|
||||
const lootboxEffect = usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX);
|
||||
|
||||
try {
|
||||
await targetChannel.send(listingMessage);
|
||||
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
||||
} else {
|
||||
console.error("Error creating listing:", error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
||||
}
|
||||
}
|
||||
if (lootboxEffect && lootboxEffect.pool) {
|
||||
const itemIds = lootboxEffect.pool
|
||||
.filter((drop: any) => drop.type === LootType.ITEM && drop.itemId)
|
||||
.map((drop: any) => drop.itemId);
|
||||
|
||||
if (itemIds.length > 0) {
|
||||
// Remove duplicates
|
||||
const uniqueIds = [...new Set(itemIds)] as number[];
|
||||
|
||||
const referencedItems = await DrizzleClient.select({
|
||||
id: items.id,
|
||||
name: items.name,
|
||||
rarity: items.rarity
|
||||
}).from(items).where(inArray(items.id, uniqueIds));
|
||||
|
||||
for (const ref of referencedItems) {
|
||||
context.referencedItems.set(ref.id, { name: ref.name, rarity: ref.rarity || 'C' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listingMessage = getShopListingMessage({
|
||||
...item,
|
||||
rarity: item.rarity || undefined,
|
||||
formattedPrice: `${item.price} 🪙`,
|
||||
price: item.price
|
||||
}, context);
|
||||
|
||||
await targetChannel.send(listingMessage as any);
|
||||
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
},
|
||||
autocomplete: async (interaction) => {
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { CaseType } from "@shared/lib/constants";
|
||||
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const note = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -24,39 +25,35 @@ export const note = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
const noteText = interaction.options.getString("note", true);
|
||||
|
||||
try {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
const noteText = interaction.options.getString("note", true);
|
||||
|
||||
// Create the note case
|
||||
const moderationCase = await ModerationService.createCase({
|
||||
type: CaseType.NOTE,
|
||||
userId: targetUser.id,
|
||||
username: targetUser.username,
|
||||
moderatorId: interaction.user.id,
|
||||
moderatorName: interaction.user.username,
|
||||
reason: noteText,
|
||||
});
|
||||
|
||||
if (!moderationCase) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("Failed to create note.")]
|
||||
// Create the note case
|
||||
const moderationCase = await moderationService.createCase({
|
||||
type: CaseType.NOTE,
|
||||
userId: targetUser.id,
|
||||
username: targetUser.username,
|
||||
moderatorId: interaction.user.id,
|
||||
moderatorName: interaction.user.username,
|
||||
reason: noteText,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Send success message
|
||||
await interaction.editReply({
|
||||
embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)]
|
||||
});
|
||||
if (!moderationCase) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("Failed to create note.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Note command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while adding the note.")]
|
||||
});
|
||||
}
|
||||
// Send success message
|
||||
await interaction.editReply({
|
||||
embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)]
|
||||
});
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCasesListEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const notes = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -16,28 +17,24 @@ export const notes = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
|
||||
try {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
// Get all notes for the user
|
||||
const userNotes = await moderationService.getUserNotes(targetUser.id);
|
||||
|
||||
// Get all notes for the user
|
||||
const userNotes = await ModerationService.getUserNotes(targetUser.id);
|
||||
|
||||
// Display the notes
|
||||
await interaction.editReply({
|
||||
embeds: [getCasesListEmbed(
|
||||
userNotes,
|
||||
`📝 Staff Notes for ${targetUser.username}`,
|
||||
userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**`
|
||||
)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Notes command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching notes.")]
|
||||
});
|
||||
}
|
||||
// Display the notes
|
||||
await interaction.editReply({
|
||||
embeds: [getCasesListEmbed(
|
||||
userNotes,
|
||||
`📝 Staff Notes for ${targetUser.username}`,
|
||||
userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**`
|
||||
)]
|
||||
});
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { PruneService } from "@shared/modules/moderation/prune.service";
|
||||
import { pruneService } from "@shared/modules/moderation/prune.service";
|
||||
import {
|
||||
getConfirmationMessage,
|
||||
getProgressEmbed,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getPruneWarningEmbed,
|
||||
getCancelledEmbed
|
||||
} from "@/modules/moderation/prune.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const prune = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -38,142 +39,126 @@ export const prune = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const amount = interaction.options.getInteger("amount");
|
||||
const user = interaction.options.getUser("user");
|
||||
const all = interaction.options.getBoolean("all") || false;
|
||||
|
||||
try {
|
||||
const amount = interaction.options.getInteger("amount");
|
||||
const user = interaction.options.getUser("user");
|
||||
const all = interaction.options.getBoolean("all") || false;
|
||||
|
||||
// Validate inputs
|
||||
if (!amount && !all) {
|
||||
// Default to 10 messages
|
||||
} else if (amount && all) {
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneErrorEmbed("Cannot specify both `amount` and `all`. Please use one or the other.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const finalAmount = all ? 'all' : (amount || 10);
|
||||
const confirmThreshold = config.moderation.prune.confirmThreshold;
|
||||
|
||||
// Check if confirmation is needed
|
||||
const needsConfirmation = all || (typeof finalAmount === 'number' && finalAmount > confirmThreshold);
|
||||
|
||||
if (needsConfirmation) {
|
||||
// Estimate message count for confirmation
|
||||
let estimatedCount: number | undefined;
|
||||
if (all) {
|
||||
try {
|
||||
estimatedCount = await PruneService.estimateMessageCount(interaction.channel!);
|
||||
} catch {
|
||||
estimatedCount = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const { embeds, components } = getConfirmationMessage(finalAmount, estimatedCount);
|
||||
const response = await interaction.editReply({ embeds, components });
|
||||
|
||||
try {
|
||||
const confirmation = await response.awaitMessageComponent({
|
||||
filter: (i) => i.user.id === interaction.user.id,
|
||||
componentType: ComponentType.Button,
|
||||
time: 30000
|
||||
});
|
||||
|
||||
if (confirmation.customId === "cancel_prune") {
|
||||
await confirmation.update({
|
||||
embeds: [getCancelledEmbed()],
|
||||
components: []
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// User confirmed, proceed with deletion
|
||||
await confirmation.update({
|
||||
embeds: [getProgressEmbed({ current: 0, total: estimatedCount || finalAmount as number })],
|
||||
components: []
|
||||
});
|
||||
|
||||
// Execute deletion with progress callback for 'all' mode
|
||||
const result = await PruneService.deleteMessages(
|
||||
interaction.channel!,
|
||||
{
|
||||
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
|
||||
userId: user?.id,
|
||||
all
|
||||
},
|
||||
all ? async (progress) => {
|
||||
await interaction.editReply({
|
||||
embeds: [getProgressEmbed(progress)]
|
||||
});
|
||||
} : undefined
|
||||
);
|
||||
|
||||
// Show success
|
||||
// Validate inputs
|
||||
if (!amount && !all) {
|
||||
// Default to 10 messages
|
||||
} else if (amount && all) {
|
||||
await interaction.editReply({
|
||||
embeds: [getSuccessEmbed(result)],
|
||||
components: []
|
||||
embeds: [getPruneErrorEmbed("Cannot specify both `amount` and `all`. Please use one or the other.")]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("time")) {
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneWarningEmbed("Confirmation timed out. Please try again.")],
|
||||
components: []
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No confirmation needed, proceed directly
|
||||
const result = await PruneService.deleteMessages(
|
||||
interaction.channel!,
|
||||
{
|
||||
amount: finalAmount as number,
|
||||
userId: user?.id,
|
||||
all: false
|
||||
}
|
||||
);
|
||||
|
||||
// Check if no messages were found
|
||||
if (result.deletedCount === 0) {
|
||||
if (user) {
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneWarningEmbed(`No messages found from **${user.username}** in the last ${finalAmount} messages.`)]
|
||||
});
|
||||
} else {
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneWarningEmbed("No messages found to delete.")]
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [getSuccessEmbed(result)]
|
||||
});
|
||||
}
|
||||
const finalAmount = all ? 'all' : (amount || 10);
|
||||
const confirmThreshold = config.moderation.prune.confirmThreshold;
|
||||
|
||||
} catch (error) {
|
||||
console.error("Prune command error:", error);
|
||||
// Check if confirmation is needed
|
||||
const needsConfirmation = all || (typeof finalAmount === 'number' && finalAmount > confirmThreshold);
|
||||
|
||||
let errorMessage = "An unexpected error occurred while trying to delete messages.";
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("permission")) {
|
||||
errorMessage = "I don't have permission to delete messages in this channel.";
|
||||
} else if (error.message.includes("channel type")) {
|
||||
errorMessage = "This command cannot be used in this type of channel.";
|
||||
if (needsConfirmation) {
|
||||
// Estimate message count for confirmation
|
||||
let estimatedCount: number | undefined;
|
||||
if (all) {
|
||||
try {
|
||||
estimatedCount = await pruneService.estimateMessageCount(interaction.channel!);
|
||||
} catch {
|
||||
estimatedCount = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const { embeds, components } = getConfirmationMessage(finalAmount, estimatedCount);
|
||||
const response = await interaction.editReply({ embeds, components });
|
||||
|
||||
try {
|
||||
const confirmation = await response.awaitMessageComponent({
|
||||
filter: (i) => i.user.id === interaction.user.id,
|
||||
componentType: ComponentType.Button,
|
||||
time: 30000
|
||||
});
|
||||
|
||||
if (confirmation.customId === "cancel_prune") {
|
||||
await confirmation.update({
|
||||
embeds: [getCancelledEmbed()],
|
||||
components: []
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// User confirmed, proceed with deletion
|
||||
await confirmation.update({
|
||||
embeds: [getProgressEmbed({ current: 0, total: estimatedCount || finalAmount as number })],
|
||||
components: []
|
||||
});
|
||||
|
||||
// Execute deletion with progress callback for 'all' mode
|
||||
const result = await pruneService.deleteMessages(
|
||||
interaction.channel!,
|
||||
{
|
||||
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
|
||||
userId: user?.id,
|
||||
all
|
||||
},
|
||||
all ? async (progress) => {
|
||||
await interaction.editReply({
|
||||
embeds: [getProgressEmbed(progress)]
|
||||
});
|
||||
} : undefined
|
||||
);
|
||||
|
||||
// Show success
|
||||
await interaction.editReply({
|
||||
embeds: [getSuccessEmbed(result)],
|
||||
components: []
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("time")) {
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneWarningEmbed("Confirmation timed out. Please try again.")],
|
||||
components: []
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
// No confirmation needed, proceed directly
|
||||
const result = await pruneService.deleteMessages(
|
||||
interaction.channel!,
|
||||
{
|
||||
amount: finalAmount as number,
|
||||
userId: user?.id,
|
||||
all: false
|
||||
}
|
||||
);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneErrorEmbed(errorMessage)]
|
||||
});
|
||||
}
|
||||
// Check if no messages were found
|
||||
if (result.deletedCount === 0) {
|
||||
if (user) {
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneWarningEmbed(`No messages found from **${user.username}** in the last ${finalAmount} messages.`)]
|
||||
});
|
||||
} else {
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneWarningEmbed("No messages found to delete.")]
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [getSuccessEmbed(result)]
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { createSuccessEmbed } from "@lib/embeds";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const refresh = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -9,25 +10,24 @@ export const refresh = createCommand({
|
||||
.setDescription("Reloads all commands and config without restarting")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const start = Date.now();
|
||||
await AuroraClient.loadCommands(true);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
await AuroraClient.loadCommands(true);
|
||||
const duration = Date.now() - start;
|
||||
// Deploy commands
|
||||
await AuroraClient.deployCommands();
|
||||
|
||||
// Deploy commands
|
||||
await AuroraClient.deployCommands();
|
||||
const embed = createSuccessEmbed(
|
||||
`Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`,
|
||||
"System Refreshed"
|
||||
);
|
||||
|
||||
const embed = createSuccessEmbed(
|
||||
`Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`,
|
||||
"System Refreshed"
|
||||
);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An error occurred while refreshing commands. Check console for details.", "Refresh Failed")] });
|
||||
}
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
243
bot/commands/admin/settings.ts
Normal file
243
bot/commands/admin/settings.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, Colors, ChatInputCommandInteraction } from "discord.js";
|
||||
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
|
||||
import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const settings = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("settings")
|
||||
.setDescription("Manage guild settings")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("show")
|
||||
.setDescription("Show current guild settings"))
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("set")
|
||||
.setDescription("Set a guild setting")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("key")
|
||||
.setDescription("Setting to change")
|
||||
.setRequired(true)
|
||||
.addChoices(
|
||||
{ name: "Student Role", value: "studentRole" },
|
||||
{ name: "Visitor Role", value: "visitorRole" },
|
||||
{ name: "Welcome Channel", value: "welcomeChannel" },
|
||||
{ name: "Welcome Message", value: "welcomeMessage" },
|
||||
{ name: "Feedback Channel", value: "feedbackChannel" },
|
||||
{ name: "Terminal Channel", value: "terminalChannel" },
|
||||
{ name: "Terminal Message", value: "terminalMessage" },
|
||||
{ name: "Moderation Log Channel", value: "moderationLogChannel" },
|
||||
{ name: "DM on Warn", value: "moderationDmOnWarn" },
|
||||
{ name: "Auto Timeout Threshold", value: "moderationAutoTimeoutThreshold" },
|
||||
))
|
||||
.addRoleOption(opt =>
|
||||
opt.setName("role")
|
||||
.setDescription("Role value"))
|
||||
.addChannelOption(opt =>
|
||||
opt.setName("channel")
|
||||
.setDescription("Channel value"))
|
||||
.addStringOption(opt =>
|
||||
opt.setName("text")
|
||||
.setDescription("Text value"))
|
||||
.addIntegerOption(opt =>
|
||||
opt.setName("number")
|
||||
.setDescription("Number value"))
|
||||
.addBooleanOption(opt =>
|
||||
opt.setName("boolean")
|
||||
.setDescription("Boolean value (true/false)")))
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("reset")
|
||||
.setDescription("Reset a setting to default")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("key")
|
||||
.setDescription("Setting to reset")
|
||||
.setRequired(true)
|
||||
.addChoices(
|
||||
{ name: "Student Role", value: "studentRole" },
|
||||
{ name: "Visitor Role", value: "visitorRole" },
|
||||
{ name: "Welcome Channel", value: "welcomeChannel" },
|
||||
{ name: "Welcome Message", value: "welcomeMessage" },
|
||||
{ name: "Feedback Channel", value: "feedbackChannel" },
|
||||
{ name: "Terminal Channel", value: "terminalChannel" },
|
||||
{ name: "Terminal Message", value: "terminalMessage" },
|
||||
{ name: "Moderation Log Channel", value: "moderationLogChannel" },
|
||||
{ name: "DM on Warn", value: "moderationDmOnWarn" },
|
||||
{ name: "Auto Timeout Threshold", value: "moderationAutoTimeoutThreshold" },
|
||||
)))
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("colors")
|
||||
.setDescription("Manage color roles")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("action")
|
||||
.setDescription("Action to perform")
|
||||
.setRequired(true)
|
||||
.addChoices(
|
||||
{ name: "List", value: "list" },
|
||||
{ name: "Add", value: "add" },
|
||||
{ name: "Remove", value: "remove" },
|
||||
))
|
||||
.addRoleOption(opt =>
|
||||
opt.setName("role")
|
||||
.setDescription("Role to add/remove")
|
||||
.setRequired(false))),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
const guildId = interaction.guildId!;
|
||||
|
||||
switch (subcommand) {
|
||||
case "show":
|
||||
await handleShow(interaction, guildId);
|
||||
break;
|
||||
case "set":
|
||||
await handleSet(interaction, guildId);
|
||||
break;
|
||||
case "reset":
|
||||
await handleReset(interaction, guildId);
|
||||
break;
|
||||
case "colors":
|
||||
await handleColors(interaction, guildId);
|
||||
break;
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
async function handleShow(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||
const settings = await getGuildConfig(guildId);
|
||||
|
||||
const colorRolesDisplay = settings.colorRoles?.length
|
||||
? settings.colorRoles.map(id => `<@&${id}>`).join(", ")
|
||||
: "None";
|
||||
|
||||
const embed = createBaseEmbed("Guild Settings", undefined, Colors.Blue)
|
||||
.addFields(
|
||||
{ name: "Student Role", value: settings.studentRole ? `<@&${settings.studentRole}>` : "Not set", inline: true },
|
||||
{ name: "Visitor Role", value: settings.visitorRole ? `<@&${settings.visitorRole}>` : "Not set", inline: true },
|
||||
{ name: "\u200b", value: "\u200b", inline: true },
|
||||
{ name: "Welcome Channel", value: settings.welcomeChannelId ? `<#${settings.welcomeChannelId}>` : "Not set", inline: true },
|
||||
{ name: "Feedback Channel", value: settings.feedbackChannelId ? `<#${settings.feedbackChannelId}>` : "Not set", inline: true },
|
||||
{ name: "Moderation Log", value: settings.moderation?.cases?.logChannelId ? `<#${settings.moderation.cases.logChannelId}>` : "Not set", inline: true },
|
||||
{ name: "Terminal Channel", value: settings.terminal?.channelId ? `<#${settings.terminal.channelId}>` : "Not set", inline: true },
|
||||
{ name: "DM on Warn", value: settings.moderation?.cases?.dmOnWarn !== false ? "Enabled" : "Disabled", inline: true },
|
||||
{ name: "Auto Timeout", value: settings.moderation?.cases?.autoTimeoutThreshold ? `${settings.moderation.cases.autoTimeoutThreshold} warnings` : "Disabled", inline: true },
|
||||
{ name: "Color Roles", value: colorRolesDisplay, inline: false },
|
||||
);
|
||||
|
||||
if (settings.welcomeMessage) {
|
||||
embed.addFields({ name: "Welcome Message", value: settings.welcomeMessage.substring(0, 1024), inline: false });
|
||||
}
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
|
||||
async function handleSet(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||
const key = interaction.options.getString("key", true);
|
||||
const role = interaction.options.getRole("role");
|
||||
const channel = interaction.options.getChannel("channel");
|
||||
const text = interaction.options.getString("text");
|
||||
const number = interaction.options.getInteger("number");
|
||||
const boolean = interaction.options.getBoolean("boolean");
|
||||
|
||||
let value: string | number | boolean | null = null;
|
||||
|
||||
if (role) value = role.id;
|
||||
else if (channel) value = channel.id;
|
||||
else if (text) value = text;
|
||||
else if (number !== null) value = number;
|
||||
else if (boolean !== null) value = boolean;
|
||||
|
||||
if (value === null) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("Please provide a role, channel, text, number, or boolean value")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await guildSettingsService.updateSetting(guildId, key, value);
|
||||
invalidateGuildConfigCache(guildId);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Setting "${key}" updated`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleReset(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||
const key = interaction.options.getString("key", true);
|
||||
|
||||
await guildSettingsService.updateSetting(guildId, key, null);
|
||||
invalidateGuildConfigCache(guildId);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Setting "${key}" reset to default`)]
|
||||
});
|
||||
}
|
||||
|
||||
async function handleColors(interaction: ChatInputCommandInteraction, guildId: string) {
|
||||
const action = interaction.options.getString("action", true);
|
||||
const role = interaction.options.getRole("role");
|
||||
|
||||
switch (action) {
|
||||
case "list": {
|
||||
const settings = await getGuildConfig(guildId);
|
||||
const colorRoles = settings.colorRoles ?? [];
|
||||
|
||||
if (colorRoles.length === 0) {
|
||||
await interaction.editReply({
|
||||
embeds: [createBaseEmbed("Color Roles", "No color roles configured.", Colors.Blue)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = createBaseEmbed("Color Roles", undefined, Colors.Blue)
|
||||
.addFields({
|
||||
name: `Configured Roles (${colorRoles.length})`,
|
||||
value: colorRoles.map(id => `<@&${id}>`).join("\n"),
|
||||
});
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
break;
|
||||
}
|
||||
|
||||
case "add": {
|
||||
if (!role) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("Please specify a role to add.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await guildSettingsService.addColorRole(guildId, role.id);
|
||||
invalidateGuildConfigCache(guildId);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Added <@&${role.id}> to color roles.`)]
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "remove": {
|
||||
if (!role) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("Please specify a role to remove.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await guildSettingsService.removeColorRole(guildId, role.id);
|
||||
invalidateGuildConfigCache(guildId);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(`Removed <@&${role.id}> from color roles.`)]
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
|
||||
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
||||
import { createBaseEmbed, createErrorEmbed } from "@/lib/embeds";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const terminal = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -23,15 +24,14 @@ export const terminal = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({ ephemeral: true, content: "Initializing terminal..." });
|
||||
|
||||
try {
|
||||
await terminalService.init(channel as TextChannel);
|
||||
await interaction.editReply({ content: "✅ Terminal initialized!" });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await interaction.editReply({ content: "❌ Failed to initialize terminal." });
|
||||
}
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
await terminalService.init(channel as TextChannel);
|
||||
await interaction.editReply({ content: "✅ Terminal initialized!" });
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||
import { UpdateService } from "@shared/modules/admin/update.service";
|
||||
import {
|
||||
getCheckingEmbed,
|
||||
getNoUpdatesEmbed,
|
||||
getUpdatesAvailableMessage,
|
||||
getPreparingEmbed,
|
||||
getUpdatingEmbed,
|
||||
getCancelledEmbed,
|
||||
getTimeoutEmbed,
|
||||
getErrorEmbed,
|
||||
getRollbackSuccessEmbed,
|
||||
getRollbackFailedEmbed
|
||||
} from "@/modules/admin/update.view";
|
||||
|
||||
export const update = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("update")
|
||||
.setDescription("Check for updates and restart the bot")
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("check")
|
||||
.setDescription("Check for and apply available updates")
|
||||
.addBooleanOption(option =>
|
||||
option.setName("force")
|
||||
.setDescription("Force update even if no changes detected")
|
||||
.setRequired(false)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("rollback")
|
||||
.setDescription("Rollback to the previous version")
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
if (subcommand === "rollback") {
|
||||
await handleRollback(interaction);
|
||||
} else {
|
||||
await handleUpdate(interaction);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function handleUpdate(interaction: any) {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
const force = interaction.options.getBoolean("force") || false;
|
||||
|
||||
try {
|
||||
// 1. Check for updates
|
||||
await interaction.editReply({ embeds: [getCheckingEmbed()] });
|
||||
const updateInfo = await UpdateService.checkForUpdates();
|
||||
|
||||
if (!updateInfo.hasUpdates && !force) {
|
||||
await interaction.editReply({
|
||||
embeds: [getNoUpdatesEmbed(updateInfo.currentCommit)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Analyze requirements
|
||||
const requirements = await UpdateService.checkUpdateRequirements(updateInfo.branch);
|
||||
const categories = UpdateService.categorizeChanges(requirements.changedFiles);
|
||||
|
||||
// 3. Show confirmation with details
|
||||
const { embeds, components } = getUpdatesAvailableMessage(
|
||||
updateInfo,
|
||||
requirements,
|
||||
categories,
|
||||
force
|
||||
);
|
||||
const response = await interaction.editReply({ embeds, components });
|
||||
|
||||
// 4. Wait for confirmation
|
||||
try {
|
||||
const confirmation = await response.awaitMessageComponent({
|
||||
filter: (i: any) => i.user.id === interaction.user.id,
|
||||
componentType: ComponentType.Button,
|
||||
time: 30000
|
||||
});
|
||||
|
||||
if (confirmation.customId === "confirm_update") {
|
||||
await confirmation.update({
|
||||
embeds: [getPreparingEmbed()],
|
||||
components: []
|
||||
});
|
||||
|
||||
// 5. Save rollback point
|
||||
const previousCommit = await UpdateService.saveRollbackPoint();
|
||||
|
||||
// 6. Prepare restart context
|
||||
await UpdateService.prepareRestartContext({
|
||||
channelId: interaction.channelId,
|
||||
userId: interaction.user.id,
|
||||
timestamp: Date.now(),
|
||||
runMigrations: requirements.needsMigrations,
|
||||
installDependencies: requirements.needsRootInstall || requirements.needsWebInstall,
|
||||
previousCommit: previousCommit.substring(0, 7),
|
||||
newCommit: updateInfo.latestCommit
|
||||
});
|
||||
|
||||
// 7. Show updating status
|
||||
await interaction.editReply({
|
||||
embeds: [getUpdatingEmbed(requirements)]
|
||||
});
|
||||
|
||||
// 8. Perform update
|
||||
await UpdateService.performUpdate(updateInfo.branch);
|
||||
|
||||
// 9. Trigger restart
|
||||
await UpdateService.triggerRestart();
|
||||
|
||||
} else {
|
||||
await confirmation.update({
|
||||
embeds: [getCancelledEmbed()],
|
||||
components: []
|
||||
});
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes("time")) {
|
||||
await interaction.editReply({
|
||||
embeds: [getTimeoutEmbed()],
|
||||
components: []
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Update failed:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getErrorEmbed(error)],
|
||||
components: []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRollback(interaction: any) {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const hasRollback = await UpdateService.hasRollbackPoint();
|
||||
|
||||
if (!hasRollback) {
|
||||
await interaction.editReply({
|
||||
embeds: [getRollbackFailedEmbed("No rollback point available. Rollback is only possible after a recent update.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await UpdateService.rollback();
|
||||
|
||||
if (result.success) {
|
||||
await interaction.editReply({
|
||||
embeds: [getRollbackSuccessEmbed(result.message.split(" ").pop() || "unknown")]
|
||||
});
|
||||
|
||||
// Restart after rollback
|
||||
setTimeout(() => UpdateService.triggerRestart(), 1000);
|
||||
} else {
|
||||
await interaction.editReply({
|
||||
embeds: [getRollbackFailedEmbed(result.message)]
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Rollback failed:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getErrorEmbed(error)]
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import {
|
||||
getWarnSuccessEmbed,
|
||||
getModerationErrorEmbed,
|
||||
getUserWarningEmbed
|
||||
} from "@/modules/moderation/moderation.view";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const warn = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -28,60 +28,63 @@ export const warn = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
const reason = interaction.options.getString("reason", true);
|
||||
|
||||
try {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
const reason = interaction.options.getString("reason", true);
|
||||
// Don't allow warning bots
|
||||
if (targetUser.bot) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("You cannot warn bots.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't allow warning bots
|
||||
if (targetUser.bot) {
|
||||
// Don't allow self-warnings
|
||||
if (targetUser.id === interaction.user.id) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("You cannot warn yourself.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch guild config for moderation settings
|
||||
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||
|
||||
// Issue the warning via service
|
||||
const { moderationCase, warningCount, autoTimeoutIssued } = await moderationService.issueWarning({
|
||||
userId: targetUser.id,
|
||||
username: targetUser.username,
|
||||
moderatorId: interaction.user.id,
|
||||
moderatorName: interaction.user.username,
|
||||
reason,
|
||||
guildName: interaction.guild?.name || undefined,
|
||||
dmTarget: targetUser,
|
||||
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id),
|
||||
config: {
|
||||
dmOnWarn: guildConfig.moderation?.cases?.dmOnWarn,
|
||||
autoTimeoutThreshold: guildConfig.moderation?.cases?.autoTimeoutThreshold,
|
||||
},
|
||||
});
|
||||
|
||||
// Send success message to moderator
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("You cannot warn bots.")]
|
||||
embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't allow self-warnings
|
||||
if (targetUser.id === interaction.user.id) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("You cannot warn yourself.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Issue the warning via service
|
||||
const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({
|
||||
userId: targetUser.id,
|
||||
username: targetUser.username,
|
||||
moderatorId: interaction.user.id,
|
||||
moderatorName: interaction.user.username,
|
||||
reason,
|
||||
guildName: interaction.guild?.name || undefined,
|
||||
dmTarget: targetUser,
|
||||
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id)
|
||||
});
|
||||
|
||||
// Send success message to moderator
|
||||
await interaction.editReply({
|
||||
embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
|
||||
});
|
||||
|
||||
// Follow up if auto-timeout was issued
|
||||
if (autoTimeoutIssued) {
|
||||
await interaction.followUp({
|
||||
embeds: [getModerationErrorEmbed(
|
||||
`⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.`
|
||||
)],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Warn command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while issuing the warning.")]
|
||||
});
|
||||
}
|
||||
// Follow up if auto-timeout was issued
|
||||
if (autoTimeoutIssued) {
|
||||
await interaction.followUp({
|
||||
embeds: [getModerationErrorEmbed(
|
||||
`⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.`
|
||||
)],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
|
||||
import { moderationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getWarningsEmbed } from "@/modules/moderation/moderation.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const warnings = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -16,24 +17,20 @@ export const warnings = createCommand({
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
|
||||
try {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
// Get active warnings for the user
|
||||
const activeWarnings = await moderationService.getUserWarnings(targetUser.id);
|
||||
|
||||
// Get active warnings for the user
|
||||
const activeWarnings = await ModerationService.getUserWarnings(targetUser.id);
|
||||
|
||||
// Display the warnings
|
||||
await interaction.editReply({
|
||||
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Warnings command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching warnings.")]
|
||||
});
|
||||
}
|
||||
// Display the warnings
|
||||
await interaction.editReply({
|
||||
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)]
|
||||
});
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const webhook = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -14,43 +15,40 @@ export const webhook = createCommand({
|
||||
.setRequired(true)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const payloadString = interaction.options.getString("payload", true);
|
||||
let payload;
|
||||
|
||||
const payloadString = interaction.options.getString("payload", true);
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(payloadString);
|
||||
} catch (error) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("The provided payload is not valid JSON.", "Invalid JSON")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
payload = JSON.parse(payloadString);
|
||||
} catch (error) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("The provided payload is not valid JSON.", "Invalid JSON")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
const channel = interaction.channel;
|
||||
|
||||
const channel = interaction.channel;
|
||||
if (!channel || !('createWebhook' in channel)) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("This channel does not support webhooks.", "Unsupported Channel")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!channel || !('createWebhook' in channel)) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("This channel does not support webhooks.", "Unsupported Channel")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
await sendWebhookMessage(
|
||||
channel,
|
||||
payload,
|
||||
interaction.client.user,
|
||||
`Proxy message requested by ${interaction.user.tag}`
|
||||
);
|
||||
|
||||
try {
|
||||
await sendWebhookMessage(
|
||||
channel,
|
||||
payload,
|
||||
interaction.client.user,
|
||||
`Proxy message requested by ${interaction.user.tag}`
|
||||
);
|
||||
|
||||
await interaction.editReply({ content: "Message sent successfully!" });
|
||||
} catch (error) {
|
||||
console.error("Webhook error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("Failed to send message via webhook. Ensure the bot has 'Manage Webhooks' permission and the payload is valid.", "Delivery Failed")]
|
||||
});
|
||||
}
|
||||
await interaction.editReply({ content: "Message sent successfully!" });
|
||||
},
|
||||
{ ephemeral: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,34 +2,29 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { createSuccessEmbed } from "@lib/embeds";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const daily = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("daily")
|
||||
.setDescription("Claim your daily reward"),
|
||||
execute: async (interaction) => {
|
||||
try {
|
||||
const result = await economyService.claimDaily(interaction.user.id);
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const result = await economyService.claimDaily(interaction.user.id);
|
||||
|
||||
const embed = createSuccessEmbed(`You claimed ** ${result.amount}** Astral Units!${result.isWeekly ? `\n🎉 **Weekly Bonus!** +${result.weeklyBonus} extra!` : ''}`, "💰 Daily Reward Claimed!")
|
||||
.addFields(
|
||||
{ name: "Streak", value: `🔥 ${result.streak} days`, inline: true },
|
||||
{ name: "Weekly Progress", value: `${"🟩".repeat(result.streak % 7 || 7)}${"⬜".repeat(7 - (result.streak % 7 || 7))} (${result.streak % 7 || 7}/7)`, inline: true },
|
||||
{ name: "Next Reward", value: `<t:${Math.floor(result.nextReadyAt.getTime() / 1000)}:R> `, inline: true }
|
||||
)
|
||||
.setColor("Gold");
|
||||
const embed = createSuccessEmbed(`You claimed ** ${result.amount}** Astral Units!${result.isWeekly ? `\n🎉 **Weekly Bonus!** +${result.weeklyBonus} extra!` : ''}`, "💰 Daily Reward Claimed!")
|
||||
.addFields(
|
||||
{ name: "Streak", value: `🔥 ${result.streak} days`, inline: true },
|
||||
{ name: "Weekly Progress", value: `${"🟩".repeat(result.streak % 7 || 7)}${"⬜".repeat(7 - (result.streak % 7 || 7))} (${result.streak % 7 || 7}/7)`, inline: true },
|
||||
{ name: "Next Reward", value: `<t:${Math.floor(result.nextReadyAt.getTime() / 1000)}:R> `, inline: true }
|
||||
)
|
||||
.setColor("Gold");
|
||||
|
||||
await interaction.reply({ embeds: [embed] });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
||||
} else {
|
||||
console.error("Error claiming daily:", error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,21 +1,8 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { userTimers, users } from "@db/schema";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { TimerType } from "@shared/lib/constants";
|
||||
|
||||
const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
|
||||
const EXAM_TIMER_KEY = 'default';
|
||||
|
||||
interface ExamMetadata {
|
||||
examDay: number;
|
||||
lastXp: string;
|
||||
}
|
||||
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
@@ -24,182 +11,62 @@ export const exam = createCommand({
|
||||
.setName("exam")
|
||||
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
if (!user) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to retrieve user data.")] });
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
const currentDay = now.getDay();
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
// First, try to take the exam or check status
|
||||
const result = await examService.takeExam(interaction.user.id);
|
||||
|
||||
try {
|
||||
// 1. Fetch existing timer/exam data
|
||||
const timer = await DrizzleClient.query.userTimers.findFirst({
|
||||
where: and(
|
||||
eq(userTimers.userId, user.id),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
)
|
||||
});
|
||||
if (result.status === ExamStatus.NOT_REGISTERED) {
|
||||
// Register the user
|
||||
const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username);
|
||||
const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000);
|
||||
|
||||
// 2. First Run Logic
|
||||
if (!timer) {
|
||||
// Set exam day to today
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + 7);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` +
|
||||
`Come back on <t:${nextRegTimestamp}:D> (<t:${nextRegTimestamp}:R>) to take your first exam!`,
|
||||
"Exam Registration Successful"
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata: ExamMetadata = {
|
||||
examDay: currentDay,
|
||||
lastXp: (user.xp ?? 0n).toString()
|
||||
};
|
||||
const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000);
|
||||
|
||||
await DrizzleClient.insert(userTimers).values({
|
||||
userId: user.id,
|
||||
type: EXAM_TIMER_TYPE,
|
||||
key: EXAM_TIMER_KEY,
|
||||
expiresAt: nextExamDate,
|
||||
metadata: metadata
|
||||
});
|
||||
if (result.status === ExamStatus.COOLDOWN) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
||||
`Next exam available: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === ExamStatus.MISSED) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You missed your exam day! Your exam day is **${DAYS[result.examDay!]}** (Server Time).\n` +
|
||||
`You verify your attendance but score a **0**.\n` +
|
||||
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
|
||||
"Exam Failed"
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If it reached here with AVAILABLE, it means they passed
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}** (Server Time).\n` +
|
||||
`Come back on <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>) to take your first exam!`,
|
||||
"Exam Registration Successful"
|
||||
`**XP Gained:** ${result.xpDiff?.toString()}\n` +
|
||||
`**Multiplier:** x${result.multiplier?.toFixed(2)}\n` +
|
||||
`**Reward:** ${result.reward?.toString()} Currency\n\n` +
|
||||
`See you next week: <t:${nextExamTimestamp}:D>`,
|
||||
"Exam Passed!"
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = timer.metadata as unknown as ExamMetadata;
|
||||
const examDay = metadata.examDay;
|
||||
|
||||
// 3. Cooldown Check
|
||||
const expiresAt = new Date(timer.expiresAt);
|
||||
expiresAt.setHours(0, 0, 0, 0);
|
||||
|
||||
if (now < expiresAt) {
|
||||
// Calculate time remaining
|
||||
const timestamp = Math.floor(expiresAt.getTime() / 1000);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
||||
`Next exam available: <t:${timestamp}:D> (<t:${timestamp}:R>)`
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Day Check
|
||||
if (currentDay !== examDay) {
|
||||
// Calculate next correct exam day to correct the schedule
|
||||
let daysUntil = (examDay - currentDay + 7) % 7;
|
||||
if (daysUntil === 0) daysUntil = 7;
|
||||
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + daysUntil);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||
|
||||
const newMetadata: ExamMetadata = {
|
||||
examDay: examDay,
|
||||
lastXp: (user.xp ?? 0n).toString()
|
||||
};
|
||||
|
||||
await DrizzleClient.update(userTimers)
|
||||
.set({
|
||||
expiresAt: nextExamDate,
|
||||
metadata: newMetadata
|
||||
})
|
||||
.where(and(
|
||||
eq(userTimers.userId, user.id),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
));
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You missed your exam day! Your exam day is **${DAYS[examDay]}** (Server Time).\n` +
|
||||
`You verify your attendance but score a **0**.\n` +
|
||||
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
|
||||
"Exam Failed"
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Reward Calculation
|
||||
const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case
|
||||
const currentXp = user.xp ?? 0n;
|
||||
const diff = currentXp - lastXp;
|
||||
|
||||
// Calculate Reward
|
||||
const multMin = config.economy.exam.multMin;
|
||||
const multMax = config.economy.exam.multMax;
|
||||
const multiplier = Math.random() * (multMax - multMin) + multMin;
|
||||
|
||||
// Allow negative reward? existing description implies "difference", usually gain.
|
||||
// If diff is negative (lost XP?), reward might be 0.
|
||||
let reward = 0n;
|
||||
if (diff > 0n) {
|
||||
reward = BigInt(Math.floor(Number(diff) * multiplier));
|
||||
}
|
||||
|
||||
// 6. Update State
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + 7);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||
|
||||
const newMetadata: ExamMetadata = {
|
||||
examDay: examDay,
|
||||
lastXp: currentXp.toString()
|
||||
};
|
||||
|
||||
await DrizzleClient.transaction(async (tx) => {
|
||||
// Update Timer
|
||||
await tx.update(userTimers)
|
||||
.set({
|
||||
expiresAt: nextExamDate,
|
||||
metadata: newMetadata
|
||||
})
|
||||
.where(and(
|
||||
eq(userTimers.userId, user.id),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
));
|
||||
|
||||
// Add Currency
|
||||
if (reward > 0n) {
|
||||
await tx.update(users)
|
||||
.set({
|
||||
balance: sql`${users.balance} + ${reward}`
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
}
|
||||
});
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`**XP Gained:** ${diff.toString()}\n` +
|
||||
`**Multiplier:** x${multiplier.toFixed(2)}\n` +
|
||||
`**Reward:** ${reward.toString()} Currency\n\n` +
|
||||
`See you next week: <t:${nextExamTimestamp}:D>`,
|
||||
"Exam Passed!"
|
||||
)]
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
||||
} else {
|
||||
console.error("Error in exam command:", error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const pay = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -50,20 +50,14 @@ export const pay = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await interaction.deferReply();
|
||||
await economyService.transfer(senderId, receiverId.toString(), amount);
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
await economyService.transfer(senderId, receiverId.toString(), amount);
|
||||
|
||||
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
|
||||
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error sending payment:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
|
||||
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,9 +3,10 @@ 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 "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { TriviaCategory } from "@shared/lib/constants";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const trivia = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -53,64 +54,54 @@ export const trivia = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
// User can play - defer publicly for trivia question
|
||||
await interaction.deferReply();
|
||||
// User can play - use standardized error handling for the main operation
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
// Start trivia session (deducts entry fee)
|
||||
const session = await triviaService.startTrivia(
|
||||
interaction.user.id,
|
||||
interaction.user.username,
|
||||
categoryId ? parseInt(categoryId) : undefined
|
||||
);
|
||||
|
||||
// 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
|
||||
}
|
||||
);
|
||||
|
||||
// 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) {
|
||||
// Handle errors from the pre-defer canPlayTrivia check
|
||||
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
|
||||
});
|
||||
}
|
||||
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
|
||||
});
|
||||
}
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { getFeedbackTypeMenu } from "@/modules/feedback/feedback.view";
|
||||
|
||||
@@ -9,8 +9,10 @@ export const feedback = createCommand({
|
||||
.setName("feedback")
|
||||
.setDescription("Submit feedback, feature requests, or bug reports"),
|
||||
execute: async (interaction) => {
|
||||
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||
|
||||
// Check if feedback channel is configured
|
||||
if (!config.feedbackChannelId) {
|
||||
if (!guildConfig.feedbackChannelId) {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("Feedback system is not configured. Please contact an administrator.")],
|
||||
ephemeral: true
|
||||
|
||||
@@ -4,9 +4,8 @@ import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
||||
import type { ItemUsageData } from "@shared/lib/types";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
|
||||
export const use = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -19,54 +18,50 @@ export const use = createCommand({
|
||||
.setAutocomplete(true)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
await withCommandErrorHandling(
|
||||
interaction,
|
||||
async () => {
|
||||
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||
const colorRoles = guildConfig.colorRoles ?? [];
|
||||
|
||||
const itemId = interaction.options.getNumber("item", true);
|
||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
if (!user) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] });
|
||||
return;
|
||||
}
|
||||
const itemId = interaction.options.getNumber("item", true);
|
||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
if (!user) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await inventoryService.useItem(user.id.toString(), itemId);
|
||||
const result = await inventoryService.useItem(user.id.toString(), itemId);
|
||||
|
||||
const usageData = result.usageData;
|
||||
if (usageData) {
|
||||
for (const effect of usageData.effects) {
|
||||
if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') {
|
||||
try {
|
||||
const member = await interaction.guild?.members.fetch(user.id.toString());
|
||||
if (member) {
|
||||
if (effect.type === 'TEMP_ROLE') {
|
||||
await member.roles.add(effect.roleId);
|
||||
} else if (effect.type === 'COLOR_ROLE') {
|
||||
// Remove existing color roles
|
||||
const rolesToRemove = config.colorRoles.filter(r => member.roles.cache.has(r));
|
||||
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
|
||||
await member.roles.add(effect.roleId);
|
||||
const usageData = result.usageData;
|
||||
if (usageData) {
|
||||
for (const effect of usageData.effects) {
|
||||
if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') {
|
||||
try {
|
||||
const member = await interaction.guild?.members.fetch(user.id.toString());
|
||||
if (member) {
|
||||
if (effect.type === 'TEMP_ROLE') {
|
||||
await member.roles.add(effect.roleId);
|
||||
} else if (effect.type === 'COLOR_ROLE') {
|
||||
// Remove existing color roles
|
||||
const rolesToRemove = colorRoles.filter(r => member.roles.cache.has(r));
|
||||
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
|
||||
await member.roles.add(effect.roleId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to assign role in /use command:", e);
|
||||
result.results.push("⚠️ Failed to assign role (Check bot permissions)");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to assign role in /use command:", e);
|
||||
result.results.push("⚠️ Failed to assign role (Check bot permissions)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { embed, files } = getItemUseResultEmbed(result.results, result.item);
|
||||
|
||||
await interaction.editReply({ embeds: [embed], files });
|
||||
}
|
||||
|
||||
const embed = getItemUseResultEmbed(result.results, result.item);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error using item:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred while using the item.")] });
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
autocomplete: async (interaction) => {
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
|
||||
@@ -1,25 +1,83 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
||||
import { questService } from "@shared/modules/quest/quest.service";
|
||||
import { createWarningEmbed } from "@lib/embeds";
|
||||
import { getQuestListEmbed } from "@/modules/quest/quest.view";
|
||||
import { createSuccessEmbed } from "@lib/embeds";
|
||||
import {
|
||||
getQuestListComponents,
|
||||
getAvailableQuestsComponents,
|
||||
getQuestActionRows
|
||||
} from "@/modules/quest/quest.view";
|
||||
|
||||
export const quests = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("quests")
|
||||
.setDescription("View your active quests"),
|
||||
.setDescription("View your active and available quests"),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
const response = await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const userQuests = await questService.getUserQuests(interaction.user.id);
|
||||
const userId = interaction.user.id;
|
||||
|
||||
if (!userQuests || userQuests.length === 0) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("You have no active quests.", "Quest Log")] });
|
||||
return;
|
||||
}
|
||||
const updateView = async (viewType: 'active' | 'available') => {
|
||||
const userQuests = await questService.getUserQuests(userId);
|
||||
const availableQuests = await questService.getAvailableQuests(userId);
|
||||
|
||||
const embed = getQuestListEmbed(userQuests);
|
||||
const containers = viewType === 'active'
|
||||
? getQuestListComponents(userQuests)
|
||||
: getAvailableQuestsComponents(availableQuests);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
const actionRows = getQuestActionRows(viewType);
|
||||
|
||||
await interaction.editReply({
|
||||
content: null,
|
||||
embeds: null as any,
|
||||
components: [...containers, ...actionRows] as any,
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
allowedMentions: { parse: [] }
|
||||
});
|
||||
};
|
||||
|
||||
// Initial view
|
||||
await updateView('active');
|
||||
|
||||
const collector = response.createMessageComponentCollector({
|
||||
time: 120000, // 2 minutes
|
||||
componentType: undefined // Allow buttons
|
||||
});
|
||||
|
||||
collector.on('collect', async (i) => {
|
||||
if (i.user.id !== interaction.user.id) return;
|
||||
|
||||
try {
|
||||
if (i.customId === "quest_view_active") {
|
||||
await i.deferUpdate();
|
||||
await updateView('active');
|
||||
} else if (i.customId === "quest_view_available") {
|
||||
await i.deferUpdate();
|
||||
await updateView('available');
|
||||
} else if (i.customId.startsWith("quest_accept:")) {
|
||||
const questIdStr = i.customId.split(":")[1];
|
||||
if (!questIdStr) return;
|
||||
const questId = parseInt(questIdStr);
|
||||
await questService.assignQuest(userId, questId);
|
||||
|
||||
await i.reply({
|
||||
embeds: [createSuccessEmbed(`You have accepted a new quest!`, "Quest Accepted")],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
|
||||
await updateView('active');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Quest interaction error:", error);
|
||||
await i.followUp({
|
||||
content: "Something went wrong while processing your quest interaction.",
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
collector.on('end', () => {
|
||||
interaction.editReply({ components: [] }).catch(() => {});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { Events } from "discord.js";
|
||||
import type { Event } from "@shared/lib/types";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
|
||||
// Visitor role
|
||||
const event: Event<Events.GuildMemberAdd> = {
|
||||
name: Events.GuildMemberAdd,
|
||||
execute: async (member) => {
|
||||
console.log(`👤 New member joined: ${member.user.tag} (${member.id})`);
|
||||
|
||||
const guildConfig = await getGuildConfig(member.guild.id);
|
||||
|
||||
try {
|
||||
const user = await userService.getUserById(member.id);
|
||||
|
||||
if (user && user.class) {
|
||||
console.log(`🔄 Returning student detected: ${member.user.tag}`);
|
||||
await member.roles.remove(config.visitorRole);
|
||||
await member.roles.add(config.studentRole);
|
||||
if (guildConfig.visitorRole) {
|
||||
await member.roles.remove(guildConfig.visitorRole);
|
||||
}
|
||||
if (guildConfig.studentRole) {
|
||||
await member.roles.add(guildConfig.studentRole);
|
||||
}
|
||||
|
||||
if (user.class.roleId) {
|
||||
await member.roles.add(user.class.roleId);
|
||||
@@ -22,8 +28,10 @@ const event: Event<Events.GuildMemberAdd> = {
|
||||
}
|
||||
console.log(`Restored student role to ${member.user.tag}`);
|
||||
} else {
|
||||
await member.roles.add(config.visitorRole);
|
||||
console.log(`Assigned visitor role to ${member.user.tag}`);
|
||||
if (guildConfig.visitorRole) {
|
||||
await member.roles.add(guildConfig.visitorRole);
|
||||
console.log(`Assigned visitor role to ${member.user.tag}`);
|
||||
}
|
||||
}
|
||||
console.log(`User Roles: ${member.roles.cache.map(role => role.name).join(", ")}`);
|
||||
} catch (error) {
|
||||
|
||||
@@ -9,9 +9,7 @@ const event: Event<Events.ClientReady> = {
|
||||
console.log(`Ready! Logged in as ${c.user.tag}`);
|
||||
schedulerService.start();
|
||||
|
||||
// Handle post-update tasks
|
||||
const { UpdateService } = await import("@shared/modules/admin/update.service");
|
||||
await UpdateService.handlePostRestart(c);
|
||||
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { env } from "@shared/lib/env";
|
||||
import { join } from "node:path";
|
||||
import { initializeConfig } from "@shared/lib/config";
|
||||
|
||||
import { startWebServerFromRoot } from "../web/src/server";
|
||||
import { startWebServerFromRoot } from "../api/src/server";
|
||||
|
||||
// Initialize config from database
|
||||
await initializeConfig();
|
||||
|
||||
// Load commands & events
|
||||
await AuroraClient.loadCommands();
|
||||
@@ -14,7 +18,7 @@ console.log("🌐 Starting web server...");
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
const webProjectPath = join(import.meta.dir, "../web");
|
||||
const webProjectPath = join(import.meta.dir, "../api");
|
||||
const webPort = Number(process.env.WEB_PORT) || 3000;
|
||||
const webHost = process.env.HOST || "0.0.0.0";
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ mock.module("discord.js", () => ({
|
||||
Routes: {
|
||||
applicationGuildCommands: () => 'guild_route',
|
||||
applicationCommands: () => 'global_route'
|
||||
}
|
||||
},
|
||||
MessageFlags: {}
|
||||
}));
|
||||
|
||||
// Mock loaders to avoid filesystem access during client init
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js";
|
||||
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes, MessageFlags } from "discord.js";
|
||||
import { join } from "node:path";
|
||||
import type { Command } from "@shared/lib/types";
|
||||
import { env } from "@shared/lib/env";
|
||||
@@ -74,6 +74,27 @@ export class Client extends DiscordClient {
|
||||
console.log(`🛠️ System Action: Maintenance mode ${enabled ? "ON" : "OFF"}${reason ? ` (${reason})` : ""}`);
|
||||
this.maintenanceMode = enabled;
|
||||
});
|
||||
|
||||
systemEvents.on(EVENTS.QUEST.COMPLETED, async (data: { userId: string, quest: any, rewards: any }) => {
|
||||
const { userId, quest, rewards } = data;
|
||||
try {
|
||||
const user = await this.users.fetch(userId);
|
||||
if (!user) return;
|
||||
|
||||
const { getQuestCompletionComponents } = await import("@/modules/quest/quest.view");
|
||||
const components = getQuestCompletionComponents(quest, rewards);
|
||||
|
||||
// Try to send to the user's DM
|
||||
await user.send({
|
||||
components: components as any,
|
||||
flags: [MessageFlags.IsComponentsV2]
|
||||
}).catch(async () => {
|
||||
console.warn(`Could not DM user ${userId} quest completion message. User might have DMs disabled.`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send quest completion notification:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadCommands(reload: boolean = false) {
|
||||
@@ -176,4 +197,4 @@ export class Client extends DiscordClient {
|
||||
}
|
||||
}
|
||||
|
||||
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers] });
|
||||
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers, GatewayIntentBits.DirectMessages] });
|
||||
@@ -20,6 +20,9 @@ mock.module("./BotClient", () => ({
|
||||
commands: {
|
||||
size: 20,
|
||||
},
|
||||
knownCommands: {
|
||||
size: 20,
|
||||
},
|
||||
lastCommandTimestamp: 1641481200000,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -23,6 +23,7 @@ export function getClientStats(): ClientStats {
|
||||
bot: {
|
||||
name: AuroraClient.user?.username || "Aurora",
|
||||
avatarUrl: AuroraClient.user?.displayAvatarURL() || null,
|
||||
status: AuroraClient.user?.presence.activities[0]?.state || AuroraClient.user?.presence.activities[0]?.name || null,
|
||||
},
|
||||
guilds: AuroraClient.guilds.cache.size,
|
||||
ping: AuroraClient.ws.ping,
|
||||
|
||||
147
bot/lib/commandUtils.test.ts
Normal file
147
bot/lib/commandUtils.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, mock, beforeEach, spyOn } from "bun:test";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
const mockDeferReply = mock(() => Promise.resolve());
|
||||
const mockEditReply = mock(() => Promise.resolve());
|
||||
|
||||
const mockInteraction = {
|
||||
deferReply: mockDeferReply,
|
||||
editReply: mockEditReply,
|
||||
} as any;
|
||||
|
||||
const mockCreateErrorEmbed = mock((msg: string) => ({ description: msg, type: "error" }));
|
||||
|
||||
mock.module("./embeds", () => ({
|
||||
createErrorEmbed: mockCreateErrorEmbed,
|
||||
}));
|
||||
|
||||
// Import AFTER mocking
|
||||
const { withCommandErrorHandling } = await import("./commandUtils");
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe("withCommandErrorHandling", () => {
|
||||
let consoleErrorSpy: ReturnType<typeof spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockDeferReply.mockClear();
|
||||
mockEditReply.mockClear();
|
||||
mockCreateErrorEmbed.mockClear();
|
||||
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => { });
|
||||
});
|
||||
|
||||
it("should always call deferReply", async () => {
|
||||
await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => "result"
|
||||
);
|
||||
|
||||
expect(mockDeferReply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should pass ephemeral option to deferReply", async () => {
|
||||
await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => "result",
|
||||
{ ephemeral: true }
|
||||
);
|
||||
|
||||
expect(mockDeferReply).toHaveBeenCalledWith({ ephemeral: true });
|
||||
});
|
||||
|
||||
it("should return the operation result on success", async () => {
|
||||
const result = await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => ({ data: "test" })
|
||||
);
|
||||
|
||||
expect(result).toEqual({ data: "test" });
|
||||
});
|
||||
|
||||
it("should call onSuccess with the result", async () => {
|
||||
const onSuccess = mock(async (_result: string) => { });
|
||||
|
||||
await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => "hello",
|
||||
{ onSuccess }
|
||||
);
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("should send successMessage when no onSuccess is provided", async () => {
|
||||
await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => "result",
|
||||
{ successMessage: "It worked!" }
|
||||
);
|
||||
|
||||
expect(mockEditReply).toHaveBeenCalledWith({
|
||||
content: "It worked!",
|
||||
});
|
||||
});
|
||||
|
||||
it("should prefer onSuccess over successMessage", async () => {
|
||||
const onSuccess = mock(async (_result: string) => { });
|
||||
|
||||
await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => "result",
|
||||
{ successMessage: "This should not be sent", onSuccess }
|
||||
);
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1);
|
||||
// editReply should NOT have been called with the successMessage
|
||||
expect(mockEditReply).not.toHaveBeenCalledWith({
|
||||
content: "This should not be sent",
|
||||
});
|
||||
});
|
||||
|
||||
it("should show error embed for UserError", async () => {
|
||||
const result = await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => {
|
||||
throw new UserError("You can't do that!");
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockCreateErrorEmbed).toHaveBeenCalledWith("You can't do that!");
|
||||
expect(mockEditReply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should show generic error and log for unexpected errors", async () => {
|
||||
const unexpectedError = new Error("Database exploded");
|
||||
|
||||
const result = await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => {
|
||||
throw unexpectedError;
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Unexpected error in command:",
|
||||
unexpectedError
|
||||
);
|
||||
expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
|
||||
"An unexpected error occurred."
|
||||
);
|
||||
expect(mockEditReply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should return undefined on error", async () => {
|
||||
const result = await withCommandErrorHandling(
|
||||
mockInteraction,
|
||||
async () => {
|
||||
throw new Error("fail");
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
79
bot/lib/commandUtils.ts
Normal file
79
bot/lib/commandUtils.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { ChatInputCommandInteraction } from "discord.js";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { createErrorEmbed } from "./embeds";
|
||||
|
||||
/**
|
||||
* Wraps a command's core logic with standardized error handling.
|
||||
*
|
||||
* - Calls `interaction.deferReply()` automatically
|
||||
* - On success, invokes `onSuccess` callback or sends `successMessage`
|
||||
* - On `UserError`, shows the error message in an error embed
|
||||
* - On unexpected errors, logs to console and shows a generic error embed
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* export const myCommand = createCommand({
|
||||
* execute: async (interaction) => {
|
||||
* await withCommandErrorHandling(
|
||||
* interaction,
|
||||
* async () => {
|
||||
* const result = await doSomething();
|
||||
* await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
||||
* }
|
||||
* );
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // With deferReply options (e.g. ephemeral)
|
||||
* await withCommandErrorHandling(
|
||||
* interaction,
|
||||
* async () => doSomething(),
|
||||
* {
|
||||
* ephemeral: true,
|
||||
* successMessage: "Done!",
|
||||
* }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export async function withCommandErrorHandling<T>(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
operation: () => Promise<T>,
|
||||
options?: {
|
||||
/** Message to send on success (if no onSuccess callback is provided) */
|
||||
successMessage?: string;
|
||||
/** Callback invoked with the operation result on success */
|
||||
onSuccess?: (result: T) => Promise<void>;
|
||||
/** Whether the deferred reply should be ephemeral */
|
||||
ephemeral?: boolean;
|
||||
}
|
||||
): Promise<T | undefined> {
|
||||
try {
|
||||
await interaction.deferReply({ ephemeral: options?.ephemeral });
|
||||
const result = await operation();
|
||||
|
||||
if (options?.onSuccess) {
|
||||
await options.onSuccess(result);
|
||||
} else if (options?.successMessage) {
|
||||
await interaction.editReply({
|
||||
content: options.successMessage,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(error.message)],
|
||||
});
|
||||
} else {
|
||||
console.error("Unexpected error in command:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("An unexpected error occurred.")],
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||
|
||||
// Mock DrizzleClient
|
||||
mock.module("./DrizzleClient", () => ({
|
||||
// Mock DrizzleClient — must match the import path used in db.ts
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: {
|
||||
transaction: async (cb: any) => cb("MOCK_TX")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js";
|
||||
import { BRANDING } from "@shared/lib/constants";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
/**
|
||||
* Applies standard branding to an embed.
|
||||
*/
|
||||
function applyBranding(embed: EmbedBuilder): EmbedBuilder {
|
||||
return embed.setFooter({
|
||||
text: `${BRANDING.FOOTER_TEXT} v${pkg.version}`
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a standardized error embed.
|
||||
@@ -7,11 +18,13 @@ import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js";
|
||||
* @returns An EmbedBuilder instance configured as an error.
|
||||
*/
|
||||
export function createErrorEmbed(message: string, title: string = "Error"): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`❌ ${title}`)
|
||||
.setDescription(message)
|
||||
.setColor(Colors.Red)
|
||||
.setTimestamp();
|
||||
|
||||
return applyBranding(embed);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,11 +34,13 @@ export function createErrorEmbed(message: string, title: string = "Error"): Embe
|
||||
* @returns An EmbedBuilder instance configured as a warning.
|
||||
*/
|
||||
export function createWarningEmbed(message: string, title: string = "Warning"): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`⚠️ ${title}`)
|
||||
.setDescription(message)
|
||||
.setColor(Colors.Yellow)
|
||||
.setTimestamp();
|
||||
|
||||
return applyBranding(embed);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,11 +50,13 @@ export function createWarningEmbed(message: string, title: string = "Warning"):
|
||||
* @returns An EmbedBuilder instance configured as a success.
|
||||
*/
|
||||
export function createSuccessEmbed(message: string, title: string = "Success"): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`✅ ${title}`)
|
||||
.setDescription(message)
|
||||
.setColor(Colors.Green)
|
||||
.setTimestamp();
|
||||
|
||||
return applyBranding(embed);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,11 +66,13 @@ export function createSuccessEmbed(message: string, title: string = "Success"):
|
||||
* @returns An EmbedBuilder instance configured as info.
|
||||
*/
|
||||
export function createInfoEmbed(message: string, title: string = "Info"): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`ℹ️ ${title}`)
|
||||
.setDescription(message)
|
||||
.setColor(Colors.Blue)
|
||||
.setTimestamp();
|
||||
|
||||
return applyBranding(embed);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,11 +84,12 @@ export function createInfoEmbed(message: string, title: string = "Info"): EmbedB
|
||||
*/
|
||||
export function createBaseEmbed(title?: string, description?: string, color?: ColorResolvable): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTimestamp();
|
||||
.setTimestamp()
|
||||
.setColor(color ?? BRANDING.COLOR);
|
||||
|
||||
if (title) embed.setTitle(title);
|
||||
if (description) embed.setDescription(description);
|
||||
if (color) embed.setColor(color);
|
||||
|
||||
return embed;
|
||||
return applyBranding(embed);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
export class ApplicationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
}
|
||||
}
|
||||
|
||||
export class UserError extends ApplicationError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class SystemError extends ApplicationError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AutocompleteInteraction } from "discord.js";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
|
||||
|
||||
/**
|
||||
@@ -16,7 +17,7 @@ export class AutocompleteHandler {
|
||||
try {
|
||||
await command.autocomplete(interaction);
|
||||
} catch (error) {
|
||||
console.error(`Error handling autocomplete for ${interaction.commandName}:`, error);
|
||||
logger.error("bot", `Error handling autocomplete for ${interaction.commandName}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
|
||||
|
||||
/**
|
||||
@@ -13,7 +15,7 @@ export class CommandHandler {
|
||||
const command = AuroraClient.commands.get(interaction.commandName);
|
||||
|
||||
if (!command) {
|
||||
console.error(`No command matching ${interaction.commandName} was found.`);
|
||||
logger.error("bot", `No command matching ${interaction.commandName} was found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,18 +26,49 @@ export class CommandHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check beta feature access
|
||||
if (command.beta) {
|
||||
const flagName = command.featureFlag || interaction.commandName;
|
||||
let memberRoles: string[] = [];
|
||||
|
||||
if (interaction.member && 'roles' in interaction.member) {
|
||||
const roles = interaction.member.roles;
|
||||
if (typeof roles === 'object' && 'cache' in roles) {
|
||||
memberRoles = [...roles.cache.keys()];
|
||||
} else if (Array.isArray(roles)) {
|
||||
memberRoles = roles;
|
||||
}
|
||||
}
|
||||
|
||||
const hasAccess = await featureFlagsService.hasAccess(flagName, {
|
||||
guildId: interaction.guildId!,
|
||||
userId: interaction.user.id,
|
||||
memberRoles,
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
const errorEmbed = createErrorEmbed(
|
||||
"This feature is currently in beta testing and not available to all users. " +
|
||||
"Stay tuned for the official release!",
|
||||
"Beta Feature"
|
||||
);
|
||||
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure user exists in database
|
||||
try {
|
||||
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
} catch (error) {
|
||||
console.error("Failed to ensure user exists:", error);
|
||||
logger.error("bot", "Failed to ensure user exists", error);
|
||||
}
|
||||
|
||||
try {
|
||||
await command.execute(interaction);
|
||||
AuroraClient.lastCommandTimestamp = Date.now();
|
||||
} catch (error) {
|
||||
console.error(String(error));
|
||||
logger.error("bot", `Error executing command ${interaction.commandName}`, error);
|
||||
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
|
||||
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js";
|
||||
|
||||
import { UserError } from "@lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
|
||||
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
||||
|
||||
@@ -28,7 +29,7 @@ export class ComponentInteractionHandler {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.error(`Handler method ${route.method} not found in module`);
|
||||
logger.error("bot", `Handler method ${route.method} not found in module`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,7 +53,7 @@ export class ComponentInteractionHandler {
|
||||
|
||||
// Log system errors (non-user errors) for debugging
|
||||
if (!isUserError) {
|
||||
console.error(`Error in ${handlerName}:`, error);
|
||||
logger.error("bot", `Error in ${handlerName}`, error);
|
||||
}
|
||||
|
||||
const errorEmbed = createErrorEmbed(errorMessage);
|
||||
@@ -72,7 +73,7 @@ export class ComponentInteractionHandler {
|
||||
}
|
||||
} catch (replyError) {
|
||||
// If we can't send a reply, log it
|
||||
console.error(`Failed to send error response in ${handlerName}:`, replyError);
|
||||
logger.error("bot", `Failed to send error response in ${handlerName}`, replyError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export const renderWizard = (userId: string, isDraft = true) => {
|
||||
draft = {
|
||||
name: "New Item",
|
||||
description: "No description",
|
||||
rarity: "Common",
|
||||
rarity: "C",
|
||||
type: ItemType.MATERIAL,
|
||||
price: null,
|
||||
iconUrl: "",
|
||||
|
||||
@@ -87,7 +87,7 @@ export const getDetailsModal = (current: DraftItem) => {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("name").setLabel("Name").setValue(current.name).setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("desc").setLabel("Description").setValue(current.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("Common, Rare, Legendary...").setRequired(true))
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("C, R, SR, SSR").setRequired(true))
|
||||
);
|
||||
return modal;
|
||||
};
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
|
||||
export interface RestartContext {
|
||||
channelId: string;
|
||||
userId: string;
|
||||
timestamp: number;
|
||||
runMigrations: boolean;
|
||||
installDependencies: boolean;
|
||||
previousCommit: string;
|
||||
newCommit: string;
|
||||
}
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
needsRootInstall: boolean;
|
||||
needsWebInstall: boolean;
|
||||
needsMigrations: boolean;
|
||||
changedFiles: string[];
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
hasUpdates: boolean;
|
||||
branch: string;
|
||||
currentCommit: string;
|
||||
latestCommit: string;
|
||||
commitCount: number;
|
||||
commits: CommitInfo[];
|
||||
}
|
||||
|
||||
export interface CommitInfo {
|
||||
hash: string;
|
||||
message: string;
|
||||
author: string;
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js";
|
||||
import { createInfoEmbed, createSuccessEmbed, createWarningEmbed, createErrorEmbed } from "@lib/embeds";
|
||||
import type { UpdateInfo, UpdateCheckResult } from "./update.types";
|
||||
|
||||
// Constants for UI
|
||||
const LOG_TRUNCATE_LENGTH = 800;
|
||||
const OUTPUT_TRUNCATE_LENGTH = 400;
|
||||
|
||||
function truncate(text: string, maxLength: number): string {
|
||||
if (!text) return "";
|
||||
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
|
||||
}
|
||||
|
||||
// ============ Pre-Update Embeds ============
|
||||
|
||||
export function getCheckingEmbed() {
|
||||
return createInfoEmbed("🔍 Fetching latest changes from remote...", "Checking for Updates");
|
||||
}
|
||||
|
||||
export function getNoUpdatesEmbed(currentCommit: string) {
|
||||
return createSuccessEmbed(
|
||||
`You're running the latest version.\n\n**Current:** \`${currentCommit}\``,
|
||||
"✅ Already Up to Date"
|
||||
);
|
||||
}
|
||||
|
||||
export function getUpdatesAvailableMessage(
|
||||
updateInfo: UpdateInfo,
|
||||
requirements: UpdateCheckResult,
|
||||
changeCategories: Record<string, number>,
|
||||
force: boolean
|
||||
) {
|
||||
const { branch, currentCommit, latestCommit, commitCount, commits } = updateInfo;
|
||||
const { needsRootInstall, needsWebInstall, needsMigrations } = requirements;
|
||||
|
||||
// Build commit list (max 5)
|
||||
const commitList = commits
|
||||
.slice(0, 5)
|
||||
.map(c => `\`${c.hash}\` ${truncate(c.message, 50)}`)
|
||||
.join("\n");
|
||||
|
||||
const moreCommits = commitCount > 5 ? `\n*...and ${commitCount - 5} more*` : "";
|
||||
|
||||
// Build change categories
|
||||
const categoryList = Object.entries(changeCategories)
|
||||
.map(([cat, count]) => `• ${cat}: ${count} file${count > 1 ? "s" : ""}`)
|
||||
.join("\n");
|
||||
|
||||
// Build requirements list
|
||||
const reqs: string[] = [];
|
||||
if (needsRootInstall) reqs.push("📦 Install root dependencies");
|
||||
if (needsWebInstall) reqs.push("🌐 Install web dependencies");
|
||||
if (needsMigrations) reqs.push("🗃️ Run database migrations");
|
||||
if (reqs.length === 0) reqs.push("⚡ Quick update (no extra steps)");
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("📥 Updates Available")
|
||||
.setColor(force ? 0xFF6B6B : 0x5865F2)
|
||||
.addFields(
|
||||
{
|
||||
name: "Version",
|
||||
value: `\`${currentCommit}\` → \`${latestCommit}\``,
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: "Branch",
|
||||
value: `\`${branch}\``,
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: "Commits",
|
||||
value: `${commitCount} new commit${commitCount > 1 ? "s" : ""}`,
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: "Recent Changes",
|
||||
value: commitList + moreCommits || "No commits",
|
||||
inline: false
|
||||
},
|
||||
{
|
||||
name: "Files Changed",
|
||||
value: categoryList || "Unknown",
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: "Update Actions",
|
||||
value: reqs.join("\n"),
|
||||
inline: true
|
||||
}
|
||||
)
|
||||
.setFooter({ text: force ? "⚠️ Force mode enabled" : "This will restart the bot" })
|
||||
.setTimestamp();
|
||||
|
||||
const confirmButton = new ButtonBuilder()
|
||||
.setCustomId("confirm_update")
|
||||
.setLabel(force ? "Force Update" : "Update Now")
|
||||
.setEmoji(force ? "⚠️" : "🚀")
|
||||
.setStyle(force ? ButtonStyle.Danger : ButtonStyle.Success);
|
||||
|
||||
const cancelButton = new ButtonBuilder()
|
||||
.setCustomId("cancel_update")
|
||||
.setLabel("Cancel")
|
||||
.setStyle(ButtonStyle.Secondary);
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(confirmButton, cancelButton);
|
||||
|
||||
return { embeds: [embed], components: [row] };
|
||||
}
|
||||
|
||||
// ============ Update Progress Embeds ============
|
||||
|
||||
export function getPreparingEmbed() {
|
||||
return createInfoEmbed(
|
||||
"🔒 Saving rollback point...\n📥 Preparing to download updates...",
|
||||
"⏳ Preparing Update"
|
||||
);
|
||||
}
|
||||
|
||||
export function getUpdatingEmbed(requirements: UpdateCheckResult) {
|
||||
const steps: string[] = ["✅ Rollback point saved"];
|
||||
|
||||
steps.push("📥 Downloading updates...");
|
||||
|
||||
if (requirements.needsRootInstall || requirements.needsWebInstall) {
|
||||
steps.push("📦 Dependencies will be installed after restart");
|
||||
}
|
||||
if (requirements.needsMigrations) {
|
||||
steps.push("🗃️ Migrations will run after restart");
|
||||
}
|
||||
|
||||
steps.push("\n🔄 **Restarting now...**");
|
||||
|
||||
return createWarningEmbed(steps.join("\n"), "🚀 Updating");
|
||||
}
|
||||
|
||||
export function getCancelledEmbed() {
|
||||
return createInfoEmbed("Update cancelled. No changes were made.", "❌ Cancelled");
|
||||
}
|
||||
|
||||
export function getTimeoutEmbed() {
|
||||
return createWarningEmbed(
|
||||
"No response received within 30 seconds.\nRun `/update` again when ready.",
|
||||
"⏰ Timed Out"
|
||||
);
|
||||
}
|
||||
|
||||
export function getErrorEmbed(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return createErrorEmbed(
|
||||
`The update could not be completed:\n\`\`\`\n${truncate(message, 500)}\n\`\`\``,
|
||||
"❌ Update Failed"
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Post-Restart Embeds ============
|
||||
|
||||
export interface PostRestartResult {
|
||||
installSuccess: boolean;
|
||||
installOutput: string;
|
||||
migrationSuccess: boolean;
|
||||
migrationOutput: string;
|
||||
ranInstall: boolean;
|
||||
ranMigrations: boolean;
|
||||
previousCommit?: string;
|
||||
newCommit?: string;
|
||||
}
|
||||
|
||||
export function getPostRestartEmbed(result: PostRestartResult, hasRollback: boolean) {
|
||||
const isSuccess = result.installSuccess && result.migrationSuccess;
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(isSuccess ? "✅ Update Complete" : "⚠️ Update Completed with Issues")
|
||||
.setColor(isSuccess ? 0x57F287 : 0xFEE75C)
|
||||
.setTimestamp();
|
||||
|
||||
// Version info
|
||||
if (result.previousCommit && result.newCommit) {
|
||||
embed.addFields({
|
||||
name: "Version",
|
||||
value: `\`${result.previousCommit}\` → \`${result.newCommit}\``,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
// Results summary
|
||||
const results: string[] = [];
|
||||
|
||||
if (result.ranInstall) {
|
||||
results.push(result.installSuccess
|
||||
? "✅ Dependencies installed"
|
||||
: "❌ Dependency installation failed"
|
||||
);
|
||||
}
|
||||
|
||||
if (result.ranMigrations) {
|
||||
results.push(result.migrationSuccess
|
||||
? "✅ Migrations applied"
|
||||
: "❌ Migration failed"
|
||||
);
|
||||
}
|
||||
|
||||
if (results.length > 0) {
|
||||
embed.addFields({
|
||||
name: "Actions Performed",
|
||||
value: results.join("\n"),
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
// Output details (collapsed if too long)
|
||||
if (result.installOutput && !result.installSuccess) {
|
||||
embed.addFields({
|
||||
name: "Install Output",
|
||||
value: `\`\`\`\n${truncate(result.installOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
if (result.migrationOutput && !result.migrationSuccess) {
|
||||
embed.addFields({
|
||||
name: "Migration Output",
|
||||
value: `\`\`\`\n${truncate(result.migrationOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
// Footer with rollback hint
|
||||
if (!isSuccess && hasRollback) {
|
||||
embed.setFooter({ text: "💡 Use /update rollback to revert if needed" });
|
||||
}
|
||||
|
||||
// Build components
|
||||
const components: ActionRowBuilder<ButtonBuilder>[] = [];
|
||||
|
||||
if (!isSuccess && hasRollback) {
|
||||
const rollbackButton = new ButtonBuilder()
|
||||
.setCustomId("rollback_update")
|
||||
.setLabel("Rollback")
|
||||
.setEmoji("↩️")
|
||||
.setStyle(ButtonStyle.Danger);
|
||||
|
||||
components.push(new ActionRowBuilder<ButtonBuilder>().addComponents(rollbackButton));
|
||||
}
|
||||
|
||||
return { embeds: [embed], components };
|
||||
}
|
||||
|
||||
export function getInstallingDependenciesEmbed() {
|
||||
return createInfoEmbed(
|
||||
"📦 Installing dependencies for root and web projects...\nThis may take a moment.",
|
||||
"⏳ Installing Dependencies"
|
||||
);
|
||||
}
|
||||
|
||||
export function getRunningMigrationsEmbed() {
|
||||
return createInfoEmbed(
|
||||
"🗃️ Applying database migrations...",
|
||||
"⏳ Running Migrations"
|
||||
);
|
||||
}
|
||||
|
||||
export function getRollbackSuccessEmbed(commit: string) {
|
||||
return createSuccessEmbed(
|
||||
`Successfully rolled back to commit \`${commit}\`.\nThe bot will restart now.`,
|
||||
"↩️ Rollback Complete"
|
||||
);
|
||||
}
|
||||
|
||||
export function getRollbackFailedEmbed(error: string) {
|
||||
return createErrorEmbed(
|
||||
`Could not rollback:\n\`\`\`\n${error}\n\`\`\``,
|
||||
"❌ Rollback Failed"
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ButtonInteraction } from "discord.js";
|
||||
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { getLootdropClaimedMessage } from "./lootdrop.view";
|
||||
|
||||
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ButtonInteraction, MessageFlags } from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
export async function handleShopInteraction(interaction: ButtonInteraction) {
|
||||
if (!interaction.customId.startsWith("shop_buy_")) return;
|
||||
|
||||
@@ -1,20 +1,208 @@
|
||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
|
||||
import { createBaseEmbed } from "@/lib/embeds";
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
AttachmentBuilder,
|
||||
Colors,
|
||||
ContainerBuilder,
|
||||
SectionBuilder,
|
||||
TextDisplayBuilder,
|
||||
MediaGalleryBuilder,
|
||||
MediaGalleryItemBuilder,
|
||||
ThumbnailBuilder,
|
||||
SeparatorBuilder,
|
||||
SeparatorSpacingSize,
|
||||
MessageFlags
|
||||
} from "discord.js";
|
||||
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
|
||||
import { join } from "path";
|
||||
import { existsSync } from "fs";
|
||||
import { LootType, EffectType } from "@shared/lib/constants";
|
||||
import type { LootTableItem } from "@shared/lib/types";
|
||||
|
||||
export function getShopListingMessage(item: { id: number; name: string; description: string | null; formattedPrice: string; iconUrl: string | null; imageUrl: string | null; price: number | bigint }) {
|
||||
const embed = createBaseEmbed(`Shop: ${item.name}`, item.description || "No description available.", "Green")
|
||||
.addFields({ name: "Price", value: item.formattedPrice, inline: true })
|
||||
.setThumbnail(item.iconUrl || null)
|
||||
.setImage(item.imageUrl || null)
|
||||
.setFooter({ text: "Click the button below to purchase instantly." });
|
||||
// Rarity Color Map
|
||||
const RarityColors: Record<string, number> = {
|
||||
"C": Colors.LightGrey,
|
||||
"R": Colors.Blue,
|
||||
"SR": Colors.Purple,
|
||||
"SSR": Colors.Gold,
|
||||
"CURRENCY": Colors.Green,
|
||||
"XP": Colors.Aqua,
|
||||
"NOTHING": Colors.DarkButNotBlack
|
||||
};
|
||||
|
||||
const TitleMap: Record<string, string> = {
|
||||
"C": "📦 Common Items",
|
||||
"R": "📦 Rare Items",
|
||||
"SR": "✨ Super Rare Items",
|
||||
"SSR": "🌟 SSR Items",
|
||||
"CURRENCY": "💰 Currency",
|
||||
"XP": "🔮 Experience",
|
||||
"NOTHING": "💨 Empty"
|
||||
};
|
||||
|
||||
export function getShopListingMessage(
|
||||
item: {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
formattedPrice: string;
|
||||
iconUrl: string | null;
|
||||
imageUrl: string | null;
|
||||
price: number | bigint;
|
||||
usageData?: any;
|
||||
rarity?: string;
|
||||
},
|
||||
context?: { referencedItems: Map<number, { name: string; rarity: string }> }
|
||||
) {
|
||||
const files: AttachmentBuilder[] = [];
|
||||
let thumbnailUrl = resolveAssetUrl(item.iconUrl);
|
||||
let displayImageUrl = resolveAssetUrl(item.imageUrl);
|
||||
|
||||
// Handle local icon
|
||||
if (item.iconUrl && isLocalAssetUrl(item.iconUrl)) {
|
||||
const iconPath = join(process.cwd(), "bot/assets/graphics", item.iconUrl.replace(/^\/?assets\//, ""));
|
||||
if (existsSync(iconPath)) {
|
||||
const iconName = defaultName(item.iconUrl);
|
||||
files.push(new AttachmentBuilder(iconPath, { name: iconName }));
|
||||
thumbnailUrl = `attachment://${iconName}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle local image
|
||||
if (item.imageUrl && isLocalAssetUrl(item.imageUrl)) {
|
||||
if (item.imageUrl === item.iconUrl && thumbnailUrl?.startsWith("attachment://")) {
|
||||
displayImageUrl = thumbnailUrl;
|
||||
} else {
|
||||
const imagePath = join(process.cwd(), "bot/assets/graphics", item.imageUrl.replace(/^\/?assets\//, ""));
|
||||
if (existsSync(imagePath)) {
|
||||
const imageName = defaultName(item.imageUrl);
|
||||
if (!files.find(f => f.name === imageName)) {
|
||||
files.push(new AttachmentBuilder(imagePath, { name: imageName }));
|
||||
}
|
||||
displayImageUrl = `attachment://${imageName}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const containers: ContainerBuilder[] = [];
|
||||
|
||||
// 1. Main Container
|
||||
const mainContainer = new ContainerBuilder()
|
||||
.setAccentColor(RarityColors[item.rarity || "C"] || Colors.Green);
|
||||
|
||||
// Header Section
|
||||
const infoSection = new SectionBuilder()
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`# ${item.name}`),
|
||||
new TextDisplayBuilder().setContent(item.description || "_No description available._"),
|
||||
new TextDisplayBuilder().setContent(`### 🏷️ Price: ${item.formattedPrice}`)
|
||||
);
|
||||
|
||||
// Set Thumbnail Accessory if we have an icon
|
||||
if (thumbnailUrl) {
|
||||
infoSection.setThumbnailAccessory(new ThumbnailBuilder().setURL(thumbnailUrl));
|
||||
}
|
||||
|
||||
mainContainer.addSectionComponents(infoSection);
|
||||
|
||||
// Media Gallery for additional images (if multiple)
|
||||
const mediaSources: string[] = [];
|
||||
if (thumbnailUrl) mediaSources.push(thumbnailUrl);
|
||||
if (displayImageUrl && displayImageUrl !== thumbnailUrl) mediaSources.push(displayImageUrl);
|
||||
|
||||
if (mediaSources.length > 1) {
|
||||
mainContainer.addMediaGalleryComponents(
|
||||
new MediaGalleryBuilder().addItems(
|
||||
...mediaSources.map(src => new MediaGalleryItemBuilder().setURL(src))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Loot Table (if applicable)
|
||||
if (item.usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX)) {
|
||||
const lootboxEffect = item.usageData.effects.find((e: any) => e.type === EffectType.LOOTBOX);
|
||||
const pool = lootboxEffect.pool as LootTableItem[];
|
||||
const totalWeight = pool.reduce((sum, i) => sum + i.weight, 0);
|
||||
|
||||
mainContainer.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||
mainContainer.addTextDisplayComponents(new TextDisplayBuilder().setContent("## 🎁 Potential Rewards"));
|
||||
|
||||
const groups: Record<string, string[]> = {};
|
||||
for (const drop of pool) {
|
||||
const chance = ((drop.weight / totalWeight) * 100).toFixed(1);
|
||||
let line = "";
|
||||
let rarity = "C";
|
||||
|
||||
switch (drop.type as any) {
|
||||
case LootType.CURRENCY:
|
||||
const currAmount = (drop.minAmount != null && drop.maxAmount != null)
|
||||
? `${drop.minAmount} - ${drop.maxAmount}`
|
||||
: (Array.isArray(drop.amount) ? `${drop.amount[0]} - ${drop.amount[1]}` : drop.amount || 0);
|
||||
line = `**${currAmount} 🪙** (${chance}%)`;
|
||||
rarity = "CURRENCY";
|
||||
break;
|
||||
case LootType.XP:
|
||||
const xpAmount = (drop.minAmount != null && drop.maxAmount != null)
|
||||
? `${drop.minAmount} - ${drop.maxAmount}`
|
||||
: (Array.isArray(drop.amount) ? `${drop.amount[0]} - ${drop.amount[1]}` : drop.amount || 0);
|
||||
line = `**${xpAmount} XP** (${chance}%)`;
|
||||
rarity = "XP";
|
||||
break;
|
||||
case LootType.ITEM:
|
||||
const referencedItems = context?.referencedItems;
|
||||
if (drop.itemId && referencedItems?.has(drop.itemId)) {
|
||||
const i = referencedItems.get(drop.itemId)!;
|
||||
line = `**${i.name}** x${drop.amount || 1} (${chance}%)`;
|
||||
rarity = i.rarity;
|
||||
} else {
|
||||
line = `**Unknown Item** (${chance}%)`;
|
||||
rarity = "C";
|
||||
}
|
||||
break;
|
||||
case LootType.NOTHING:
|
||||
line = `**Nothing** (${chance}%)`;
|
||||
rarity = "NOTHING";
|
||||
break;
|
||||
}
|
||||
|
||||
if (line) {
|
||||
if (!groups[rarity]) groups[rarity] = [];
|
||||
groups[rarity]!.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
const order = ["SSR", "SR", "R", "C", "CURRENCY", "XP", "NOTHING"];
|
||||
for (const rarity of order) {
|
||||
if (groups[rarity] && groups[rarity]!.length > 0) {
|
||||
mainContainer.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`### ${TitleMap[rarity] || rarity}`),
|
||||
new TextDisplayBuilder().setContent(groups[rarity]!.join("\n"))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Purchase Row
|
||||
const buyButton = new ButtonBuilder()
|
||||
.setCustomId(`shop_buy_${item.id}`)
|
||||
.setLabel(`Buy for ${item.price} 🪙`)
|
||||
.setLabel(`Purchase for ${item.price} 🪙`)
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setEmoji("🛒");
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton);
|
||||
mainContainer.addActionRowComponents(
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton)
|
||||
);
|
||||
|
||||
return { embeds: [embed], components: [row] };
|
||||
containers.push(mainContainer);
|
||||
|
||||
return {
|
||||
components: containers as any,
|
||||
files,
|
||||
flags: MessageFlags.IsComponentsV2
|
||||
};
|
||||
}
|
||||
|
||||
function defaultName(path: string): string {
|
||||
return path.split("/").pop() || "image.png";
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Interaction } from "discord.js";
|
||||
import { TextChannel, MessageFlags } from "discord.js";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
|
||||
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
||||
// Handle select menu for choosing feedback type
|
||||
@@ -33,7 +33,13 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
||||
throw new UserError("An error occurred processing your feedback. Please try again.");
|
||||
}
|
||||
|
||||
if (!config.feedbackChannelId) {
|
||||
if (!interaction.guildId) {
|
||||
throw new UserError("This action can only be performed in a server.");
|
||||
}
|
||||
|
||||
const guildConfig = await getGuildConfig(interaction.guildId);
|
||||
|
||||
if (!guildConfig.feedbackChannelId) {
|
||||
throw new UserError("Feedback channel is not configured. Please contact an administrator.");
|
||||
}
|
||||
|
||||
@@ -52,7 +58,7 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
||||
};
|
||||
|
||||
// Get feedback channel
|
||||
const channel = await AuroraClient.channels.fetch(config.feedbackChannelId).catch(() => null) as TextChannel | null;
|
||||
const channel = await AuroraClient.channels.fetch(guildConfig.feedbackChannelId).catch(() => null) as TextChannel | null;
|
||||
|
||||
if (!channel) {
|
||||
throw new UserError("Feedback channel not found. Please contact an administrator.");
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { levelingService } from "@shared/modules/leveling/leveling.service";
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { userTimers } from "@db/schema";
|
||||
import type { EffectHandler } from "./types";
|
||||
import type { EffectHandler, ValidatedEffectPayload } from "./effect.types";
|
||||
import { EffectType } from "@shared/lib/constants";
|
||||
import type { LootTableItem } from "@shared/lib/types";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { inventory, items } from "@db/schema";
|
||||
@@ -15,21 +16,21 @@ const getDuration = (effect: any): number => {
|
||||
return effect.durationSeconds || 60; // Default to 60s if nothing provided
|
||||
};
|
||||
|
||||
export const handleAddXp: EffectHandler = async (userId, effect, txFn) => {
|
||||
export const handleAddXp: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.ADD_XP }>, txFn) => {
|
||||
await levelingService.addXp(userId, BigInt(effect.amount), txFn);
|
||||
return `Gained ${effect.amount} XP`;
|
||||
};
|
||||
|
||||
export const handleAddBalance: EffectHandler = async (userId, effect, txFn) => {
|
||||
export const handleAddBalance: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.ADD_BALANCE }>, txFn) => {
|
||||
await economyService.modifyUserBalance(userId, BigInt(effect.amount), TransactionType.ITEM_USE, `Used Item`, null, txFn);
|
||||
return `Gained ${effect.amount} 🪙`;
|
||||
};
|
||||
|
||||
export const handleReplyMessage: EffectHandler = async (_userId, effect, _txFn) => {
|
||||
export const handleReplyMessage: EffectHandler = async (_userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.REPLY_MESSAGE }>, _txFn) => {
|
||||
return effect.message;
|
||||
};
|
||||
|
||||
export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
|
||||
export const handleXpBoost: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.XP_BOOST }>, txFn) => {
|
||||
const boostDuration = getDuration(effect);
|
||||
const expiresAt = new Date(Date.now() + boostDuration * 1000);
|
||||
await txFn.insert(userTimers).values({
|
||||
@@ -45,7 +46,7 @@ export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
|
||||
return `XP Boost (${effect.multiplier}x) active for ${Math.floor(boostDuration / 60)}m`;
|
||||
};
|
||||
|
||||
export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
|
||||
export const handleTempRole: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.TEMP_ROLE }>, txFn) => {
|
||||
const roleDuration = getDuration(effect);
|
||||
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
|
||||
await txFn.insert(userTimers).values({
|
||||
@@ -62,11 +63,11 @@ export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
|
||||
return `Temporary Role granted for ${Math.floor(roleDuration / 60)}m`;
|
||||
};
|
||||
|
||||
export const handleColorRole: EffectHandler = async (_userId, _effect, _txFn) => {
|
||||
export const handleColorRole: EffectHandler = async (_userId, _effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.COLOR_ROLE }>, _txFn) => {
|
||||
return "Color Role Equipped";
|
||||
};
|
||||
|
||||
export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
||||
export const handleLootbox: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.LOOTBOX }>, txFn) => {
|
||||
const pool = effect.pool as LootTableItem[];
|
||||
if (!pool || pool.length === 0) return "The box is empty...";
|
||||
|
||||
@@ -86,7 +87,11 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
||||
|
||||
// Process Winner
|
||||
if (winner.type === LootType.NOTHING) {
|
||||
return winner.message || "You found nothing inside.";
|
||||
return {
|
||||
type: 'LOOTBOX_RESULT',
|
||||
rewardType: 'NOTHING',
|
||||
message: winner.message || "You found nothing inside."
|
||||
};
|
||||
}
|
||||
|
||||
if (winner.type === LootType.CURRENCY) {
|
||||
@@ -96,7 +101,12 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
||||
}
|
||||
if (amount > 0) {
|
||||
await economyService.modifyUserBalance(userId, BigInt(amount), TransactionType.LOOTBOX, 'Lootbox Reward', null, txFn);
|
||||
return winner.message || `You found ${amount} 🪙!`;
|
||||
return {
|
||||
type: 'LOOTBOX_RESULT',
|
||||
rewardType: 'CURRENCY',
|
||||
amount: amount,
|
||||
message: winner.message || `You found ${amount} 🪙!`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +117,12 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
||||
}
|
||||
if (amount > 0) {
|
||||
await levelingService.addXp(userId, BigInt(amount), txFn);
|
||||
return winner.message || `You gained ${amount} XP!`;
|
||||
return {
|
||||
type: 'LOOTBOX_RESULT',
|
||||
rewardType: 'XP',
|
||||
amount: amount,
|
||||
message: winner.message || `You gained ${amount} XP!`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +138,18 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
||||
where: (items: any, { eq }: any) => eq(items.id, winner.itemId!)
|
||||
});
|
||||
if (item) {
|
||||
return winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`;
|
||||
return {
|
||||
type: 'LOOTBOX_RESULT',
|
||||
rewardType: 'ITEM',
|
||||
amount: Number(quantity),
|
||||
item: {
|
||||
name: item.name,
|
||||
rarity: item.rarity,
|
||||
description: item.description,
|
||||
image: item.imageUrl || item.iconUrl
|
||||
},
|
||||
message: winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch item name for lootbox message", e);
|
||||
41
bot/modules/inventory/effect.registry.ts
Normal file
41
bot/modules/inventory/effect.registry.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
handleAddXp,
|
||||
handleAddBalance,
|
||||
handleReplyMessage,
|
||||
handleXpBoost,
|
||||
handleTempRole,
|
||||
handleColorRole,
|
||||
handleLootbox
|
||||
} from "./effect.handlers";
|
||||
import type { EffectHandler, ValidatedEffectPayload } from "./effect.types";
|
||||
import { EffectPayloadSchema } from "./effect.types";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import type { Transaction } from "@shared/lib/types";
|
||||
|
||||
export const effectHandlers: Record<string, EffectHandler> = {
|
||||
'ADD_XP': handleAddXp,
|
||||
'ADD_BALANCE': handleAddBalance,
|
||||
'REPLY_MESSAGE': handleReplyMessage,
|
||||
'XP_BOOST': handleXpBoost,
|
||||
'TEMP_ROLE': handleTempRole,
|
||||
'COLOR_ROLE': handleColorRole,
|
||||
'LOOTBOX': handleLootbox
|
||||
};
|
||||
|
||||
export async function validateAndExecuteEffect(
|
||||
effect: unknown,
|
||||
userId: string,
|
||||
tx: Transaction
|
||||
) {
|
||||
const result = EffectPayloadSchema.safeParse(effect);
|
||||
if (!result.success) {
|
||||
throw new UserError(`Invalid effect configuration: ${result.error.message}`);
|
||||
}
|
||||
|
||||
const handler = effectHandlers[result.data.type];
|
||||
if (!handler) {
|
||||
throw new UserError(`Unknown effect type: ${result.data.type}`);
|
||||
}
|
||||
|
||||
return handler(userId, result.data, tx);
|
||||
}
|
||||
71
bot/modules/inventory/effect.types.ts
Normal file
71
bot/modules/inventory/effect.types.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { Transaction } from "@shared/lib/types";
|
||||
import { z } from "zod";
|
||||
import { EffectType, LootType } from "@shared/lib/constants";
|
||||
|
||||
// Helper Schemas
|
||||
const LootTableItemSchema = z.object({
|
||||
type: z.nativeEnum(LootType),
|
||||
weight: z.number(),
|
||||
amount: z.number().optional(),
|
||||
itemId: z.number().optional(),
|
||||
minAmount: z.number().optional(),
|
||||
maxAmount: z.number().optional(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
const DurationSchema = z.object({
|
||||
durationSeconds: z.number().optional(),
|
||||
durationMinutes: z.number().optional(),
|
||||
durationHours: z.number().optional(),
|
||||
});
|
||||
|
||||
// Effect Schemas
|
||||
const AddXpSchema = z.object({
|
||||
type: z.literal(EffectType.ADD_XP),
|
||||
amount: z.number().positive(),
|
||||
});
|
||||
|
||||
const AddBalanceSchema = z.object({
|
||||
type: z.literal(EffectType.ADD_BALANCE),
|
||||
amount: z.number(),
|
||||
});
|
||||
|
||||
const ReplyMessageSchema = z.object({
|
||||
type: z.literal(EffectType.REPLY_MESSAGE),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
const XpBoostSchema = DurationSchema.extend({
|
||||
type: z.literal(EffectType.XP_BOOST),
|
||||
multiplier: z.number(),
|
||||
});
|
||||
|
||||
const TempRoleSchema = DurationSchema.extend({
|
||||
type: z.literal(EffectType.TEMP_ROLE),
|
||||
roleId: z.string(),
|
||||
});
|
||||
|
||||
const ColorRoleSchema = z.object({
|
||||
type: z.literal(EffectType.COLOR_ROLE),
|
||||
roleId: z.string(),
|
||||
});
|
||||
|
||||
const LootboxSchema = z.object({
|
||||
type: z.literal(EffectType.LOOTBOX),
|
||||
pool: z.array(LootTableItemSchema),
|
||||
});
|
||||
|
||||
// Union Schema
|
||||
export const EffectPayloadSchema = z.discriminatedUnion('type', [
|
||||
AddXpSchema,
|
||||
AddBalanceSchema,
|
||||
ReplyMessageSchema,
|
||||
XpBoostSchema,
|
||||
TempRoleSchema,
|
||||
ColorRoleSchema,
|
||||
LootboxSchema,
|
||||
]);
|
||||
|
||||
export type ValidatedEffectPayload = z.infer<typeof EffectPayloadSchema>;
|
||||
|
||||
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<any>;
|
||||
@@ -1,20 +0,0 @@
|
||||
import {
|
||||
handleAddXp,
|
||||
handleAddBalance,
|
||||
handleReplyMessage,
|
||||
handleXpBoost,
|
||||
handleTempRole,
|
||||
handleColorRole,
|
||||
handleLootbox
|
||||
} from "./handlers";
|
||||
import type { EffectHandler } from "./types";
|
||||
|
||||
export const effectHandlers: Record<string, EffectHandler> = {
|
||||
'ADD_XP': handleAddXp,
|
||||
'ADD_BALANCE': handleAddBalance,
|
||||
'REPLY_MESSAGE': handleReplyMessage,
|
||||
'XP_BOOST': handleXpBoost,
|
||||
'TEMP_ROLE': handleTempRole,
|
||||
'COLOR_ROLE': handleColorRole,
|
||||
'LOOTBOX': handleLootbox
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
|
||||
import type { Transaction } from "@shared/lib/types";
|
||||
|
||||
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<string>;
|
||||
@@ -1,6 +1,9 @@
|
||||
import { EmbedBuilder } from "discord.js";
|
||||
import { EmbedBuilder, AttachmentBuilder } from "discord.js";
|
||||
import type { ItemUsageData } from "@shared/lib/types";
|
||||
import { EffectType } from "@shared/lib/constants";
|
||||
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
|
||||
import { join } from "path";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
/**
|
||||
* Inventory entry with item details
|
||||
@@ -31,24 +34,107 @@ export function getInventoryEmbed(items: InventoryEntry[], username: string): Em
|
||||
/**
|
||||
* Creates an embed showing the results of using an item
|
||||
*/
|
||||
export function getItemUseResultEmbed(results: string[], item?: { name: string, iconUrl: string | null, usageData: any }): EmbedBuilder {
|
||||
const description = results.map(r => `• ${r}`).join("\n");
|
||||
export function getItemUseResultEmbed(results: any[], item?: { name: string, iconUrl: string | null, usageData: any }): { embed: EmbedBuilder, files: AttachmentBuilder[] } {
|
||||
const embed = new EmbedBuilder();
|
||||
const files: AttachmentBuilder[] = [];
|
||||
const otherMessages: string[] = [];
|
||||
let lootResult: any = null;
|
||||
|
||||
// Check if it was a lootbox
|
||||
const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === EffectType.LOOTBOX);
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setDescription(description)
|
||||
.setColor(isLootbox ? 0xFFD700 : 0x2ecc71); // Gold for lootbox, Green otherwise
|
||||
|
||||
if (isLootbox && item) {
|
||||
embed.setTitle(`🎁 ${item.name} Opened!`);
|
||||
if (item.iconUrl) {
|
||||
embed.setThumbnail(item.iconUrl);
|
||||
for (const res of results) {
|
||||
if (typeof res === 'object' && res.type === 'LOOTBOX_RESULT') {
|
||||
lootResult = res;
|
||||
} else {
|
||||
otherMessages.push(typeof res === 'string' ? `• ${res}` : `• ${JSON.stringify(res)}`);
|
||||
}
|
||||
} else {
|
||||
embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!");
|
||||
}
|
||||
|
||||
return embed;
|
||||
// Default Configuration
|
||||
const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === EffectType.LOOTBOX);
|
||||
embed.setColor(isLootbox ? 0xFFD700 : 0x2ecc71); // Gold for lootbox, Green otherwise by default
|
||||
embed.setTimestamp();
|
||||
|
||||
if (lootResult) {
|
||||
embed.setTitle(`🎁 ${item?.name || "Lootbox"} Opened!`);
|
||||
|
||||
if (lootResult.rewardType === 'ITEM' && lootResult.item) {
|
||||
const i = lootResult.item;
|
||||
const amountStr = lootResult.amount > 1 ? `x${lootResult.amount}` : '';
|
||||
|
||||
// Rarity Colors
|
||||
const rarityColors: Record<string, number> = {
|
||||
'C': 0x95A5A6, // Gray
|
||||
'R': 0x3498DB, // Blue
|
||||
'SR': 0x9B59B6, // Purple
|
||||
'SSR': 0xF1C40F // Gold
|
||||
};
|
||||
|
||||
const rarityKey = i.rarity || 'C';
|
||||
if (rarityKey in rarityColors) {
|
||||
embed.setColor(rarityColors[rarityKey] ?? 0x95A5A6);
|
||||
} else {
|
||||
embed.setColor(0x95A5A6);
|
||||
}
|
||||
|
||||
if (i.image) {
|
||||
if (isLocalAssetUrl(i.image)) {
|
||||
const imagePath = join(process.cwd(), "bot/assets/graphics", i.image.replace(/^\/?assets\//, ""));
|
||||
if (existsSync(imagePath)) {
|
||||
const imageName = defaultName(i.image);
|
||||
if (!files.find(f => f.name === imageName)) {
|
||||
files.push(new AttachmentBuilder(imagePath, { name: imageName }));
|
||||
}
|
||||
embed.setImage(`attachment://${imageName}`);
|
||||
}
|
||||
} else {
|
||||
const imgUrl = resolveAssetUrl(i.image);
|
||||
if (imgUrl) embed.setImage(imgUrl);
|
||||
}
|
||||
}
|
||||
|
||||
embed.setDescription(`**You found ${i.name} ${amountStr}!**\n${i.description || '_'}`);
|
||||
embed.addFields({ name: 'Rarity', value: rarityKey, inline: true });
|
||||
|
||||
} else if (lootResult.rewardType === 'CURRENCY') {
|
||||
embed.setColor(0xF1C40F);
|
||||
embed.setDescription(`**You found ${lootResult.amount.toLocaleString()} 🪙 AU!**`);
|
||||
} else if (lootResult.rewardType === 'XP') {
|
||||
embed.setColor(0x2ECC71); // Green
|
||||
embed.setDescription(`**You gained ${lootResult.amount.toLocaleString()} XP!**`);
|
||||
} else {
|
||||
// Nothing or Message
|
||||
embed.setDescription(lootResult.message);
|
||||
embed.setColor(0x95A5A6); // Gray
|
||||
}
|
||||
|
||||
} else {
|
||||
// Standard item usage
|
||||
embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!");
|
||||
embed.setDescription(otherMessages.join("\n") || "Effect applied.");
|
||||
|
||||
if (isLootbox && item && item.iconUrl) {
|
||||
if (isLocalAssetUrl(item.iconUrl)) {
|
||||
const iconPath = join(process.cwd(), "bot/assets/graphics", item.iconUrl.replace(/^\/?assets\//, ""));
|
||||
if (existsSync(iconPath)) {
|
||||
const iconName = defaultName(item.iconUrl);
|
||||
if (!files.find(f => f.name === iconName)) {
|
||||
files.push(new AttachmentBuilder(iconPath, { name: iconName }));
|
||||
}
|
||||
embed.setThumbnail(`attachment://${iconName}`);
|
||||
}
|
||||
} else {
|
||||
const resolvedIconUrl = resolveAssetUrl(item.iconUrl);
|
||||
if (resolvedIconUrl) embed.setThumbnail(resolvedIconUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (otherMessages.length > 0 && lootResult) {
|
||||
embed.addFields({ name: "Other Effects", value: otherMessages.join("\n") });
|
||||
}
|
||||
|
||||
return { embed, files };
|
||||
}
|
||||
|
||||
function defaultName(path: string): string {
|
||||
return path.split("/").pop() || "image.png";
|
||||
}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { EmbedBuilder } from "discord.js";
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
ContainerBuilder,
|
||||
TextDisplayBuilder,
|
||||
SeparatorBuilder,
|
||||
SeparatorSpacingSize,
|
||||
MessageFlags
|
||||
} from "discord.js";
|
||||
|
||||
/**
|
||||
* Quest entry with quest details and progress
|
||||
@@ -7,12 +16,33 @@ interface QuestEntry {
|
||||
progress: number | null;
|
||||
completedAt: Date | null;
|
||||
quest: {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
triggerEvent: string;
|
||||
requirements: any;
|
||||
rewards: any;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Available quest interface
|
||||
*/
|
||||
interface AvailableQuest {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
rewards: any;
|
||||
requirements: any;
|
||||
}
|
||||
|
||||
// Color palette for containers
|
||||
const COLORS = {
|
||||
ACTIVE: 0x3498db, // Blue - in progress
|
||||
AVAILABLE: 0x2ecc71, // Green - available
|
||||
COMPLETED: 0xf1c40f // Gold - completed
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats quest rewards object into a human-readable string
|
||||
*/
|
||||
@@ -20,35 +50,169 @@ function formatQuestRewards(rewards: { xp?: number, balance?: number }): string
|
||||
const rewardStr: string[] = [];
|
||||
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
|
||||
if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`);
|
||||
return rewardStr.join(", ");
|
||||
return rewardStr.join(" • ") || "None";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the quest status display string
|
||||
* Renders a simple progress bar
|
||||
*/
|
||||
function getQuestStatus(completedAt: Date | null): string {
|
||||
return completedAt ? "✅ Completed" : "📝 In Progress";
|
||||
function renderProgressBar(current: number, total: number, size: number = 10): string {
|
||||
const percentage = Math.min(current / total, 1);
|
||||
const progress = Math.round(size * percentage);
|
||||
const empty = size - progress;
|
||||
|
||||
const progressText = "▰".repeat(progress);
|
||||
const emptyText = "▱".repeat(empty);
|
||||
|
||||
return `${progressText}${emptyText} ${Math.round(percentage * 100)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an embed displaying a user's quest log
|
||||
* Creates Components v2 containers for the quest list (active quests only)
|
||||
*/
|
||||
export function getQuestListEmbed(userQuests: QuestEntry[]): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("📜 Quest Log")
|
||||
.setColor(0x3498db); // Blue
|
||||
export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuilder[] {
|
||||
// Filter to only show in-progress quests (not completed)
|
||||
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
|
||||
|
||||
const container = new ContainerBuilder()
|
||||
.setAccentColor(COLORS.ACTIVE)
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("# 📜 Quest Log"),
|
||||
new TextDisplayBuilder().setContent("-# Your active quests")
|
||||
);
|
||||
|
||||
if (activeQuests.length === 0) {
|
||||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||
container.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("*You have no active quests. Check available quests!*")
|
||||
);
|
||||
return [container];
|
||||
}
|
||||
|
||||
activeQuests.forEach((entry) => {
|
||||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||
|
||||
userQuests.forEach(entry => {
|
||||
const status = getQuestStatus(entry.completedAt);
|
||||
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
||||
const rewardsText = formatQuestRewards(rewards);
|
||||
|
||||
embed.addFields({
|
||||
name: `${entry.quest.name} (${status})`,
|
||||
value: `${entry.quest.description}\n**Rewards:** ${rewardsText}\n**Progress:** ${entry.progress}%`,
|
||||
inline: false
|
||||
});
|
||||
const requirements = entry.quest.requirements as { target?: number };
|
||||
const target = requirements?.target || 1;
|
||||
const progress = entry.progress || 0;
|
||||
const progressBar = renderProgressBar(progress, target);
|
||||
|
||||
container.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`**${entry.quest.name}**`),
|
||||
new TextDisplayBuilder().setContent(entry.quest.description || "*No description*"),
|
||||
new TextDisplayBuilder().setContent(`📊 ${progressBar} \`${progress}/${target}\` • 🎁 ${rewardsText}`)
|
||||
);
|
||||
});
|
||||
|
||||
return embed;
|
||||
return [container];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates Components v2 containers for available quests with inline accept buttons
|
||||
*/
|
||||
export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]): ContainerBuilder[] {
|
||||
const container = new ContainerBuilder()
|
||||
.setAccentColor(COLORS.AVAILABLE)
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("# 🗺️ Available Quests"),
|
||||
new TextDisplayBuilder().setContent("-# Quests you can accept")
|
||||
);
|
||||
|
||||
if (availableQuests.length === 0) {
|
||||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||
container.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("*No new quests available at the moment.*")
|
||||
);
|
||||
return [container];
|
||||
}
|
||||
|
||||
// Limit to 10 quests (5 action rows max with 2 added for navigation)
|
||||
const questsToShow = availableQuests.slice(0, 10);
|
||||
|
||||
questsToShow.forEach((quest) => {
|
||||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||
|
||||
const rewards = quest.rewards as { xp?: number, balance?: number };
|
||||
const rewardsText = formatQuestRewards(rewards);
|
||||
|
||||
const requirements = quest.requirements as { target?: number };
|
||||
const target = requirements?.target || 1;
|
||||
|
||||
container.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`**${quest.name}**`),
|
||||
new TextDisplayBuilder().setContent(quest.description || "*No description*"),
|
||||
new TextDisplayBuilder().setContent(`🎯 Goal: \`${target}\` • 🎁 ${rewardsText}`)
|
||||
);
|
||||
|
||||
// Add accept button inline within the container
|
||||
container.addActionRowComponents(
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(`quest_accept:${quest.id}`)
|
||||
.setLabel("Accept Quest")
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setEmoji("✅")
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return [container];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns action rows for navigation only
|
||||
*/
|
||||
export function getQuestActionRows(viewType: 'active' | 'available'): ActionRowBuilder<ButtonBuilder>[] {
|
||||
// Navigation row
|
||||
const navRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId("quest_view_active")
|
||||
.setLabel("📜 Active")
|
||||
.setStyle(viewType === 'active' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
||||
.setDisabled(viewType === 'active'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId("quest_view_available")
|
||||
.setLabel("🗺️ Available")
|
||||
.setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
||||
.setDisabled(viewType === 'available')
|
||||
);
|
||||
|
||||
return [navRow];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates Components v2 celebratory message for quest completion
|
||||
*/
|
||||
export function getQuestCompletionComponents(quest: any, rewards: { xp: bigint, balance: bigint }): ContainerBuilder[] {
|
||||
const rewardsText = formatQuestRewards({
|
||||
xp: Number(rewards.xp),
|
||||
balance: Number(rewards.balance)
|
||||
});
|
||||
|
||||
const container = new ContainerBuilder()
|
||||
.setAccentColor(COLORS.COMPLETED)
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("# 🎉 Quest Completed!"),
|
||||
new TextDisplayBuilder().setContent(`Congratulations! You've completed **${quest.name}**`)
|
||||
)
|
||||
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small))
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`📝 ${quest.description || "No description provided."}`),
|
||||
new TextDisplayBuilder().setContent(`🎁 **Rewards Earned:** ${rewardsText}`)
|
||||
);
|
||||
|
||||
return [container];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets MessageFlags and allowedMentions for Components v2 messages
|
||||
*/
|
||||
export function getComponentsV2MessageFlags() {
|
||||
return {
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
allowedMentions: { parse: [] as const }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { temporaryRoleService } from "@shared/modules/system/temp-role.service";
|
||||
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
||||
|
||||
export const schedulerService = {
|
||||
start: () => {
|
||||
@@ -10,7 +11,6 @@ export const schedulerService = {
|
||||
}, 60 * 1000);
|
||||
|
||||
// 2. Terminal Update Loop (every 60s)
|
||||
const { terminalService } = require("@shared/modules/terminal/terminal.service");
|
||||
setInterval(() => {
|
||||
terminalService.update();
|
||||
}, 60 * 1000);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user