forked from syntaxbullet/aurorabot
Compare commits
252 Commits
1cd3dbcd72
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
222f32d98f | ||
|
|
454ded8b26 | ||
|
|
9e85ba1fa4 | ||
|
|
2fb8d559a6 | ||
|
|
b0a103d8ce | ||
|
|
cb056e010f | ||
|
|
de15cb4206 | ||
|
|
f796cac6be | ||
|
|
31580df919 | ||
|
|
9a17209db2 | ||
|
|
04656790d2 | ||
|
|
25a0bd3431 | ||
|
|
6abbd4652a | ||
|
|
8369d10bab | ||
|
|
bdfe0d1594 | ||
|
|
034f2ead1c | ||
|
|
06c3891045 | ||
|
|
f09cbe6939 | ||
|
|
cd9e1e7242 | ||
|
|
966bad98d3 | ||
|
|
2b89fb7ede | ||
|
|
0fc88323ea | ||
|
|
96eba8270c | ||
|
|
a36c05994c | ||
|
|
ef78a85b9c | ||
|
|
f368da9e73 | ||
|
|
4f89ed3082 | ||
|
|
0d8152914a | ||
|
|
12809623c1 | ||
|
|
a29bb63a1d | ||
|
|
9e95194627 | ||
|
|
451fb206a6 | ||
|
|
e3c49effdb | ||
|
|
5c40249a18 | ||
|
|
b645f55f57 | ||
|
|
838fbe1b50 | ||
|
|
94d259e92a | ||
|
|
56db5bc998 | ||
|
|
ebac1ad6cc | ||
|
|
abca1922f2 | ||
|
|
e0dcfe6abe | ||
|
|
132f92d2d9 | ||
|
|
70a149ab82 | ||
|
|
26a0e532f6 | ||
|
|
e521d3086f | ||
|
|
9c4da51cfb | ||
|
|
24211dca14 | ||
|
|
87b66cd65d | ||
|
|
0dadc82f84 | ||
|
|
5527981fff | ||
|
|
9f105ada5e | ||
|
|
cac9fae142 | ||
|
|
b832723d6b | ||
|
|
0c3b289ba0 | ||
|
|
f4b36a745e | ||
|
|
3b53c9cb5f | ||
|
|
3bdb720e4a | ||
|
|
f290eeeb8a | ||
|
|
4b3f6590cc | ||
|
|
069c0b93ef | ||
|
|
33a1848096 | ||
|
|
55df982a0b | ||
|
|
eb7dfaf6f5 | ||
|
|
aa145592c5 | ||
|
|
37fa5fc3c8 | ||
|
|
db10ebe220 | ||
|
|
a5478dce2b | ||
|
|
29b6153777 | ||
|
|
d3e83bac66 | ||
|
|
40ae93f68b | ||
|
|
1e978dff58 | ||
|
|
3c256ba0b2 | ||
|
|
70d59a091a | ||
|
|
9569972cd6 | ||
|
|
5bd390b4ee | ||
|
|
5f8819bb46 | ||
|
|
b8cf136ff7 | ||
|
|
5188d86d61 | ||
|
|
6a1498813f | ||
|
|
e4f7c03005 | ||
|
|
38098a02ea | ||
|
|
fa09ef25e2 | ||
|
|
ba8afd144e | ||
|
|
8ef1873410 | ||
|
|
289044e26f | ||
|
|
47ea6d8620 | ||
|
|
21b5fedfc9 | ||
|
|
912ce5b942 | ||
|
|
4ead7e60b1 | ||
|
|
e64ffdc4cb | ||
|
|
1d601febcf | ||
|
|
3edda1d707 | ||
|
|
e56e133a69 | ||
|
|
0f871026eb | ||
|
|
782a138fd8 | ||
|
|
58d07a02fd | ||
|
|
01bb73f6a2 | ||
|
|
602147e961 | ||
|
|
9e6bb8b148 | ||
|
|
305a0b0553 | ||
|
|
023ff9fb1b | ||
|
|
56353a7756 | ||
|
|
86142cba6c | ||
|
|
0517cd638c | ||
|
|
b8303a7e28 | ||
|
|
d259c0c6a6 | ||
|
|
8b9ab2cd29 | ||
|
|
5d832c9601 | ||
|
|
968cc09c98 | ||
|
|
2bddab001a | ||
|
|
fc058effd5 | ||
|
|
3f99a77446 | ||
|
|
abe25e0ceb | ||
|
|
5a20ed23f4 | ||
|
|
0142508eb5 | ||
|
|
5863418ae9 | ||
|
|
a96c6caa49 | ||
|
|
22e446ff28 | ||
|
|
10c84a8478 | ||
|
|
9eba64621a | ||
|
|
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 | ||
|
|
3a620a84c5 | ||
|
|
7d68652ea5 | ||
|
|
35bd1f58dd |
@@ -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
|
||||
26
.env.example
26
.env.example
@@ -1,12 +1,34 @@
|
||||
# =============================================================================
|
||||
# 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
|
||||
SESSION_SECRET=change-me-to-a-random-string
|
||||
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"
|
||||
132
.gitea/workflows/ci-deploy.yml
Normal file
132
.gitea/workflows/ci-deploy.yml
Normal file
@@ -0,0 +1,132 @@
|
||||
name: CI / Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
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
|
||||
run: |
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
echo "$HOME/.bun/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- 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: Typecheck
|
||||
run: bunx tsc --noEmit
|
||||
|
||||
- name: Build Panel
|
||||
run: bun run panel:build
|
||||
|
||||
- name: Setup Test Database
|
||||
run: bun run db:push:local
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/aurora_test
|
||||
DISCORD_BOT_TOKEN: test_token
|
||||
DISCORD_CLIENT_ID: "123"
|
||||
DISCORD_GUILD_ID: "123"
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
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"
|
||||
DISCORD_CLIENT_SECRET="test-client-secret"
|
||||
SESSION_SECRET="test-session-secret"
|
||||
ADMIN_TOKEN="admin_token_123"
|
||||
LOG_LEVEL="error"
|
||||
EOF
|
||||
bash shared/scripts/test-isolated.sh --integration
|
||||
env:
|
||||
NODE_ENV: test
|
||||
|
||||
deploy:
|
||||
needs: test
|
||||
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Configure SSH
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||
run: |
|
||||
install -m 700 -d ~/.ssh
|
||||
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Deploy on VPS
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||
VPS_USER: ${{ secrets.VPS_USER }}
|
||||
VPS_PROJECT_PATH: ${{ secrets.VPS_PROJECT_PATH }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REMOTE_DIR="${VPS_PROJECT_PATH:-~/Aurora}"
|
||||
ssh -o BatchMode=yes "$VPS_USER@$VPS_HOST" "cd $REMOTE_DIR && bash shared/scripts/deploy.sh"
|
||||
|
||||
- name: Post-deploy Health Check
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||
VPS_USER: ${{ secrets.VPS_USER }}
|
||||
VPS_PROJECT_PATH: ${{ secrets.VPS_PROJECT_PATH }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REMOTE_DIR="${VPS_PROJECT_PATH:-~/Aurora}"
|
||||
ssh -o BatchMode=yes "$VPS_USER@$VPS_HOST" "cd $REMOTE_DIR && curl -fsS http://127.0.0.1:3000/api/health >/dev/null"
|
||||
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
|
||||
run: |
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
echo "$HOME/.bun/bin" >> $GITHUB_PATH
|
||||
|
||||
- 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 the isolated test runner / 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-isolated.sh --integration
|
||||
env:
|
||||
NODE_ENV: test
|
||||
9
.gitignore
vendored
9
.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,9 @@ 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
|
||||
.worktrees/
|
||||
.superpowers/
|
||||
|
||||
135
AGENTS.md
Normal file
135
AGENTS.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file documents the current implementation shape of the Aurora repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# App
|
||||
bun run dev # bot + API in one Bun process with watch mode
|
||||
docker compose up # app + db
|
||||
docker compose up app # app only
|
||||
docker compose up db # database only
|
||||
|
||||
# Testing
|
||||
bun test # Bun's native runner
|
||||
bun run test # repo test wrapper script
|
||||
bun run test:ci # include CI/integration path
|
||||
|
||||
# Database
|
||||
bun run db:push # drizzle-kit push via Docker
|
||||
bun run db:push:local # drizzle-kit push locally
|
||||
bun run db:generate # drizzle-kit generate via Docker
|
||||
bun run db:migrate # drizzle-kit migrate via Docker
|
||||
bun run db:studio # local Drizzle Studio on :4983
|
||||
|
||||
# Panel
|
||||
bun run panel:dev # Vite dev server on :5173
|
||||
bun run panel:build # build panel/dist
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Aurora is a single-process Bun application:
|
||||
|
||||
- `bot/index.ts` boots shared config, registers domain listeners, starts the API server, then logs into Discord.
|
||||
- `api/src/server.ts` hosts REST routes, WebSocket traffic, and built panel assets.
|
||||
- `shared/modules/*` contains the business logic used by both the bot and the API.
|
||||
- `shared/games/*` contains reusable game plugins; `api/src/games/*` runs rooms and WebSocket orchestration.
|
||||
|
||||
Current high-level layout:
|
||||
|
||||
```text
|
||||
bot/ Discord commands, events, views, interactions
|
||||
api/ Bun HTTP + WebSocket server
|
||||
panel/ React dashboard
|
||||
shared/db/ Drizzle client and schema
|
||||
shared/lib/ config, env, errors, logger, events, constants
|
||||
shared/modules/ domain services
|
||||
shared/games/ game plugins shared by API and panel
|
||||
```
|
||||
|
||||
## Import conventions
|
||||
|
||||
Use path aliases from the repo `tsconfig.json`:
|
||||
|
||||
- `@/*` -> `bot/*`
|
||||
- `@commands/*` -> `bot/commands/*`
|
||||
- `@db/*` -> `shared/db/*`
|
||||
- `@lib/*` -> `bot/lib/*`
|
||||
- `@modules/*` -> `bot/modules/*`
|
||||
- `@shared/*` -> `shared/*`
|
||||
|
||||
Import order in the repo is generally:
|
||||
|
||||
1. external packages
|
||||
2. aliases
|
||||
3. relative imports
|
||||
|
||||
## File patterns
|
||||
|
||||
- `*.service.ts`: domain/business logic, usually in `shared/modules/*`
|
||||
- `*.view.ts`: Discord message/view construction
|
||||
- `*.interaction.ts`: component interaction handlers
|
||||
- `*.types.ts`: local types and custom ID helpers
|
||||
- `*.handler.ts`: bot-side orchestration around services/views
|
||||
- `*.test.ts`: colocated tests
|
||||
|
||||
## Runtime config
|
||||
|
||||
- Global game settings live in `game_settings` and are loaded into `shared/lib/config.ts`.
|
||||
- Guild-specific settings live in `guild_settings`; `getGuildConfig()` adds a 60-second cache on top of DB reads.
|
||||
- Most numeric DB values exposed through runtime config are converted to `bigint` in `shared/lib/config.ts`.
|
||||
|
||||
## Interaction routing
|
||||
|
||||
Global component routing is defined in `bot/lib/interaction.routes.ts` and consumed by `ComponentInteractionHandler`.
|
||||
|
||||
Current route table:
|
||||
|
||||
- `trade_` and `amount` -> `bot/modules/trade/trade.interaction.ts`
|
||||
- `shop_buy_` -> `bot/modules/economy/shop.interaction.ts`
|
||||
- `lootdrop_` -> `bot/modules/economy/lootdrop.interaction.ts`
|
||||
- `trivia_` -> `bot/modules/trivia/trivia.interaction.ts`
|
||||
- `createitem_` -> `bot/modules/admin/item_wizard.ts`
|
||||
- `enrollment` -> `bot/modules/user/enrollment.interaction.ts`
|
||||
- `feedback_` -> `bot/modules/feedback/feedback.interaction.ts`
|
||||
|
||||
Some features still use local collectors instead of the global route table, notably inventory.
|
||||
|
||||
## Commands and access control
|
||||
|
||||
- Slash command execution is centralized in `bot/lib/handlers/CommandHandler.ts`.
|
||||
- `withCommandErrorHandling()` is the normal command wrapper for defer/reply/error behavior.
|
||||
- Beta commands rely on `featureFlagsService.hasAccess()`.
|
||||
- `ADMIN_USER_IDS` controls admin panel access, not Discord permissions inside command code.
|
||||
|
||||
## API and panel
|
||||
|
||||
- API routes are prefix-matched in `api/src/routes/index.ts`.
|
||||
- `/auth/*` and `/api/health` are public.
|
||||
- Players may access `/api/stats`, `/api/health`, `/api/me`, and `/api/me/inventory`.
|
||||
- Remaining `/api/*` routes are admin-only.
|
||||
- The panel dev server proxies back to the Bun server; the integrated server serves `panel/dist` when built.
|
||||
|
||||
## Database notes
|
||||
|
||||
- Docker Compose uses PostgreSQL 17.
|
||||
- Discord IDs and currency/xp values are stored as `bigint`.
|
||||
- `withTransaction()` lives in `bot/lib/db.ts` and is the normal way shared services compose DB work.
|
||||
|
||||
## Testing
|
||||
|
||||
- Tests use `bun:test`.
|
||||
- Mock modules before importing the unit under test.
|
||||
- Most service tests stub `DrizzleClient` or `withTransaction()` rather than hitting the real database.
|
||||
|
||||
## Key entrypoints
|
||||
|
||||
- `bot/index.ts`
|
||||
- `bot/lib/BotClient.ts`
|
||||
- `api/src/server.ts`
|
||||
- `api/src/routes/index.ts`
|
||||
- `shared/lib/config.ts`
|
||||
- `shared/db/DrizzleClient.ts`
|
||||
- `shared/db/schema/index.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"]
|
||||
|
||||
271
README.md
271
README.md
@@ -1,160 +1,181 @@
|
||||
# Aurora
|
||||
|
||||
> A comprehensive, feature-rich Discord RPG bot built with modern technologies.
|
||||
Aurora is a Discord RPG bot, admin/player panel, and REST/WebSocket API that run as one Bun application. The Discord bot and HTTP server share the same database client, config, services, and domain events.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
## What exists today
|
||||
|
||||
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.
|
||||
- Discord slash commands for economy, inventory, quests, moderation, feedback, user profiles, and admin tooling.
|
||||
- A Bun HTTP API under `/api/*`, Discord OAuth under `/auth/*`, and a WebSocket endpoint at `/ws`.
|
||||
- A React panel for both admins and enrolled players.
|
||||
- Shared domain services in `shared/modules/*` and reusable game plugins in `shared/games/*`.
|
||||
- Built-in real-time games: chess and blackjack.
|
||||
|
||||
**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.
|
||||
## Architecture
|
||||
|
||||
## ✨ Features
|
||||
```text
|
||||
bot/ Discord bot entrypoint, commands, events, Discord-facing views/interactions
|
||||
api/ Bun HTTP server, route modules, WebSocket/game room server
|
||||
panel/ React 19 + Vite + Tailwind v4 dashboard
|
||||
shared/ Shared DB schema, services, config, events, utilities, game plugins
|
||||
docs/ Product and design notes
|
||||
```
|
||||
|
||||
### Discord Bot
|
||||
* **Class System**: Users can join different classes.
|
||||
* **Economy**: Complete economy system with balance, transactions, and daily rewards.
|
||||
* **Inventory & Items**: Sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management.
|
||||
* **Leveling**: XP-based leveling system to track user activity and progress.
|
||||
* **Quests**: Quest system with requirements and rewards.
|
||||
* **Trading**: Secure trading system between users.
|
||||
* **Lootdrops**: Random loot drops in channels to engage users.
|
||||
* **Admin Tools**: Administrative commands for server management.
|
||||
Important points:
|
||||
|
||||
### Web Dashboard
|
||||
* **Live Analytics**: View real-time activity charts (commands, transactions).
|
||||
* **Configuration Management**: Update bot settings without restarting.
|
||||
* **Database Inspection**: Integrated Drizzle Studio access.
|
||||
* **State Monitoring**: View internal bot state (Lootdrops, etc.).
|
||||
- `bot/index.ts` initializes DB-backed config, wires domain events, starts the API server, then logs into Discord.
|
||||
- The API server also serves built panel assets from `panel/dist` when they exist.
|
||||
- Bot commands, API routes, and the panel all rely on the same service layer in `shared/modules/*`.
|
||||
- Runtime game config is loaded from the `game_settings` table into `shared/lib/config.ts`.
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
Aurora uses a **Single Process Monolith** architecture to maximize performance and simplify resource sharing.
|
||||
|
||||
* **Unified Runtime**: Both the Discord Client and the Web Dashboard run within the same Bun process.
|
||||
* **Shared State**: This allows the Dashboard to access live bot memory (caches, gateways) directly without complex inter-process communication (IPC).
|
||||
* **Simplified Deployment**: You only need to deploy a single Docker container.
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
* **Runtime**: [Bun](https://bun.sh/)
|
||||
* **Bot Framework**: [Discord.js](https://discord.js.org/)
|
||||
* **Web Framework**: [React 19](https://react.dev/) + [Vite](https://vitejs.dev/) (served via Bun)
|
||||
* **Styling**: [Tailwind CSS v4](https://tailwindcss.com/) + [Radix UI](https://www.radix-ui.com/)
|
||||
* **Database**: [PostgreSQL](https://www.postgresql.org/)
|
||||
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
||||
* **Validation**: [Zod](https://zod.dev/)
|
||||
* **Containerization**: [Docker](https://www.docker.com/)
|
||||
|
||||
## 🚀 Getting Started
|
||||
## Getting started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
* [Bun](https://bun.sh/) (latest version)
|
||||
* [Docker](https://www.docker.com/) & Docker Compose
|
||||
- Bun
|
||||
- Docker and Docker Compose
|
||||
- A Discord application with bot token, client ID, and client secret
|
||||
|
||||
### Installation
|
||||
### Setup
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd aurora
|
||||
```
|
||||
1. Install dependencies.
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
3. **Environment Setup**
|
||||
Copy the example environment file and configure it:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
Edit `.env` with your Discord bot token, Client ID, and database credentials.
|
||||
2. Create your environment file.
|
||||
|
||||
> **Note**: The `DATABASE_URL` in `.env.example` is pre-configured for Docker.
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
4. **Start the Database**
|
||||
Run the database service using Docker Compose:
|
||||
```bash
|
||||
docker compose up -d db
|
||||
```
|
||||
3. Start PostgreSQL.
|
||||
|
||||
5. **Run Migrations**
|
||||
```bash
|
||||
bun run migrate
|
||||
```
|
||||
OR
|
||||
```bash
|
||||
bun run db:push
|
||||
```
|
||||
```bash
|
||||
docker compose up -d db
|
||||
```
|
||||
|
||||
### Running the Bot & Dashboard
|
||||
4. Initialize the schema.
|
||||
|
||||
```bash
|
||||
bun run db:push:local
|
||||
```
|
||||
|
||||
If you prefer running schema changes through Docker:
|
||||
|
||||
```bash
|
||||
bun run migrate
|
||||
```
|
||||
|
||||
5. Start the bot and API.
|
||||
|
||||
**Development Mode** (with hot reload):
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
* Bot: Online in Discord
|
||||
* Dashboard: http://localhost:3000
|
||||
|
||||
**Production Mode**:
|
||||
Build and run with Docker (recommended):
|
||||
The Bun server listens on `http://localhost:3000`.
|
||||
|
||||
### Panel development
|
||||
|
||||
The Bun server can serve a built panel, but day-to-day panel work is done with Vite:
|
||||
|
||||
```bash
|
||||
docker compose up -d app
|
||||
bun run panel:dev
|
||||
```
|
||||
|
||||
### 🔐 Accessing Production Services (SSH Tunnel)
|
||||
The panel dev server runs on `http://localhost:5173` and proxies `/api`, `/auth`, `/assets`, and `/ws` to `http://localhost:3000`.
|
||||
|
||||
For security, the Production Database and Dashboard are **not exposed** to the public internet by default. They are only accessible via localhost on the server.
|
||||
To build the panel for the integrated Bun server:
|
||||
|
||||
To access them from your local machine, use the included SSH tunnel script.
|
||||
|
||||
1. Add your VPS details to your local `.env` file:
|
||||
```env
|
||||
VPS_USER=root
|
||||
VPS_HOST=123.45.67.89
|
||||
```
|
||||
|
||||
2. Run the remote connection script:
|
||||
```bash
|
||||
bun run remote
|
||||
```
|
||||
|
||||
This will establish secure tunnels for:
|
||||
* **Dashboard**: http://localhost:3000
|
||||
* **Drizzle Studio**: http://localhost:4983
|
||||
|
||||
## 📜 Scripts
|
||||
|
||||
* `bun run dev`: Start the bot and dashboard in watch mode.
|
||||
* `bun run remote`: Open SSH tunnel to production services.
|
||||
* `bun run generate`: Generate Drizzle migrations.
|
||||
* `bun run migrate`: Apply migrations (via Docker).
|
||||
* `bun run db:studio`: Open Drizzle Studio to inspect the database.
|
||||
* `bun test`: Run tests.
|
||||
|
||||
## 📂 Project Structure
|
||||
|
||||
```
|
||||
├── bot # Discord Bot logic & entry point
|
||||
├── web # React Web Dashboard (Frontend + Server)
|
||||
├── shared # Shared code (Database, Config, Types)
|
||||
├── drizzle # Drizzle migration files
|
||||
├── scripts # Utility scripts
|
||||
├── docker-compose.yml
|
||||
└── package.json
|
||||
```bash
|
||||
bun run panel:build
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
## Useful scripts
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
```bash
|
||||
# App
|
||||
bun run dev
|
||||
docker compose up
|
||||
docker compose up app
|
||||
docker compose up db
|
||||
|
||||
## 📄 License
|
||||
# Database
|
||||
bun run db:push
|
||||
bun run db:push:local
|
||||
bun run db:generate
|
||||
bun run db:migrate
|
||||
bun run db:studio
|
||||
bun run db:backup
|
||||
bun run db:restore
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
# Panel
|
||||
bun run panel:dev
|
||||
bun run panel:build
|
||||
|
||||
# Tests
|
||||
bun test
|
||||
bun run test
|
||||
bun run test:ci
|
||||
|
||||
# Ops
|
||||
bun run remote
|
||||
bun run deploy
|
||||
bun run deploy:remote
|
||||
```
|
||||
|
||||
## Environment notes
|
||||
|
||||
The main variables you need in `.env` are:
|
||||
|
||||
- `DISCORD_BOT_TOKEN`
|
||||
- `DISCORD_CLIENT_ID`
|
||||
- `DISCORD_CLIENT_SECRET`
|
||||
- `DISCORD_GUILD_ID`
|
||||
- `ADMIN_USER_IDS`
|
||||
- `SESSION_SECRET`
|
||||
- `DB_USER`
|
||||
- `DB_PASSWORD`
|
||||
- `DB_NAME`
|
||||
- `DATABASE_URL`
|
||||
- `PANEL_BASE_URL`
|
||||
|
||||
Players can authenticate into the panel only after they exist in the `users` table. Admin access is determined by `ADMIN_USER_IDS`, and panel sessions are stored in signed cookies keyed by `SESSION_SECRET`.
|
||||
|
||||
## API and panel summary
|
||||
|
||||
- Public routes: `/auth/*`, `/api/health`
|
||||
- Player-accessible API routes: `/api/stats`, `/api/health`, `/api/me`, `/api/me/inventory`
|
||||
- Admin-only API routes: the rest of `/api/*`
|
||||
- WebSocket: `/ws` with cookie-based auth
|
||||
- Static assets: `/assets/*`
|
||||
|
||||
## Project structure
|
||||
|
||||
```text
|
||||
bot/
|
||||
commands/
|
||||
events/
|
||||
lib/
|
||||
modules/
|
||||
|
||||
api/
|
||||
src/
|
||||
routes/
|
||||
games/
|
||||
|
||||
panel/
|
||||
src/
|
||||
|
||||
shared/
|
||||
db/
|
||||
games/
|
||||
lib/
|
||||
modules/
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [AGENTS.md](AGENTS.md): repo-wide implementation guidance
|
||||
- [api/README.md](api/README.md): API surface and auth model
|
||||
- [docs/new-design/DESIGN.md](docs/new-design/DESIGN.md): current panel design language
|
||||
|
||||
0
web/.gitignore → api/.gitignore
vendored
0
web/.gitignore → api/.gitignore
vendored
130
api/README.md
Normal file
130
api/README.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Aurora API
|
||||
|
||||
Aurora's API is a Bun server that runs inside the same process as the Discord bot. It serves REST routes, the authenticated WebSocket endpoint, static assets, and built panel files.
|
||||
|
||||
## Runtime model
|
||||
|
||||
- Entry point: `api/src/server.ts`
|
||||
- Route dispatcher: `api/src/routes/index.ts`
|
||||
- Auth: Discord OAuth with signed session cookies
|
||||
- WebSocket: `/ws`
|
||||
- Static assets: `/assets/*`
|
||||
- Built panel fallback: `panel/dist`
|
||||
|
||||
## Access model
|
||||
|
||||
Public:
|
||||
|
||||
- `GET /api/health`
|
||||
- `/auth/discord`
|
||||
- `/auth/callback`
|
||||
- `POST /auth/logout`
|
||||
- `GET /auth/me`
|
||||
|
||||
Player-accessible API routes:
|
||||
|
||||
- `GET /api/stats`
|
||||
- `GET /api/health`
|
||||
- `GET /api/me`
|
||||
- `GET /api/me/inventory`
|
||||
|
||||
Admin-only API routes:
|
||||
|
||||
- everything else under `/api/*`
|
||||
|
||||
Admin vs player is derived from `ADMIN_USER_IDS`. A user must already exist in the `users` table to complete panel login.
|
||||
|
||||
## Route summary
|
||||
|
||||
### Auth
|
||||
|
||||
- `GET /auth/discord`
|
||||
- `GET /auth/callback`
|
||||
- `POST /auth/logout`
|
||||
- `GET /auth/me`
|
||||
|
||||
### Dashboard and system
|
||||
|
||||
- `GET /api/health`
|
||||
- `GET /api/stats`
|
||||
- `GET /api/stats/activity`
|
||||
- `POST /api/actions/reload-commands`
|
||||
- `POST /api/actions/clear-cache`
|
||||
- `POST /api/actions/maintenance-mode`
|
||||
|
||||
### Settings
|
||||
|
||||
- `GET /api/settings`
|
||||
- `POST /api/settings`
|
||||
- `GET /api/settings/meta`
|
||||
- `GET /api/guilds/:guildId/settings`
|
||||
- `PUT|PATCH /api/guilds/:guildId/settings`
|
||||
- `DELETE /api/guilds/:guildId/settings`
|
||||
|
||||
### Users, classes, and inventory
|
||||
|
||||
- `GET /api/me`
|
||||
- `GET /api/me/inventory`
|
||||
- `GET /api/users`
|
||||
- `GET /api/users/:id`
|
||||
- `PUT /api/users/:id`
|
||||
- `GET /api/users/:id/inventory`
|
||||
- `POST /api/users/:id/inventory`
|
||||
- `DELETE /api/users/:id/inventory/:itemId`
|
||||
- `GET /api/classes`
|
||||
- `POST /api/classes`
|
||||
- `PUT /api/classes/:id`
|
||||
- `DELETE /api/classes/:id`
|
||||
|
||||
### Game content
|
||||
|
||||
- `GET /api/items`
|
||||
- `POST /api/items`
|
||||
- `GET /api/items/:id`
|
||||
- `PUT /api/items/:id`
|
||||
- `DELETE /api/items/:id`
|
||||
- `POST /api/items/:id/icon`
|
||||
- `GET /api/quests`
|
||||
- `POST /api/quests`
|
||||
- `PUT /api/quests/:id`
|
||||
- `DELETE /api/quests/:id`
|
||||
- `GET /api/lootdrops`
|
||||
- `POST /api/lootdrops`
|
||||
- `DELETE /api/lootdrops/:messageId`
|
||||
|
||||
### Moderation and economy history
|
||||
|
||||
- `GET /api/moderation`
|
||||
- `POST /api/moderation`
|
||||
- `GET /api/transactions`
|
||||
|
||||
## WebSocket
|
||||
|
||||
`/ws` requires a valid `aurora_session` cookie.
|
||||
|
||||
Current behavior:
|
||||
|
||||
- dashboard clients subscribe to `dashboard`
|
||||
- game clients also use lobby and room-scoped traffic through `GameServer`
|
||||
- `PING` from the client returns `PONG`
|
||||
- dashboard stats are broadcast every 5 seconds while at least one client is connected
|
||||
- hard limits in `api/src/server.ts`:
|
||||
- 200 concurrent connections
|
||||
- 16 KB max payload
|
||||
- 60 second idle timeout
|
||||
|
||||
## Development
|
||||
|
||||
Start the backend:
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Optional panel dev server:
|
||||
|
||||
```bash
|
||||
bun run panel:dev
|
||||
```
|
||||
|
||||
Panel dev runs on `http://localhost:5173` and proxies API/auth/assets/WebSocket requests to `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
68
api/src/AGENTS.md
Normal file
68
api/src/AGENTS.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# API layer
|
||||
|
||||
## Server shape
|
||||
|
||||
- Aurora uses Bun's native `serve()` API in `api/src/server.ts`.
|
||||
- Route modules are aggregated in `api/src/routes/index.ts`.
|
||||
- A route module returns `null` when it does not match so the dispatcher can continue.
|
||||
- After route handling, the server tries `panel/dist` for SPA/static files.
|
||||
|
||||
## Authentication and authorization
|
||||
|
||||
- OAuth routes live in `api/src/routes/auth.routes.ts`.
|
||||
- Sessions are stored in signed `aurora_session` cookies.
|
||||
- Session TTL is 7 days.
|
||||
- Login succeeds only for users already present in the `users` table.
|
||||
- Role is `admin` if the Discord ID is in `ADMIN_USER_IDS`, otherwise `player`.
|
||||
- Redirects after login are intentionally restricted to localhost or relative paths.
|
||||
|
||||
Current access rules from `api/src/routes/index.ts`:
|
||||
|
||||
- public: `/auth/*`, `/api/health`
|
||||
- player allow-list: `/api/stats`, `/api/health`, `/api/me`
|
||||
- everything else under `/api/*`: admin-only
|
||||
|
||||
`/api/me/inventory` is handled by `users.routes.ts` and still depends on a valid session.
|
||||
|
||||
## Response conventions
|
||||
|
||||
- `jsonResponse()` serializes `bigint` values as strings.
|
||||
- `errorResponse()` returns `{ error, details? }`.
|
||||
- `parseBody()` and `parseQuery()` validate with Zod and return a `Response` on failure.
|
||||
- The API does not use a framework-level middleware stack; each route handles its own parsing and branching.
|
||||
|
||||
## WebSocket
|
||||
|
||||
- Endpoint: `/ws`
|
||||
- Requires an authenticated session
|
||||
- Dashboard channel: `dashboard`
|
||||
- Lobby channel: `lobby`
|
||||
- Room-specific messaging is handled inside `GameServer`
|
||||
- Dashboard broadcasts `STATS_UPDATE` every 5 seconds while clients are connected
|
||||
- `NEW_EVENT` broadcasts are wired from `shared/lib/events`
|
||||
|
||||
Hard limits:
|
||||
|
||||
- max connections: 200
|
||||
- max payload: 16 KB
|
||||
- idle timeout: 60 seconds
|
||||
|
||||
## Static files
|
||||
|
||||
- Built panel assets are served from `panel/dist`
|
||||
- `/assets/*` serves files from `bot/assets/graphics`
|
||||
- `/api/*`, `/auth/*`, `/ws`, and `/assets/*` bypass the SPA fallback
|
||||
|
||||
## Route notes
|
||||
|
||||
- `items.routes.ts` supports both JSON and multipart form data for item creation.
|
||||
- `settings.routes.ts` writes DB-backed game settings and emits the reload-commands event.
|
||||
- `guild-settings.routes.ts` invalidates the guild config cache after writes.
|
||||
- `lootdrops.routes.ts` delegates spawning/deletion to bot-side handlers because Discord message creation happens there.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Some runtime caches are in-memory only and are lost on restart.
|
||||
- The server registers game plugins at startup; duplicate registration throws.
|
||||
- BigInt-safe JSON matters for nearly every domain route.
|
||||
- The panel's auth flow depends on `PANEL_BASE_URL` matching the OAuth callback origin.
|
||||
540
api/src/games/GameServer.ts
Normal file
540
api/src/games/GameServer.ts
Normal file
@@ -0,0 +1,540 @@
|
||||
import { RoomManager } from "./RoomManager";
|
||||
import { GameWsClientSchema } from "./types";
|
||||
import type { PlayerInfo } from "./types";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { TransactionType } from "@shared/lib/constants";
|
||||
import { gameRegistry } from "@shared/games/registry";
|
||||
import type { Server, ServerWebSocket } from "bun";
|
||||
|
||||
export interface WsConnectionData {
|
||||
session: { discordId: string; username: string; role: string };
|
||||
rooms: Set<string>;
|
||||
}
|
||||
|
||||
export class GameServer {
|
||||
readonly roomManager = new RoomManager();
|
||||
private connections = new Map<string, ServerWebSocket<WsConnectionData>>();
|
||||
private replacedConnections = new Map<string, ServerWebSocket<WsConnectionData>>();
|
||||
private bunServer: Server<WsConnectionData> | null = null;
|
||||
|
||||
constructor() {
|
||||
// Subscribe to room events and route them to the right clients
|
||||
|
||||
this.roomManager.emitter.on("room:created", ({ roomId, gameSlug }) => {
|
||||
// The creating connection will subscribe itself; just broadcast room list
|
||||
this.publishRoomListUpdate();
|
||||
});
|
||||
|
||||
this.roomManager.emitter.on("game:started", ({ roomId, spectatorView, playerViews }) => {
|
||||
// Send personalised state to each player
|
||||
for (const [playerId, view] of playerViews) {
|
||||
this.sendToPlayer(playerId, {
|
||||
type: "GAME_STATE",
|
||||
roomId,
|
||||
state: view,
|
||||
});
|
||||
}
|
||||
// Broadcast started event with spectator view to the room channel
|
||||
this.publish(`room:${roomId}`, {
|
||||
type: "GAME_STARTED",
|
||||
roomId,
|
||||
state: spectatorView,
|
||||
});
|
||||
});
|
||||
|
||||
this.roomManager.emitter.on("game:updated", ({ roomId, spectatorView, playerViews }) => {
|
||||
// Each player gets their personalised view directly
|
||||
for (const [playerId, view] of playerViews) {
|
||||
this.sendToPlayer(playerId, {
|
||||
type: "GAME_STATE",
|
||||
roomId,
|
||||
state: view,
|
||||
});
|
||||
}
|
||||
// Spectators/others get the spectator view via pub/sub
|
||||
this.publish(`room:${roomId}`, {
|
||||
type: "GAME_UPDATE",
|
||||
roomId,
|
||||
state: spectatorView,
|
||||
});
|
||||
});
|
||||
|
||||
this.roomManager.emitter.on("game:ended", ({ roomId, winner, reason, payouts }) => {
|
||||
const room = this.roomManager.getRoom(roomId);
|
||||
const betAmount = room?.betAmount ?? 0;
|
||||
|
||||
// Handle bet payouts asynchronously — broadcast happens after settlement
|
||||
if (betAmount > 0) {
|
||||
this.settleBets(roomId, winner, betAmount, payouts).then((payout) => {
|
||||
this.publish(`room:${roomId}`, {
|
||||
type: "GAME_ENDED",
|
||||
roomId,
|
||||
winner,
|
||||
reason,
|
||||
payout,
|
||||
});
|
||||
this.publishRoomListUpdate();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.publish(`room:${roomId}`, {
|
||||
type: "GAME_ENDED",
|
||||
roomId,
|
||||
winner,
|
||||
reason,
|
||||
});
|
||||
this.publishRoomListUpdate();
|
||||
});
|
||||
|
||||
this.roomManager.emitter.on("round:settled", async ({ roomId, roundSettlements }) => {
|
||||
const room = this.roomManager.getRoom(roomId);
|
||||
if (!room || room.betAmount <= 0) return;
|
||||
const gameName = gameRegistry.get(room.gameSlug)?.name ?? "Game";
|
||||
const settlementDetails: typeof roundSettlements = {};
|
||||
|
||||
for (const [playerId, settlement] of Object.entries(roundSettlements)) {
|
||||
try {
|
||||
if (settlement.payout > 0) {
|
||||
await economyService.modifyUserBalance(
|
||||
playerId,
|
||||
BigInt(settlement.payout),
|
||||
TransactionType.GAME_WIN,
|
||||
`${gameName} round payout (room ${roomId.slice(0, 8)})`,
|
||||
);
|
||||
}
|
||||
settlementDetails[playerId] = settlement;
|
||||
} catch (err) {
|
||||
logger.error("web", `Round payout failed for ${playerId} in room ${roomId}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(settlementDetails).length > 0) {
|
||||
this.publish(`room:${roomId}`, { type: "ROUND_SETTLED", roomId, settlements: settlementDetails });
|
||||
}
|
||||
});
|
||||
|
||||
this.roomManager.emitter.on("player:left", ({ roomId, playerId }) => {
|
||||
this.publish(`room:${roomId}`, {
|
||||
type: "PLAYER_LEFT",
|
||||
roomId,
|
||||
playerId,
|
||||
});
|
||||
});
|
||||
|
||||
this.roomManager.emitter.on("room:deleted", ({ roomId }) => {
|
||||
const channel = `room:${roomId}`;
|
||||
for (const [, ws] of this.connections) {
|
||||
if (ws.data.rooms.has(roomId)) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: "Room not found" }));
|
||||
ws.unsubscribe(channel);
|
||||
ws.data.rooms.delete(roomId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.roomManager.emitter.on("room:list:changed", () => {
|
||||
this.publishRoomListUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
setServer(server: Server<WsConnectionData>): void {
|
||||
this.bunServer = server;
|
||||
}
|
||||
|
||||
handleOpen(ws: ServerWebSocket<WsConnectionData>): void {
|
||||
const discordId = ws.data.session.discordId;
|
||||
const existing = this.connections.get(discordId);
|
||||
if (existing && existing !== ws) {
|
||||
this.replacedConnections.set(discordId, existing);
|
||||
}
|
||||
this.connections.set(discordId, ws);
|
||||
ws.send(JSON.stringify({ type: "ROOM_LIST_UPDATE", rooms: this.roomManager.listRooms() }));
|
||||
}
|
||||
|
||||
async handleMessage(ws: ServerWebSocket<WsConnectionData>, raw: unknown): Promise<void> {
|
||||
const parsed = GameWsClientSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: "Invalid message format" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = parsed.data;
|
||||
const { discordId, username, role } = ws.data.session;
|
||||
|
||||
switch (msg.type) {
|
||||
case "CREATE_ROOM": {
|
||||
// Solo mode forces betAmount to 0
|
||||
const options = msg.options ? { ...msg.options } : {};
|
||||
if (options.soloMode) options.betAmount = 0;
|
||||
|
||||
const result = this.roomManager.createRoom(msg.gameType, discordId, options);
|
||||
if (!result.ok) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: result.error }));
|
||||
return;
|
||||
}
|
||||
ws.subscribe(`room:${result.roomId}`);
|
||||
ws.data.rooms.add(result.roomId);
|
||||
ws.send(JSON.stringify({ type: "ROOM_CREATED", roomId: result.roomId, gameSlug: msg.gameType }));
|
||||
logger.info("web", `Room created: ${result.roomId} (${msg.gameType}) by ${discordId}`);
|
||||
|
||||
// Solo mode: auto-fill and start immediately
|
||||
if (options.soloMode) {
|
||||
const fillResult = this.roomManager.fillRoom(result.roomId, discordId);
|
||||
if (!fillResult.ok) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: fillResult.error }));
|
||||
}
|
||||
// fillRoom with betAmount=0 calls startGame internally
|
||||
}
|
||||
|
||||
// Auto-start if room is immediately full (e.g. maxPlayers: 1) — skip for manualStart games
|
||||
const plugin = gameRegistry.get(msg.gameType);
|
||||
const createdRoom = this.roomManager.getRoom(result.roomId);
|
||||
if (!options.soloMode && plugin && !plugin.manualStart && createdRoom && createdRoom.players.length >= plugin.maxPlayers && createdRoom.status === "waiting") {
|
||||
if (createdRoom.betAmount > 0) {
|
||||
this.deductBetsAndStart(result.roomId, createdRoom.betAmount, createdRoom.players, ws);
|
||||
} else {
|
||||
this.roomManager.startGame(result.roomId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "JOIN_ROOM": {
|
||||
const result = this.roomManager.joinRoom(msg.roomId, discordId, msg.preferAs, msg.role ?? role);
|
||||
if (!result.ok) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: result.error }));
|
||||
return;
|
||||
}
|
||||
|
||||
ws.subscribe(`room:${msg.roomId}`);
|
||||
ws.data.rooms.add(msg.roomId);
|
||||
|
||||
const room = this.roomManager.getRoom(msg.roomId);
|
||||
const roomStatus = room?.status ?? "waiting";
|
||||
|
||||
// Determine the current state to send to this client
|
||||
let state: unknown = undefined;
|
||||
if (room && room.status === "playing") {
|
||||
state = result.joinedAs === "spectator"
|
||||
? this.roomManager.getSpectatorView(msg.roomId)
|
||||
: this.roomManager.getPlayerView(msg.roomId, discordId);
|
||||
}
|
||||
|
||||
// Build player/spectator lists for JOIN_RESULT
|
||||
const resolveInfo = (ids: string[]): PlayerInfo[] =>
|
||||
ids.map(id => ({
|
||||
discordId: id,
|
||||
username: this.connections.get(id)?.data.session.username ?? id,
|
||||
}));
|
||||
|
||||
const players = resolveInfo(room?.players ?? []);
|
||||
const spectators = resolveInfo(Array.from(room?.spectators ?? new Set()));
|
||||
|
||||
// Notify replaced connection in the same room (multi-tab detection)
|
||||
const replacedWs = this.replacedConnections.get(discordId);
|
||||
if (replacedWs && replacedWs.data.rooms.has(msg.roomId)) {
|
||||
replacedWs.send(JSON.stringify({ type: "SESSION_REPLACED", roomId: msg.roomId }));
|
||||
replacedWs.data.rooms.delete(msg.roomId);
|
||||
replacedWs.unsubscribe(`room:${msg.roomId}`);
|
||||
}
|
||||
this.replacedConnections.delete(discordId);
|
||||
|
||||
// Build room options for the client
|
||||
const roomOptions = room
|
||||
? {
|
||||
...(room.betAmount > 0 ? { betAmount: room.betAmount } : {}),
|
||||
...(typeof room.options?.timeControl === "string" ? { timeControl: room.options.timeControl } : {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Respond with JOIN_RESULT
|
||||
ws.send(JSON.stringify({
|
||||
type: "JOIN_RESULT",
|
||||
roomId: msg.roomId,
|
||||
joinedAs: result.joinedAs,
|
||||
roomStatus,
|
||||
players,
|
||||
spectators,
|
||||
state,
|
||||
roomOptions,
|
||||
}));
|
||||
|
||||
// Notify other room members
|
||||
const playerInfo: PlayerInfo = { discordId, username };
|
||||
this.publish(`room:${msg.roomId}`, {
|
||||
type: "PLAYER_JOINED",
|
||||
roomId: msg.roomId,
|
||||
player: playerInfo,
|
||||
joinedAs: result.joinedAs,
|
||||
});
|
||||
|
||||
logger.info("web", `${discordId} joined room ${msg.roomId} as ${result.joinedAs}`);
|
||||
|
||||
// Handle async bet deduction when room is ready to start
|
||||
if (result.readyToStart && room) {
|
||||
this.deductBetsAndStart(msg.roomId, room.betAmount, room.players, ws);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "LEAVE_ROOM": {
|
||||
this.roomManager.leaveRoom(msg.roomId, discordId);
|
||||
ws.unsubscribe(`room:${msg.roomId}`);
|
||||
ws.data.rooms.delete(msg.roomId);
|
||||
break;
|
||||
}
|
||||
|
||||
case "GAME_ACTION": {
|
||||
// Action cost pre-check: deduct bet before processing split/double/place_bet
|
||||
const actionRoom = this.roomManager.getRoom(msg.roomId);
|
||||
if (actionRoom && actionRoom.betAmount > 0 && actionRoom.state) {
|
||||
const actionPlugin = gameRegistry.get(actionRoom.gameSlug);
|
||||
if (actionPlugin?.getActionCost) {
|
||||
const cost = actionPlugin.getActionCost(actionRoom.state, msg.action, discordId);
|
||||
if (cost > 0) {
|
||||
const amount = actionRoom.betAmount * cost;
|
||||
const gameName = actionPlugin.name ?? actionRoom.gameSlug;
|
||||
try {
|
||||
await economyService.modifyUserBalance(
|
||||
discordId,
|
||||
-BigInt(amount),
|
||||
TransactionType.GAME_BET,
|
||||
`${gameName} action bet (room ${msg.roomId.slice(0, 8)})`,
|
||||
);
|
||||
} catch {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: "Insufficient funds for this action" }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = this.roomManager.handleAction(msg.roomId, discordId, msg.action);
|
||||
if (!result.ok) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: result.error }));
|
||||
return;
|
||||
}
|
||||
// game:updated event handler will dispatch views to players and spectators
|
||||
break;
|
||||
}
|
||||
|
||||
case "START_GAME": {
|
||||
const room = this.roomManager.getRoom(msg.roomId);
|
||||
if (!room) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: "Room not found" }));
|
||||
return;
|
||||
}
|
||||
if (room.host !== discordId) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: "Only the host can start the game" }));
|
||||
return;
|
||||
}
|
||||
if (room.status !== "waiting") {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: "Game is not in waiting state" }));
|
||||
return;
|
||||
}
|
||||
const startPlugin = gameRegistry.get(room.gameSlug);
|
||||
if (startPlugin && room.players.length < startPlugin.minPlayers) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: `Need at least ${startPlugin.minPlayers} player(s) to start` }));
|
||||
return;
|
||||
}
|
||||
if (room.betAmount > 0) {
|
||||
this.deductBetsAndStart(msg.roomId, room.betAmount, room.players, ws);
|
||||
} else {
|
||||
const startResult = this.roomManager.startGame(msg.roomId);
|
||||
if (!startResult.ok) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: startResult.error }));
|
||||
}
|
||||
}
|
||||
logger.info("web", `Host ${discordId} started game in room ${msg.roomId}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "FILL_ROOM": {
|
||||
if (role !== "admin") {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: "Only admins can fill a room for solo testing" }));
|
||||
return;
|
||||
}
|
||||
const fillResult = this.roomManager.fillRoom(msg.roomId, discordId);
|
||||
if (!fillResult.ok) {
|
||||
ws.send(JSON.stringify({ type: "ERROR", message: fillResult.error }));
|
||||
return;
|
||||
}
|
||||
if (fillResult.readyToStart) {
|
||||
const room = this.roomManager.getRoom(msg.roomId);
|
||||
if (room) this.deductBetsAndStart(msg.roomId, room.betAmount, room.players, ws);
|
||||
}
|
||||
logger.info("web", `Admin ${discordId} filled room ${msg.roomId} for solo testing`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleClose(ws: ServerWebSocket<WsConnectionData>): void {
|
||||
// If this is a replaced (displaced) connection, just clean up the registry and stop
|
||||
for (const [id, prevWs] of this.replacedConnections) {
|
||||
if (prevWs === ws) {
|
||||
this.replacedConnections.delete(id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (const roomId of ws.data.rooms) {
|
||||
this.roomManager.leaveRoom(roomId, ws.data.session.discordId);
|
||||
}
|
||||
this.connections.delete(ws.data.session.discordId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduct bet amounts from all players, then start the game.
|
||||
* If any player can't afford the bet, refund already-deducted players
|
||||
* and remove the failing player from the room.
|
||||
*/
|
||||
private async deductBetsAndStart(
|
||||
roomId: string,
|
||||
betAmount: number,
|
||||
playerIds: string[],
|
||||
triggeringWs: ServerWebSocket<WsConnectionData>,
|
||||
): Promise<void> {
|
||||
const room = this.roomManager.getRoom(roomId);
|
||||
if (!room || room.betsPending) return;
|
||||
|
||||
// Games with getActionCost handle per-round betting themselves (e.g., blackjack).
|
||||
// Skip the upfront deduction — just start the game.
|
||||
const plugin = gameRegistry.get(room.gameSlug);
|
||||
if (plugin?.getActionCost) {
|
||||
const startResult = this.roomManager.startGame(roomId);
|
||||
if (!startResult.ok) {
|
||||
triggeringWs.send(JSON.stringify({ type: "ERROR", message: startResult.error }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
room.betsPending = true;
|
||||
|
||||
const uniquePlayers = [...new Set(playerIds)];
|
||||
const deducted: string[] = [];
|
||||
|
||||
try {
|
||||
const gameName = gameRegistry.get(room.gameSlug)?.name ?? room.gameSlug;
|
||||
for (const pid of uniquePlayers) {
|
||||
await economyService.modifyUserBalance(
|
||||
pid,
|
||||
-BigInt(betAmount),
|
||||
TransactionType.GAME_BET,
|
||||
`${gameName} wager (room ${roomId.slice(0, 8)})`,
|
||||
);
|
||||
deducted.push(pid);
|
||||
}
|
||||
|
||||
// All deductions succeeded — start the game
|
||||
const startResult = this.roomManager.startGame(roomId);
|
||||
if (!startResult.ok) {
|
||||
// Shouldn't happen, but refund if it does
|
||||
await this.refundPlayers(deducted, betAmount, roomId);
|
||||
}
|
||||
} catch (err) {
|
||||
// Refund anyone already deducted
|
||||
await this.refundPlayers(deducted, betAmount, roomId);
|
||||
|
||||
// Find the player who couldn't afford the bet
|
||||
const failedPlayer = uniquePlayers.find(p => !deducted.includes(p));
|
||||
if (failedPlayer) {
|
||||
this.roomManager.removePlayer(roomId, failedPlayer);
|
||||
this.sendToPlayer(failedPlayer, {
|
||||
type: "ERROR",
|
||||
message: "Insufficient funds for the bet. You have been removed from the room.",
|
||||
});
|
||||
this.publish(`room:${roomId}`, {
|
||||
type: "PLAYER_LEFT",
|
||||
roomId,
|
||||
playerId: failedPlayer,
|
||||
});
|
||||
}
|
||||
|
||||
logger.warn("web", `Bet deduction failed for room ${roomId}: ${err}`);
|
||||
} finally {
|
||||
if (room) room.betsPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Pay out winnings or refund bets on game end. */
|
||||
private async settleBets(
|
||||
roomId: string,
|
||||
winner: string | null,
|
||||
betAmount: number,
|
||||
payouts?: Record<string, number>,
|
||||
): Promise<{ amount: number; refunded?: boolean }> {
|
||||
const room = this.roomManager.getRoom(roomId);
|
||||
const uniquePlayers = [...new Set(room?.players ?? [])];
|
||||
const gameName = gameRegistry.get(room?.gameSlug ?? "")?.name ?? "Game";
|
||||
|
||||
try {
|
||||
// Custom payouts override default pot logic (used by house-edge games like blackjack)
|
||||
if (payouts) {
|
||||
let totalPaid = 0;
|
||||
for (const [playerId, multiplier] of Object.entries(payouts)) {
|
||||
if (multiplier <= 0) continue;
|
||||
const amount = Math.floor(betAmount * multiplier);
|
||||
await economyService.modifyUserBalance(
|
||||
playerId,
|
||||
BigInt(amount),
|
||||
TransactionType.GAME_WIN,
|
||||
`${gameName} payout (room ${roomId.slice(0, 8)})`,
|
||||
);
|
||||
totalPaid = Math.max(totalPaid, amount);
|
||||
}
|
||||
const isRefund = !winner && totalPaid === betAmount;
|
||||
return { amount: totalPaid, refunded: isRefund };
|
||||
}
|
||||
|
||||
// Default pot logic: winner takes all, draw refunds everyone
|
||||
const pot = betAmount * uniquePlayers.length;
|
||||
if (winner) {
|
||||
await economyService.modifyUserBalance(
|
||||
winner,
|
||||
BigInt(pot),
|
||||
TransactionType.GAME_WIN,
|
||||
`${gameName} wager won (room ${roomId.slice(0, 8)})`,
|
||||
);
|
||||
return { amount: pot };
|
||||
} else {
|
||||
await this.refundPlayers(uniquePlayers, betAmount, roomId, gameName);
|
||||
return { amount: betAmount, refunded: true };
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("web", `Bet settlement failed for room ${roomId}: ${err}`);
|
||||
return { amount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
private async refundPlayers(playerIds: string[], betAmount: number, roomId: string, gameName = "Game"): Promise<void> {
|
||||
for (const pid of playerIds) {
|
||||
try {
|
||||
await economyService.modifyUserBalance(
|
||||
pid,
|
||||
BigInt(betAmount),
|
||||
TransactionType.GAME_WIN,
|
||||
`${gameName} wager refund (room ${roomId.slice(0, 8)})`,
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error("web", `Failed to refund ${pid} for room ${roomId}: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private publish(channel: string, message: unknown): void {
|
||||
this.bunServer?.publish(channel, JSON.stringify(message));
|
||||
}
|
||||
|
||||
private sendToPlayer(discordId: string, message: unknown): void {
|
||||
this.connections.get(discordId)?.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
private publishRoomListUpdate(): void {
|
||||
this.publish("lobby", { type: "ROOM_LIST_UPDATE", rooms: this.roomManager.listRooms() });
|
||||
}
|
||||
}
|
||||
|
||||
export const gameServer = new GameServer();
|
||||
187
api/src/games/RoomManager.test.ts
Normal file
187
api/src/games/RoomManager.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect, beforeEach } from "bun:test";
|
||||
import { RoomManager } from "./RoomManager";
|
||||
import { gameRegistry } from "@shared/games/registry";
|
||||
import type { GamePlugin } from "@shared/games/types";
|
||||
|
||||
// Minimal stub plugin for testing the room system
|
||||
const stubPlugin: GamePlugin<{ turn: number }, { type: string }> = {
|
||||
slug: "stub",
|
||||
name: "Stub Game",
|
||||
minPlayers: 2,
|
||||
maxPlayers: 2,
|
||||
createInitialState: (players) => ({ turn: 0 }),
|
||||
handleAction: (state, action, playerId) => ({ ok: true, state: { ...state, turn: state.turn + 1 } }),
|
||||
getPlayerView: (state) => state,
|
||||
getSpectatorView: (state) => state,
|
||||
};
|
||||
|
||||
if (!gameRegistry.get("stub")) {
|
||||
gameRegistry.register(stubPlugin);
|
||||
}
|
||||
|
||||
describe("RoomManager", () => {
|
||||
let manager: RoomManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new RoomManager();
|
||||
});
|
||||
|
||||
describe("createRoom", () => {
|
||||
it("should create a room and return its id", () => {
|
||||
const result = manager.createRoom("stub", "player1");
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.roomId).toBeDefined();
|
||||
expect(typeof result.roomId).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject unknown game type", () => {
|
||||
const result = manager.createRoom("unknown-game", "player1");
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("should add creator as first player", () => {
|
||||
const result = manager.createRoom("stub", "player1");
|
||||
if (result.ok) {
|
||||
const room = manager.getRoom(result.roomId);
|
||||
expect(room?.players).toContain("player1");
|
||||
expect(room?.host).toBe("player1");
|
||||
expect(room?.status).toBe("waiting");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("joinRoom", () => {
|
||||
it("should add a player to a waiting room", () => {
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
const join = manager.joinRoom(create.roomId, "player2", "player");
|
||||
expect(join.ok).toBe(true);
|
||||
if (join.ok) {
|
||||
expect(join.joinedAs).toBe("player");
|
||||
}
|
||||
});
|
||||
|
||||
it("should auto-start when room reaches maxPlayers", () => {
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
manager.joinRoom(create.roomId, "player2", "player");
|
||||
const room = manager.getRoom(create.roomId);
|
||||
expect(room?.status).toBe("playing");
|
||||
expect(room?.state).toBeDefined();
|
||||
});
|
||||
|
||||
it("should allow joining as spectator when game is playing", () => {
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
manager.joinRoom(create.roomId, "player2", "player");
|
||||
const spec = manager.joinRoom(create.roomId, "spectator1", "spectator");
|
||||
expect(spec.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("should downgrade to spectator when joining full room as player", () => {
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
manager.joinRoom(create.roomId, "player2", "player");
|
||||
const result = manager.joinRoom(create.roomId, "player3", "player");
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.joinedAs).toBe("spectator");
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject joining nonexistent room", () => {
|
||||
const result = manager.joinRoom("fake-id", "player1", "player");
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAction", () => {
|
||||
it("should apply a valid game action", () => {
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
manager.joinRoom(create.roomId, "player2", "player");
|
||||
const result = manager.handleAction(create.roomId, "player1", { type: "action" });
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject action from spectator", () => {
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
manager.joinRoom(create.roomId, "player2", "player");
|
||||
manager.joinRoom(create.roomId, "spectator1", "spectator");
|
||||
const result = manager.handleAction(create.roomId, "spectator1", { type: "action" });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("leaveRoom", () => {
|
||||
it("should remove a player from the room", () => {
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
manager.leaveRoom(create.roomId, "player1");
|
||||
const room = manager.getRoom(create.roomId);
|
||||
expect(room).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should remove a spectator from the room", () => {
|
||||
const create = manager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
manager.joinRoom(create.roomId, "player2", "player");
|
||||
manager.joinRoom(create.roomId, "spec1", "spectator");
|
||||
manager.leaveRoom(create.roomId, "spec1");
|
||||
const room = manager.getRoom(create.roomId);
|
||||
expect(room?.spectators.has("spec1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listRooms", () => {
|
||||
it("should return summaries of all rooms", () => {
|
||||
manager.createRoom("stub", "player1");
|
||||
manager.createRoom("stub", "player2");
|
||||
const rooms = manager.listRooms();
|
||||
expect(rooms.length).toBe(2);
|
||||
expect(rooms[0].gameSlug).toBe("stub");
|
||||
expect(rooms[0].status).toBe("waiting");
|
||||
});
|
||||
|
||||
it("should filter by game type", () => {
|
||||
manager.createRoom("stub", "player1");
|
||||
const rooms = manager.listRooms("stub");
|
||||
expect(rooms.length).toBe(1);
|
||||
const empty = manager.listRooms("blackjack");
|
||||
expect(empty.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("waiting room cleanup", () => {
|
||||
it("should remove waiting rooms after the configured timeout", async () => {
|
||||
const shortLivedManager = new RoomManager({ WAITING_CLEANUP_MS: 20 });
|
||||
const create = shortLivedManager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 35));
|
||||
|
||||
expect(shortLivedManager.getRoom(create.roomId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should refresh the waiting room timeout when the room is active", async () => {
|
||||
const shortLivedManager = new RoomManager({ WAITING_CLEANUP_MS: 25 });
|
||||
const create = shortLivedManager.createRoom("stub", "player1");
|
||||
if (!create.ok) throw new Error("Failed to create room");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 15));
|
||||
|
||||
const spectatorJoin = shortLivedManager.joinRoom(create.roomId, "spectator1", "spectator");
|
||||
expect(spectatorJoin.ok).toBe(true);
|
||||
expect(shortLivedManager.getRoom(create.roomId)).toBeDefined();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 15));
|
||||
expect(shortLivedManager.getRoom(create.roomId)).toBeDefined();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
expect(shortLivedManager.getRoom(create.roomId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
327
api/src/games/RoomManager.ts
Normal file
327
api/src/games/RoomManager.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import mitt from "mitt";
|
||||
import { gameRegistry } from "@shared/games/registry";
|
||||
import type { Room, RoomSummary } from "./types";
|
||||
import type { RoundSettlement } from "@shared/games/types";
|
||||
|
||||
const DEFAULT_ROOM_CONFIG = {
|
||||
WAITING_CLEANUP_MS: 15 * 60_000,
|
||||
FINISHED_CLEANUP_MS: 60_000,
|
||||
PLAYING_MAX_MS: 30 * 60_000, // 30 minutes — safety net for stuck games
|
||||
} as const;
|
||||
|
||||
type RoomManagerConfig = typeof DEFAULT_ROOM_CONFIG;
|
||||
|
||||
type ActionResult =
|
||||
| { ok: true; state: unknown; gameOver: { winner: string | null; reason: string } | null; roundSettlements?: Record<string, RoundSettlement> }
|
||||
| { ok: false; error: string };
|
||||
|
||||
type CreateResult = { ok: true; roomId: string } | { ok: false; error: string };
|
||||
type JoinResult =
|
||||
| { ok: true; joinedAs: "player" | "spectator"; started: boolean; readyToStart?: boolean }
|
||||
| { ok: false; error: string };
|
||||
type FillResult = { ok: true; readyToStart?: boolean } | { ok: false; error: string };
|
||||
|
||||
type RoomEvents = {
|
||||
"room:created": { roomId: string; gameSlug: string; hostId: string };
|
||||
"player:joined": { roomId: string; playerId: string; username: string; joinedAs: "player" | "spectator" };
|
||||
"game:started": { roomId: string; spectatorView: unknown; playerViews: Map<string, unknown> };
|
||||
"game:updated": { roomId: string; spectatorView: unknown; playerViews: Map<string, unknown> };
|
||||
"game:ended": { roomId: string; winner: string | null; reason: string; payouts?: Record<string, number> };
|
||||
"round:settled": { roomId: string; roundSettlements: Record<string, RoundSettlement> };
|
||||
"player:left": { roomId: string; playerId: string };
|
||||
"room:deleted": { roomId: string };
|
||||
"room:list:changed": void;
|
||||
};
|
||||
|
||||
export class RoomManager {
|
||||
private rooms = new Map<string, Room>();
|
||||
private cleanupTimers = new Map<string, Timer>();
|
||||
private readonly config: RoomManagerConfig;
|
||||
readonly emitter = mitt<RoomEvents>();
|
||||
|
||||
constructor(config: Partial<RoomManagerConfig> = {}) {
|
||||
this.config = { ...DEFAULT_ROOM_CONFIG, ...config };
|
||||
}
|
||||
|
||||
createRoom(gameSlug: string, hostId: string, options?: Record<string, unknown>): CreateResult {
|
||||
const plugin = gameRegistry.get(gameSlug);
|
||||
if (!plugin) return { ok: false, error: `Unknown game type: ${gameSlug}` };
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const betAmount = typeof options?.betAmount === "number" && options.betAmount > 0 ? options.betAmount : 0;
|
||||
const room: Room = {
|
||||
id,
|
||||
gameSlug,
|
||||
host: hostId,
|
||||
players: [hostId],
|
||||
spectators: new Set(),
|
||||
state: null,
|
||||
status: "waiting",
|
||||
createdAt: Date.now(),
|
||||
options,
|
||||
betAmount,
|
||||
};
|
||||
|
||||
this.rooms.set(id, room);
|
||||
this.refreshWaitingCleanup(id, room);
|
||||
|
||||
this.emitter.emit("room:created", { roomId: id, gameSlug, hostId });
|
||||
this.emitter.emit("room:list:changed");
|
||||
|
||||
return { ok: true, roomId: id };
|
||||
}
|
||||
|
||||
joinRoom(roomId: string, playerId: string, preferAs: "player" | "spectator", role?: string): JoinResult {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return { ok: false, error: "Room not found" };
|
||||
|
||||
// Reconnecting player: must be checked before the in-progress spectator guard.
|
||||
if (preferAs !== "spectator" && room.players.includes(playerId)) {
|
||||
room.spectators.delete(playerId);
|
||||
this.refreshWaitingCleanup(roomId, room);
|
||||
return { ok: true, joinedAs: "player", started: room.status === "playing" };
|
||||
}
|
||||
|
||||
if (preferAs === "spectator" || room.status !== "waiting") {
|
||||
room.spectators.add(playerId);
|
||||
this.refreshWaitingCleanup(roomId, room);
|
||||
return { ok: true, joinedAs: "spectator", started: room.status === "playing" };
|
||||
}
|
||||
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
const isAdmin = role === "admin";
|
||||
|
||||
if (room.players.length >= plugin.maxPlayers && !isAdmin) {
|
||||
// Downgrade to spectator — room is full but still waiting, so game hasn't started
|
||||
room.spectators.add(playerId);
|
||||
return { ok: true, joinedAs: "spectator", started: false };
|
||||
}
|
||||
|
||||
room.players.push(playerId);
|
||||
|
||||
if (room.players.length >= plugin.maxPlayers && !plugin.manualStart) {
|
||||
// Defer start when bets are involved — GameServer handles async deduction first
|
||||
if (room.betAmount > 0) {
|
||||
this.refreshWaitingCleanup(roomId, room);
|
||||
this.emitter.emit("room:list:changed");
|
||||
return { ok: true, joinedAs: "player", started: false, readyToStart: true };
|
||||
}
|
||||
this.startGame(roomId);
|
||||
return { ok: true, joinedAs: "player", started: true };
|
||||
}
|
||||
|
||||
this.refreshWaitingCleanup(roomId, room);
|
||||
this.emitter.emit("room:list:changed");
|
||||
return { ok: true, joinedAs: "player", started: false };
|
||||
}
|
||||
|
||||
handleAction(roomId: string, playerId: string, action: unknown): ActionResult {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return { ok: false, error: "Room not found" };
|
||||
if (room.status !== "playing") return { ok: false, error: "Game is not in progress" };
|
||||
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
|
||||
// Spectator-to-player promotion for actions like "sit_down"
|
||||
if (!room.players.includes(playerId)) {
|
||||
if (room.spectators.has(playerId) && plugin.isSpectatorAction?.(action as any)) {
|
||||
room.spectators.delete(playerId);
|
||||
room.players.push(playerId);
|
||||
} else {
|
||||
return { ok: false, error: "You are not a player in this game" };
|
||||
}
|
||||
}
|
||||
|
||||
const result = plugin.handleAction(room.state, action, playerId);
|
||||
if (!result.ok) return result;
|
||||
|
||||
room.state = result.state;
|
||||
const gameOver = plugin.isGameOver?.(room.state) ?? null;
|
||||
if (gameOver) {
|
||||
room.status = "finished";
|
||||
this.scheduleCleanup(roomId, this.config.FINISHED_CLEANUP_MS);
|
||||
}
|
||||
|
||||
const spectatorView = plugin.getSpectatorView(room.state);
|
||||
const playerViews = new Map<string, unknown>();
|
||||
for (const pid of new Set(room.players)) {
|
||||
playerViews.set(pid, plugin.getPlayerView(room.state, pid));
|
||||
}
|
||||
this.emitter.emit("game:updated", { roomId, spectatorView, playerViews });
|
||||
|
||||
// Emit round payouts for mid-game settlement (continuous-play games)
|
||||
if (result.roundSettlements && !gameOver) {
|
||||
this.emitter.emit("round:settled", { roomId, roundSettlements: result.roundSettlements });
|
||||
}
|
||||
|
||||
if (gameOver) {
|
||||
this.emitter.emit("game:ended", { roomId, winner: gameOver.winner, reason: gameOver.reason, payouts: gameOver.payouts });
|
||||
this.emitter.emit("room:list:changed");
|
||||
}
|
||||
|
||||
return { ok: true, state: room.state, gameOver, roundSettlements: result.roundSettlements };
|
||||
}
|
||||
|
||||
leaveRoom(roomId: string, playerId: string): void {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return;
|
||||
|
||||
room.spectators.delete(playerId);
|
||||
|
||||
const playerIdx = room.players.indexOf(playerId);
|
||||
if (playerIdx !== -1) {
|
||||
room.players.splice(playerIdx, 1);
|
||||
|
||||
if (room.status === "playing") {
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
if (plugin.onPlayerDisconnect) {
|
||||
room.state = plugin.onPlayerDisconnect(room.state, playerId);
|
||||
const gameOver = plugin.isGameOver?.(room.state) ?? null;
|
||||
if (gameOver) {
|
||||
room.status = "finished";
|
||||
this.scheduleCleanup(roomId, this.config.FINISHED_CLEANUP_MS);
|
||||
this.emitter.emit("game:ended", { roomId, winner: gameOver.winner, reason: gameOver.reason, payouts: gameOver.payouts });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (room.players.length === 0 && room.status === "waiting") {
|
||||
this.deleteRoom(roomId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.refreshWaitingCleanup(roomId, room);
|
||||
|
||||
this.emitter.emit("player:left", { roomId, playerId });
|
||||
this.emitter.emit("room:list:changed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills empty seats with the admin's own ID for solo testing.
|
||||
* This means `createInitialState` will receive duplicate player IDs
|
||||
* (e.g. ["alice", "alice"]). Plugin authors should be aware that
|
||||
* solo-test mode produces non-unique player arrays.
|
||||
*/
|
||||
fillRoom(roomId: string, adminId: string): FillResult {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return { ok: false, error: "Room not found" };
|
||||
if (room.status !== "waiting") return { ok: false, error: "Game is not in waiting state" };
|
||||
if (!room.players.includes(adminId)) return { ok: false, error: "You are not in this room" };
|
||||
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
while (room.players.length < plugin.maxPlayers) {
|
||||
room.players.push(adminId);
|
||||
}
|
||||
|
||||
// Defer start when bets are involved
|
||||
if (room.betAmount > 0) {
|
||||
this.refreshWaitingCleanup(roomId, room);
|
||||
this.emitter.emit("room:list:changed");
|
||||
return { ok: true, readyToStart: true };
|
||||
}
|
||||
|
||||
this.startGame(roomId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Initialize game state and transition room to playing. */
|
||||
startGame(roomId: string): { ok: true } | { ok: false; error: string } {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return { ok: false, error: "Room not found" };
|
||||
if (room.status !== "waiting") return { ok: false, error: "Game is not in waiting state" };
|
||||
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
room.state = plugin.createInitialState(room.players, room.options);
|
||||
room.status = "playing";
|
||||
this.scheduleCleanup(roomId, this.config.PLAYING_MAX_MS);
|
||||
|
||||
const spectatorView = plugin.getSpectatorView(room.state);
|
||||
const playerViews = new Map<string, unknown>();
|
||||
for (const pid of new Set(room.players)) {
|
||||
playerViews.set(pid, plugin.getPlayerView(room.state, pid));
|
||||
}
|
||||
this.emitter.emit("game:started", { roomId, spectatorView, playerViews });
|
||||
this.emitter.emit("room:list:changed");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Remove a player from a waiting room (used when bet deduction fails). */
|
||||
removePlayer(roomId: string, playerId: string): void {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room || room.status !== "waiting") return;
|
||||
const idx = room.players.indexOf(playerId);
|
||||
if (idx !== -1) room.players.splice(idx, 1);
|
||||
if (room.players.length === 0) {
|
||||
this.deleteRoom(roomId);
|
||||
return;
|
||||
}
|
||||
this.refreshWaitingCleanup(roomId, room);
|
||||
this.emitter.emit("room:list:changed");
|
||||
}
|
||||
|
||||
getRoom(roomId: string): Room | undefined {
|
||||
return this.rooms.get(roomId);
|
||||
}
|
||||
|
||||
listRooms(gameSlug?: string): RoomSummary[] {
|
||||
const summaries: RoomSummary[] = [];
|
||||
for (const room of this.rooms.values()) {
|
||||
if (gameSlug && room.gameSlug !== gameSlug) continue;
|
||||
const plugin = gameRegistry.get(room.gameSlug);
|
||||
summaries.push({
|
||||
id: room.id,
|
||||
gameSlug: room.gameSlug,
|
||||
gameName: plugin?.name ?? room.gameSlug,
|
||||
host: room.host,
|
||||
playerCount: room.players.length,
|
||||
maxPlayers: plugin?.maxPlayers ?? 0,
|
||||
spectatorCount: room.spectators.size,
|
||||
status: room.status,
|
||||
betAmount: room.betAmount,
|
||||
});
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
getPlayerView(roomId: string, playerId: string): unknown {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room || !room.state) return null;
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
return plugin.getPlayerView(room.state, playerId);
|
||||
}
|
||||
|
||||
getSpectatorView(roomId: string): unknown {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room || !room.state) return null;
|
||||
const plugin = gameRegistry.get(room.gameSlug)!;
|
||||
return plugin.getSpectatorView(room.state);
|
||||
}
|
||||
|
||||
private scheduleCleanup(roomId: string, ms: number): void {
|
||||
this.clearCleanup(roomId);
|
||||
const timer = setTimeout(() => this.deleteRoom(roomId), ms);
|
||||
this.cleanupTimers.set(roomId, timer);
|
||||
}
|
||||
|
||||
private refreshWaitingCleanup(roomId: string, room: Room): void {
|
||||
if (room.status !== "waiting") return;
|
||||
this.scheduleCleanup(roomId, this.config.WAITING_CLEANUP_MS);
|
||||
}
|
||||
|
||||
private clearCleanup(roomId: string): void {
|
||||
const existing = this.cleanupTimers.get(roomId);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
this.cleanupTimers.delete(roomId);
|
||||
}
|
||||
}
|
||||
|
||||
private deleteRoom(roomId: string): void {
|
||||
this.clearCleanup(roomId);
|
||||
if (this.rooms.delete(roomId)) {
|
||||
this.emitter.emit("room:deleted", { roomId });
|
||||
this.emitter.emit("room:list:changed");
|
||||
}
|
||||
}
|
||||
}
|
||||
65
api/src/games/types.ts
Normal file
65
api/src/games/types.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { RoundSettlement } from "@shared/games/types";
|
||||
import { z } from "zod";
|
||||
|
||||
export interface Room {
|
||||
id: string;
|
||||
gameSlug: string;
|
||||
host: string;
|
||||
players: string[];
|
||||
spectators: Set<string>;
|
||||
state: unknown;
|
||||
status: "waiting" | "playing" | "finished";
|
||||
createdAt: number;
|
||||
options?: Record<string, unknown>;
|
||||
betAmount: number;
|
||||
/** Guard against double bet-deduction when two joins race */
|
||||
betsPending?: boolean;
|
||||
}
|
||||
|
||||
export interface RoomSummary {
|
||||
id: string;
|
||||
gameSlug: string;
|
||||
gameName: string;
|
||||
host: string;
|
||||
playerCount: number;
|
||||
maxPlayers: number;
|
||||
spectatorCount: number;
|
||||
status: "waiting" | "playing" | "finished";
|
||||
betAmount: number;
|
||||
}
|
||||
|
||||
export interface PlayerInfo {
|
||||
discordId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export const GameWsClientSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("CREATE_ROOM"), gameType: z.string(), options: z.looseObject({}).optional() }),
|
||||
z.object({
|
||||
type: z.literal("JOIN_ROOM"),
|
||||
roomId: z.string(),
|
||||
preferAs: z.enum(["player", "spectator"]),
|
||||
role: z.enum(["player", "admin"]).optional(),
|
||||
}),
|
||||
z.object({ type: z.literal("LEAVE_ROOM"), roomId: z.string() }),
|
||||
// Use looseObject for GAME_ACTION to avoid Zod bug with record()
|
||||
z.object({ type: z.literal("GAME_ACTION"), roomId: z.string(), action: z.looseObject({}, { message: "Invalid action" }) }),
|
||||
z.object({ type: z.literal("FILL_ROOM"), roomId: z.string() }),
|
||||
z.object({ type: z.literal("START_GAME"), roomId: z.string() }),
|
||||
]);
|
||||
|
||||
export type GameWsClientMessage = z.infer<typeof GameWsClientSchema>;
|
||||
|
||||
export type GameWsServerMessage =
|
||||
| { type: "ROOM_LIST_UPDATE"; rooms: RoomSummary[] }
|
||||
| { type: "GAME_STATE"; roomId: string; state: unknown }
|
||||
| { type: "GAME_UPDATE"; roomId: string; state: unknown }
|
||||
| { type: "PLAYER_JOINED"; roomId: string; player: PlayerInfo; joinedAs: "player" | "spectator" }
|
||||
| { type: "PLAYER_LEFT"; roomId: string; playerId: string }
|
||||
| { type: "GAME_STARTED"; roomId: string; state: unknown }
|
||||
| { type: "GAME_ENDED"; roomId: string; winner: string | null; reason: string; payout?: { amount: number; refunded?: boolean } }
|
||||
| { type: "ROOM_CREATED"; roomId: string; gameSlug: string }
|
||||
| { type: "JOIN_RESULT"; roomId: string; joinedAs: "player" | "spectator"; roomStatus: "waiting" | "playing" | "finished"; players: PlayerInfo[]; spectators: PlayerInfo[]; state?: unknown; roomOptions?: { betAmount?: number; timeControl?: string } }
|
||||
| { type: "ROUND_SETTLED"; roomId: string; settlements: Record<string, RoundSettlement> }
|
||||
| { type: "SESSION_REPLACED"; roomId: string }
|
||||
| { type: "ERROR"; message: string };
|
||||
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": "no-cache",
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const assetsRoutes: RouteModule = {
|
||||
name: "assets",
|
||||
handler
|
||||
};
|
||||
103
api/src/routes/auth.routes.test.ts
Normal file
103
api/src/routes/auth.routes.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
|
||||
|
||||
const findFirst = mock(async () => ({ id: 123n }));
|
||||
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: {
|
||||
query: {
|
||||
users: {
|
||||
findFirst,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("@shared/lib/logger", () => ({
|
||||
logger: {
|
||||
error: () => { },
|
||||
info: () => { },
|
||||
warn: () => { },
|
||||
debug: () => { },
|
||||
},
|
||||
}));
|
||||
|
||||
import { authRoutes, getSession } from "./auth.routes";
|
||||
|
||||
describe("Auth Routes", () => {
|
||||
let fetchSpy: ReturnType<typeof spyOn> | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.DISCORD_CLIENT_ID = "client-id";
|
||||
process.env.DISCORD_CLIENT_SECRET = "client-secret";
|
||||
process.env.SESSION_SECRET = "session-secret";
|
||||
process.env.PANEL_BASE_URL = "http://localhost:3000";
|
||||
process.env.ADMIN_USER_IDS = "123";
|
||||
findFirst.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy?.mockRestore();
|
||||
fetchSpy = null;
|
||||
});
|
||||
|
||||
it("creates a signed session cookie during OAuth callback", async () => {
|
||||
const loginUrl = new URL("http://localhost/auth/discord?return_to=http://localhost:5173/admin");
|
||||
const loginRes = await authRoutes.handler({
|
||||
req: new Request(loginUrl, { method: "GET" }),
|
||||
url: loginUrl,
|
||||
method: "GET",
|
||||
pathname: "/auth/discord",
|
||||
});
|
||||
|
||||
expect(loginRes?.status).toBe(302);
|
||||
|
||||
const redirectLocation = loginRes?.headers.get("Location");
|
||||
expect(redirectLocation).not.toBeNull();
|
||||
|
||||
const state = new URL(redirectLocation!).searchParams.get("state");
|
||||
expect(state).not.toBeNull();
|
||||
|
||||
fetchSpy = spyOn(globalThis, "fetch");
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ access_token: "discord-token" }), { status: 200 }));
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
id: "123",
|
||||
username: "aurora-admin",
|
||||
avatar: null,
|
||||
}), { status: 200 }));
|
||||
|
||||
const callbackUrl = new URL(`http://localhost/auth/callback?code=oauth-code&state=${encodeURIComponent(state!)}`);
|
||||
const callbackRes = await authRoutes.handler({
|
||||
req: new Request(callbackUrl, { method: "GET" }),
|
||||
url: callbackUrl,
|
||||
method: "GET",
|
||||
pathname: "/auth/callback",
|
||||
});
|
||||
|
||||
expect(callbackRes?.status).toBe(302);
|
||||
expect(callbackRes?.headers.get("Location")).toBe("/admin");
|
||||
|
||||
const setCookie = callbackRes?.headers.get("Set-Cookie");
|
||||
expect(setCookie).toContain("aurora_session=");
|
||||
|
||||
const sessionCookie = setCookie!.split(";")[0]!;
|
||||
const session = getSession(new Request("http://localhost/api/me", {
|
||||
headers: { cookie: sessionCookie },
|
||||
}));
|
||||
|
||||
expect(session).toEqual({
|
||||
discordId: "123",
|
||||
username: "aurora-admin",
|
||||
avatar: null,
|
||||
role: "admin",
|
||||
expiresAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects tampered session cookies", () => {
|
||||
const session = getSession(new Request("http://localhost/api/me", {
|
||||
headers: { cookie: "aurora_session=not-a-valid-token" },
|
||||
}));
|
||||
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
});
|
||||
349
api/src/routes/auth.routes.ts
Normal file
349
api/src/routes/auth.routes.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* @fileoverview Discord OAuth2 authentication routes for the admin panel.
|
||||
* Handles login flow, callback, logout, and session management.
|
||||
*/
|
||||
|
||||
import { Buffer } from "node:buffer";
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import type { RouteContext, RouteModule } from "./types";
|
||||
import { jsonResponse, errorResponse } from "./utils";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { users } from "@shared/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
// Signed session payload stored in the aurora_session cookie.
|
||||
export interface Session {
|
||||
discordId: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
role: "admin" | "player";
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface SessionTokenPayload extends Session {
|
||||
v: 1;
|
||||
}
|
||||
|
||||
interface OAuthStatePayload {
|
||||
exp: number;
|
||||
returnTo: string;
|
||||
v: 1;
|
||||
}
|
||||
|
||||
const COOKIE_NAME = "aurora_session";
|
||||
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
const OAUTH_STATE_MAX_AGE = 10 * 60 * 1000; // 10 minutes
|
||||
const TOKEN_NAMESPACE = "aurora.auth";
|
||||
const TOKEN_VERSION = "v1";
|
||||
|
||||
function getEnv(key: string): string {
|
||||
const val = process.env[key];
|
||||
if (!val) throw new Error(`Missing env: ${key}`);
|
||||
return val;
|
||||
}
|
||||
|
||||
function getSessionSecret(required: boolean = false): string | null {
|
||||
const secret = process.env.SESSION_SECRET ?? process.env.DISCORD_CLIENT_SECRET ?? null;
|
||||
if (!secret && required) {
|
||||
throw new Error("Missing env: SESSION_SECRET or DISCORD_CLIENT_SECRET");
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
function requireSessionSecret(): string {
|
||||
return getSessionSecret(true)!;
|
||||
}
|
||||
|
||||
function getAdminIds(): string[] {
|
||||
const raw = process.env.ADMIN_USER_IDS ?? "";
|
||||
return raw.split(",").map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function encodeBase64Url(value: string): string {
|
||||
return Buffer.from(value, "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
function decodeBase64Url(value: string): string {
|
||||
return Buffer.from(value, "base64url").toString("utf8");
|
||||
}
|
||||
|
||||
function signValue(kind: string, encodedPayload: string, secret: string): string {
|
||||
return createHmac("sha256", secret)
|
||||
.update(`${TOKEN_NAMESPACE}.${kind}.${encodedPayload}`)
|
||||
.digest("base64url");
|
||||
}
|
||||
|
||||
function serializeSignedToken(kind: string, payload: SessionTokenPayload | OAuthStatePayload, secret: string): string {
|
||||
const encodedPayload = encodeBase64Url(JSON.stringify(payload));
|
||||
const signature = signValue(kind, encodedPayload, secret);
|
||||
return `${TOKEN_VERSION}.${encodedPayload}.${signature}`;
|
||||
}
|
||||
|
||||
function parseSignedToken<T>(token: string | undefined, kind: string): T | null {
|
||||
if (!token) return null;
|
||||
|
||||
const secret = getSessionSecret();
|
||||
if (!secret) return null;
|
||||
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const version = parts[0];
|
||||
const encodedPayload = parts[1];
|
||||
const providedSignature = parts[2];
|
||||
if (version !== TOKEN_VERSION) return null;
|
||||
if (!encodedPayload || !providedSignature) return null;
|
||||
|
||||
const expectedSignature = signValue(kind, encodedPayload, secret);
|
||||
const providedBuffer = Buffer.from(providedSignature);
|
||||
const expectedBuffer = Buffer.from(expectedSignature);
|
||||
|
||||
if (providedBuffer.length !== expectedBuffer.length) return null;
|
||||
if (!timingSafeEqual(providedBuffer, expectedBuffer)) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(decodeBase64Url(encodedPayload)) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function sanitizeReturnTo(rawReturnTo: string | null, baseUrl: string): string {
|
||||
if (!rawReturnTo || rawReturnTo.length > 1024) return "/";
|
||||
|
||||
try {
|
||||
if (rawReturnTo.startsWith("/") && !rawReturnTo.startsWith("//")) {
|
||||
return rawReturnTo;
|
||||
}
|
||||
|
||||
const parsed = new URL(rawReturnTo, baseUrl);
|
||||
const allowedBase = new URL(baseUrl);
|
||||
const isLocalhostRedirect = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
||||
|
||||
if (parsed.origin === allowedBase.origin || isLocalhostRedirect) {
|
||||
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
}
|
||||
} catch {
|
||||
return "/";
|
||||
}
|
||||
|
||||
return "/";
|
||||
}
|
||||
|
||||
function buildCookieAttributes(maxAgeSeconds?: number): string {
|
||||
const attrs = [
|
||||
"Path=/",
|
||||
"HttpOnly",
|
||||
"SameSite=Lax",
|
||||
];
|
||||
|
||||
try {
|
||||
if (new URL(getBaseUrl()).protocol === "https:") {
|
||||
attrs.push("Secure");
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid PANEL_BASE_URL here; handlers that need it will fail explicitly.
|
||||
}
|
||||
|
||||
if (typeof maxAgeSeconds === "number") {
|
||||
attrs.push(`Max-Age=${maxAgeSeconds}`);
|
||||
}
|
||||
|
||||
return attrs.join("; ");
|
||||
}
|
||||
|
||||
/** Get session from request cookie */
|
||||
export function getSession(req: Request): Session | null {
|
||||
const cookies = parseCookies(req.headers.get("cookie"));
|
||||
const payload = parseSignedToken<SessionTokenPayload>(cookies[COOKIE_NAME], "session");
|
||||
|
||||
if (!payload || payload.v !== 1) return null;
|
||||
if (Date.now() > payload.expiresAt) return null;
|
||||
|
||||
return {
|
||||
discordId: payload.discordId,
|
||||
username: payload.username,
|
||||
avatar: payload.avatar,
|
||||
role: payload.role,
|
||||
expiresAt: payload.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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";
|
||||
const secret = requireSessionSecret();
|
||||
|
||||
// Store return_to URL in signed OAuth state
|
||||
const returnTo = sanitizeReturnTo(ctx.url.searchParams.get("return_to"), baseUrl);
|
||||
const state = serializeSignedToken("oauth", {
|
||||
exp: Date.now() + OAUTH_STATE_MAX_AGE,
|
||||
returnTo,
|
||||
v: 1,
|
||||
}, secret);
|
||||
|
||||
const url = `https://discord.com/oauth2/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&scope=${scope}&state=${encodeURIComponent(state)}`;
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: url,
|
||||
},
|
||||
});
|
||||
} 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`;
|
||||
const secret = requireSessionSecret();
|
||||
const statePayload = parseSignedToken<OAuthStatePayload>(ctx.url.searchParams.get("state") ?? undefined, "oauth");
|
||||
|
||||
if (!statePayload || statePayload.v !== 1 || Date.now() > statePayload.exp) {
|
||||
return errorResponse("Invalid OAuth state", 400);
|
||||
}
|
||||
|
||||
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 enrollment — user must exist in the users table
|
||||
const dbUser = await DrizzleClient.query.users.findFirst({
|
||||
where: eq(users.id, BigInt(user.id)),
|
||||
});
|
||||
|
||||
if (!dbUser) {
|
||||
logger.info("auth", `Non-enrolled login attempt by ${user.username} (${user.id})`);
|
||||
return new Response(
|
||||
`<html><body><h1>Not Enrolled</h1><p>You need to use the Aurora bot in Discord before you can access this panel.</p><a href="/">Go back</a></body></html>`,
|
||||
{ status: 403, headers: { "Content-Type": "text/html" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Determine role
|
||||
const adminIds = getAdminIds();
|
||||
const role: "admin" | "player" = adminIds.includes(user.id) ? "admin" : "player";
|
||||
|
||||
// Create signed session cookie
|
||||
const sessionToken = serializeSignedToken("session", {
|
||||
discordId: user.id,
|
||||
username: user.username,
|
||||
avatar: user.avatar,
|
||||
role,
|
||||
expiresAt: Date.now() + SESSION_MAX_AGE,
|
||||
v: 1,
|
||||
}, secret);
|
||||
|
||||
logger.info("auth", `Login: ${user.username} (${user.id}) as ${role}`);
|
||||
|
||||
// Redirect to panel with session cookie
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: sanitizeReturnTo(statePayload.returnTo, baseUrl),
|
||||
"Set-Cookie": `${COOKIE_NAME}=${sessionToken}; ${buildCookieAttributes(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") {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Set-Cookie": `${COOKIE_NAME}=; ${buildCookieAttributes(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, enrolled: true });
|
||||
return jsonResponse({
|
||||
authenticated: true,
|
||||
enrolled: true,
|
||||
user: {
|
||||
discordId: session.discordId,
|
||||
username: session.username,
|
||||
avatar: session.avatar,
|
||||
role: session.role,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
114
api/src/routes/index.authz.test.ts
Normal file
114
api/src/routes/index.authz.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
|
||||
let currentSession: { discordId: string; username: string; role: "admin" | "player"; expiresAt: number } | null = null;
|
||||
|
||||
mock.module("./auth.routes", () => ({
|
||||
authRoutes: { name: "auth", handler: () => null },
|
||||
getSession: () => currentSession,
|
||||
}));
|
||||
|
||||
mock.module("./health.routes", () => ({
|
||||
healthRoutes: {
|
||||
name: "health",
|
||||
handler: ({ pathname }: { pathname: string }) =>
|
||||
pathname === "/api/health"
|
||||
? Response.json({ status: "ok" }, { status: 200 })
|
||||
: null,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("./stats.routes", () => ({
|
||||
statsRoutes: {
|
||||
name: "stats",
|
||||
handler: ({ pathname }: { pathname: string }) =>
|
||||
pathname === "/api/stats"
|
||||
? Response.json({ ok: true }, { status: 200 })
|
||||
: null,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("./actions.routes", () => ({ actionsRoutes: { name: "actions", handler: () => null } }));
|
||||
mock.module("./quests.routes", () => ({ questsRoutes: { name: "quests", handler: () => null } }));
|
||||
mock.module("./settings.routes", () => ({ settingsRoutes: { name: "settings", handler: () => null } }));
|
||||
mock.module("./guild-settings.routes", () => ({ guildSettingsRoutes: { name: "guild-settings", handler: () => null } }));
|
||||
mock.module("./items.routes", () => ({ itemsRoutes: { name: "items", handler: () => null } }));
|
||||
mock.module("./classes.routes", () => ({ classesRoutes: { name: "classes", handler: () => null } }));
|
||||
mock.module("./moderation.routes", () => ({ moderationRoutes: { name: "moderation", handler: () => null } }));
|
||||
mock.module("./transactions.routes", () => ({ transactionsRoutes: { name: "transactions", handler: () => null } }));
|
||||
mock.module("./lootdrops.routes", () => ({ lootdropsRoutes: { name: "lootdrops", handler: () => null } }));
|
||||
mock.module("./assets.routes", () => ({ assetsRoutes: { name: "assets", handler: () => null } }));
|
||||
mock.module("@shared/modules/user/user.service", () => ({
|
||||
userService: {
|
||||
getUserById: async (id: string) => ({ id, username: `user-${id}` }),
|
||||
},
|
||||
}));
|
||||
mock.module("@shared/modules/inventory/inventory.service", () => ({
|
||||
inventoryService: {
|
||||
getInventory: async (id: string) => [{ userId: id, itemId: 1, quantity: 1n }],
|
||||
},
|
||||
}));
|
||||
mock.module("@shared/lib/logger", () => ({
|
||||
logger: {
|
||||
error: () => { },
|
||||
info: () => { },
|
||||
warn: () => { },
|
||||
debug: () => { },
|
||||
},
|
||||
}));
|
||||
|
||||
import { handleRequest } from "./index";
|
||||
|
||||
describe("Route Authorization", () => {
|
||||
beforeEach(() => {
|
||||
currentSession = null;
|
||||
});
|
||||
|
||||
it("rejects unauthenticated protected API requests", async () => {
|
||||
const url = new URL("http://localhost/api/users/123");
|
||||
const res = await handleRequest(new Request(url, { method: "GET" }), url);
|
||||
|
||||
expect(res?.status).toBe(401);
|
||||
});
|
||||
|
||||
it("blocks players from admin user routes", async () => {
|
||||
currentSession = {
|
||||
discordId: "123",
|
||||
username: "player",
|
||||
role: "player",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
};
|
||||
|
||||
const url = new URL("http://localhost/api/users/456");
|
||||
const res = await handleRequest(new Request(url, { method: "GET" }), url);
|
||||
|
||||
expect(res?.status).toBe(403);
|
||||
});
|
||||
|
||||
it("allows players to access self-service API routes", async () => {
|
||||
currentSession = {
|
||||
discordId: "123",
|
||||
username: "player",
|
||||
role: "player",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
};
|
||||
|
||||
const url = new URL("http://localhost/api/me/inventory");
|
||||
const res = await handleRequest(new Request(url, { method: "GET" }), url);
|
||||
|
||||
expect(res?.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows admins to access admin user routes", async () => {
|
||||
currentSession = {
|
||||
discordId: "1",
|
||||
username: "admin",
|
||||
role: "admin",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
};
|
||||
|
||||
const url = new URL("http://localhost/api/users/456");
|
||||
const res = await handleRequest(new Request(url, { method: "GET" }), url);
|
||||
|
||||
expect(res?.status).toBe(200);
|
||||
});
|
||||
});
|
||||
102
api/src/routes/index.ts
Normal file
102
api/src/routes/index.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @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, getSession } 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/")) {
|
||||
const session = getSession(req);
|
||||
if (!session) {
|
||||
return errorResponse("Unauthorized", 401);
|
||||
}
|
||||
|
||||
// Player routes are explicitly allow-listed. Everything else is admin-only.
|
||||
const playerAllowedPrefixes = ["/api/stats", "/api/health", "/api/me"];
|
||||
const isPlayerAllowed = playerAllowedPrefixes.some(p => ctx.pathname.startsWith(p));
|
||||
|
||||
if (session.role === "player" && !isPlayerAllowed) {
|
||||
return errorResponse("Admin access required", 403);
|
||||
}
|
||||
}
|
||||
|
||||
// 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}?v=${Date.now()}`;
|
||||
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}?v=${Date.now()}`;
|
||||
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 { spawnLootdrop } = await import("../../../bot/modules/economy/lootdrop.handler");
|
||||
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 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 { deleteLootdrop } = await import("../../../bot/modules/economy/lootdrop.handler");
|
||||
const success = await 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()]).nullable().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);
|
||||
}
|
||||
}
|
||||
140
api/src/routes/users.routes.test.ts
Normal file
140
api/src/routes/users.routes.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
|
||||
let currentSession: { discordId: string; username: string; role: "admin" | "player"; expiresAt: number } | null = null;
|
||||
|
||||
const getUserById = mock(async (id: string) => ({
|
||||
id,
|
||||
username: id === "123" ? "player-one" : "user",
|
||||
level: 5,
|
||||
xp: 100n,
|
||||
balance: 250n,
|
||||
className: null,
|
||||
}));
|
||||
|
||||
const updateUser = mock(async (id: string, data: Record<string, unknown>) => ({
|
||||
id,
|
||||
...data,
|
||||
}));
|
||||
|
||||
const getInventory = mock(async (id: string) => [{ userId: id, itemId: 1, quantity: 2n }]);
|
||||
const addItem = mock(async (userId: string, itemId: number, quantity: bigint) => ({ userId, itemId, quantity }));
|
||||
const removeItem = mock(async () => undefined);
|
||||
|
||||
mock.module("./auth.routes", () => ({
|
||||
getSession: () => currentSession,
|
||||
}));
|
||||
|
||||
mock.module("@shared/modules/user/user.service", () => ({
|
||||
userService: {
|
||||
getUserById,
|
||||
updateUser,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("@shared/modules/inventory/inventory.service", () => ({
|
||||
inventoryService: {
|
||||
getInventory,
|
||||
addItem,
|
||||
removeItem,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module("@shared/lib/logger", () => ({
|
||||
logger: {
|
||||
error: () => { },
|
||||
info: () => { },
|
||||
warn: () => { },
|
||||
debug: () => { },
|
||||
},
|
||||
}));
|
||||
|
||||
import { usersRoutes } from "./users.routes";
|
||||
|
||||
describe("Users Routes", () => {
|
||||
beforeEach(() => {
|
||||
currentSession = {
|
||||
discordId: "123",
|
||||
username: "player",
|
||||
role: "player",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
};
|
||||
getUserById.mockClear();
|
||||
updateUser.mockClear();
|
||||
getInventory.mockClear();
|
||||
addItem.mockClear();
|
||||
removeItem.mockClear();
|
||||
});
|
||||
|
||||
it("serves the authenticated user through /api/me", async () => {
|
||||
const url = new URL("http://localhost/api/me");
|
||||
const res = await usersRoutes.handler({
|
||||
req: new Request(url, { method: "GET" }),
|
||||
url,
|
||||
method: "GET",
|
||||
pathname: "/api/me",
|
||||
});
|
||||
|
||||
expect(res?.status).toBe(200);
|
||||
expect(getUserById).toHaveBeenCalledWith("123");
|
||||
});
|
||||
|
||||
it("serves the authenticated user's inventory through /api/me/inventory", async () => {
|
||||
const url = new URL("http://localhost/api/me/inventory");
|
||||
const res = await usersRoutes.handler({
|
||||
req: new Request(url, { method: "GET" }),
|
||||
url,
|
||||
method: "GET",
|
||||
pathname: "/api/me/inventory",
|
||||
});
|
||||
|
||||
expect(res?.status).toBe(200);
|
||||
expect(getInventory).toHaveBeenCalledWith("123");
|
||||
});
|
||||
|
||||
it("validates user updates before calling the service", async () => {
|
||||
const url = new URL("http://localhost/api/users/123");
|
||||
const res = await usersRoutes.handler({
|
||||
req: new Request(url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ level: -1 }),
|
||||
}),
|
||||
url,
|
||||
method: "PUT",
|
||||
pathname: "/api/users/123",
|
||||
});
|
||||
|
||||
expect(res?.status).toBe(400);
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates inventory additions before calling the service", async () => {
|
||||
const url = new URL("http://localhost/api/users/123/inventory");
|
||||
const res = await usersRoutes.handler({
|
||||
req: new Request(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ itemId: 1, quantity: 0 }),
|
||||
}),
|
||||
url,
|
||||
method: "POST",
|
||||
pathname: "/api/users/123/inventory",
|
||||
});
|
||||
|
||||
expect(res?.status).toBe(400);
|
||||
expect(addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates inventory removal query params before calling the service", async () => {
|
||||
const url = new URL("http://localhost/api/users/123/inventory/1?amount=0");
|
||||
const res = await usersRoutes.handler({
|
||||
req: new Request(url, { method: "DELETE" }),
|
||||
url,
|
||||
method: "DELETE",
|
||||
pathname: "/api/users/123/inventory/1",
|
||||
});
|
||||
|
||||
expect(res?.status).toBe(400);
|
||||
expect(removeItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
302
api/src/routes/users.routes.ts
Normal file
302
api/src/routes/users.routes.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* @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,
|
||||
parseQuery,
|
||||
parseStringIdFromPath,
|
||||
withErrorHandling
|
||||
} from "./utils";
|
||||
import { InventoryAddSchema, InventoryRemoveQuerySchema, UpdateUserSchema, UserQuerySchema } from "./schemas";
|
||||
import { getSession } from "./auth.routes";
|
||||
|
||||
/**
|
||||
* Users routes handler.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/me - Get current authenticated user
|
||||
* - GET /api/me/inventory - Get current authenticated user's inventory
|
||||
* - 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")) {
|
||||
if (pathname === "/api/me" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const session = getSession(req);
|
||||
if (!session) {
|
||||
return errorResponse("Unauthorized", 401);
|
||||
}
|
||||
|
||||
const { userService } = await import("@shared/modules/user/user.service");
|
||||
const user = await userService.getUserById(session.discordId);
|
||||
|
||||
if (!user) {
|
||||
return errorResponse("User not found", 404);
|
||||
}
|
||||
|
||||
return jsonResponse(user);
|
||||
}, "fetch current user");
|
||||
}
|
||||
|
||||
if (pathname === "/api/me/inventory" && method === "GET") {
|
||||
return withErrorHandling(async () => {
|
||||
const session = getSession(req);
|
||||
if (!session) {
|
||||
return errorResponse("Unauthorized", 401);
|
||||
}
|
||||
|
||||
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
|
||||
const inventory = await inventoryService.getInventory(session.discordId);
|
||||
return jsonResponse({ inventory });
|
||||
}, "fetch current user inventory");
|
||||
}
|
||||
|
||||
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 queryParams = parseQuery(url, UserQuerySchema);
|
||||
if (queryParams instanceof Response) {
|
||||
return queryParams;
|
||||
}
|
||||
|
||||
const { search, sortBy, sortOrder, limit, offset } = queryParams;
|
||||
|
||||
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 parsed = await parseBody(req, UpdateUserSchema);
|
||||
if (parsed instanceof Response) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
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 (parsed.username !== undefined) updateData.username = parsed.username;
|
||||
if (parsed.balance !== undefined) updateData.balance = BigInt(parsed.balance);
|
||||
if (parsed.xp !== undefined) updateData.xp = BigInt(parsed.xp);
|
||||
if (parsed.level !== undefined) updateData.level = parsed.level;
|
||||
if (parsed.dailyStreak !== undefined) updateData.dailyStreak = parsed.dailyStreak;
|
||||
if (parsed.isActive !== undefined) updateData.isActive = parsed.isActive;
|
||||
if (parsed.settings !== undefined) updateData.settings = parsed.settings;
|
||||
if (parsed.classId !== undefined) {
|
||||
updateData.classId = parsed.classId === null ? null : BigInt(parsed.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 parsed = await parseBody(req, InventoryAddSchema);
|
||||
if (parsed instanceof Response) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const entry = await inventoryService.addItem(id, parsed.itemId, BigInt(parsed.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 queryParams = parseQuery(url, InventoryRemoveQuerySchema);
|
||||
if (queryParams instanceof Response) {
|
||||
return queryParams;
|
||||
}
|
||||
|
||||
await inventoryService.removeItem(userId, itemId, BigInt(queryParams.amount));
|
||||
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)
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
445
api/src/server.items.test.ts
Normal file
445
api/src/server.items.test.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
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 },
|
||||
getSession: () => ({ discordId: "123", username: "testuser", role: "admin", 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,26 @@ mock.module("bun", () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock auth (bypass authentication)
|
||||
mock.module("./routes/auth.routes", () => ({
|
||||
authRoutes: { name: "auth", handler: () => null },
|
||||
getSession: () => ({ discordId: "123", username: "testuser", role: "admin", 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 +142,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 +158,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 +186,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 });
|
||||
199
api/src/server.test.ts
Normal file
199
api/src/server.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { beforeEach, describe, test, expect, afterAll, mock } from "bun:test";
|
||||
import type { WebServerInstance } from "./server";
|
||||
|
||||
interface MockBotStats {
|
||||
bot: { name: string; avatarUrl: string | null };
|
||||
guilds: number;
|
||||
ping: number;
|
||||
cachedUsers: number;
|
||||
commandsRegistered: number;
|
||||
uptime: number;
|
||||
lastCommandTimestamp: number | null;
|
||||
}
|
||||
|
||||
// 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: 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(() => mockBuilder),
|
||||
query: {
|
||||
transactions: { findMany: mock(() => Promise.resolve([])) },
|
||||
moderationCases: { findMany: mock(() => Promise.resolve([])) },
|
||||
users: {
|
||||
findFirst: mock(() => Promise.resolve({ username: "test" })),
|
||||
findMany: mock(() => Promise.resolve([])),
|
||||
},
|
||||
lootdrops: { findMany: mock(() => Promise.resolve([])) },
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// 2. Mock Bot Stats Provider
|
||||
mock.module("../../bot/lib/clientStats", () => ({
|
||||
getClientStats: mock((): MockBotStats => ({
|
||||
bot: { name: "TestBot", avatarUrl: null },
|
||||
guilds: 5,
|
||||
ping: 42,
|
||||
cachedUsers: 100,
|
||||
commandsRegistered: 10,
|
||||
uptime: 3600,
|
||||
lastCommandTimestamp: Date.now(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// 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" }
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
let currentSession: { discordId: string; username: string; role: "admin" | "player"; expiresAt: number } | null = {
|
||||
discordId: "123",
|
||||
username: "admin-user",
|
||||
role: "admin",
|
||||
expiresAt: Date.now() + 3600000,
|
||||
};
|
||||
|
||||
// 4. Mock auth with a mutable session so tests can exercise authz paths.
|
||||
mock.module("./routes/auth.routes", () => ({
|
||||
authRoutes: { name: "auth", handler: () => null },
|
||||
getSession: () => currentSession,
|
||||
}));
|
||||
|
||||
// 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;
|
||||
|
||||
beforeEach(() => {
|
||||
currentSession = {
|
||||
discordId: "123",
|
||||
username: "admin-user",
|
||||
role: "admin",
|
||||
expiresAt: Date.now() + 3600000,
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (serverInstance) {
|
||||
await serverInstance.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("should reject unauthorized websocket requests", async () => {
|
||||
serverInstance = await createWebServer({ port, hostname });
|
||||
currentSession = null;
|
||||
|
||||
const response = await fetch(`http://${hostname}:${port}/ws`);
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(body).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
test("should accept websocket requests for authenticated sessions", async () => {
|
||||
if (!serverInstance) {
|
||||
serverInstance = await createWebServer({ port, hostname });
|
||||
}
|
||||
|
||||
const ws = new WebSocket(`ws://${hostname}:${port}/ws`);
|
||||
const opened = await new Promise<boolean>((resolve) => {
|
||||
const timeout = setTimeout(() => resolve(false), 1000);
|
||||
ws.addEventListener("open", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(true);
|
||||
});
|
||||
ws.addEventListener("error", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
|
||||
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||
ws.close();
|
||||
}
|
||||
|
||||
expect(opened).toBe(true);
|
||||
});
|
||||
|
||||
test("should return 200 for health check", async () => {
|
||||
if (!serverInstance) {
|
||||
serverInstance = await createWebServer({ port, hostname });
|
||||
}
|
||||
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");
|
||||
});
|
||||
|
||||
describe("Administrative Actions", () => {
|
||||
test("should allow administrative actions for admin sessions", async () => {
|
||||
const response = await fetch(`http://${hostname}:${port}/api/actions/reload-commands`, {
|
||||
method: "POST"
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("should reject administrative actions for player sessions", async () => {
|
||||
currentSession = {
|
||||
discordId: "456",
|
||||
username: "player-user",
|
||||
role: "player",
|
||||
expiresAt: Date.now() + 3600000,
|
||||
};
|
||||
|
||||
const response = await fetch(`http://${hostname}:${port}/api/actions/reload-commands`, {
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const data = await response.json() as { error: string };
|
||||
expect(data.error).toBe("Admin access required");
|
||||
});
|
||||
|
||||
test("should reject maintenance mode with invalid payload", async () => {
|
||||
const response = await fetch(`http://${hostname}:${port}/api/actions/maintenance-mode`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ not_enabled: true }) // Wrong field
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json() as { error: string };
|
||||
expect(data.error).toBe("Invalid payload");
|
||||
});
|
||||
});
|
||||
});
|
||||
242
api/src/server.ts
Normal file
242
api/src/server.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* @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 type { ServerWebSocket } from "bun";
|
||||
import { logger } from "@shared/lib/logger";
|
||||
import { handleRequest } from "./routes";
|
||||
import { getFullDashboardStats } from "./routes/stats.helper";
|
||||
import { join } from "path";
|
||||
import { gameServer } from "./games/GameServer";
|
||||
import type { WsConnectionData } from "./games/GameServer";
|
||||
import { getSession } from "./routes/auth.routes";
|
||||
import { GameWsClientSchema } from "./games/types";
|
||||
|
||||
// Register game plugins
|
||||
import { gameRegistry } from "@shared/games/registry";
|
||||
import { chessPlugin } from "@shared/games/chess/chess.plugin";
|
||||
import { blackjackPlugin } from "@shared/games/blackjack/blackjack.plugin";
|
||||
gameRegistry.register(chessPlugin);
|
||||
gameRegistry.register(blackjackPlugin);
|
||||
|
||||
const WS_CONFIG = {
|
||||
MAX_CONNECTIONS: 200,
|
||||
MAX_PAYLOAD_BYTES: 16384,
|
||||
IDLE_TIMEOUT_SECONDS: 60,
|
||||
STATS_BROADCAST_INTERVAL_MS: 5000,
|
||||
} as const;
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
export interface WebServerConfig {
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
export interface WebServerInstance {
|
||||
server: ReturnType<typeof serve>;
|
||||
stop: () => Promise<void>;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
let activeConnections = 0;
|
||||
let statsBroadcastInterval: Timer | undefined;
|
||||
|
||||
const server = serve<WsConnectionData>({
|
||||
port,
|
||||
hostname,
|
||||
async fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (url.pathname === "/ws") {
|
||||
if (activeConnections >= WS_CONFIG.MAX_CONNECTIONS) {
|
||||
logger.warn("web", `Connection rejected: limit reached (${activeConnections}/${WS_CONFIG.MAX_CONNECTIONS})`);
|
||||
return new Response("Connection limit reached", { status: 429 });
|
||||
}
|
||||
|
||||
const session = getSession(req);
|
||||
if (!session) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const success = server.upgrade(req, {
|
||||
data: {
|
||||
session: {
|
||||
discordId: session.discordId,
|
||||
username: session.username,
|
||||
role: session.role,
|
||||
},
|
||||
rooms: new Set<string>(),
|
||||
},
|
||||
});
|
||||
if (success) return undefined;
|
||||
return new Response("WebSocket upgrade failed", { status: 400 });
|
||||
}
|
||||
|
||||
const response = await handleRequest(req, url);
|
||||
if (response) return response;
|
||||
|
||||
const panelDistDir = join(import.meta.dir, "../../panel/dist");
|
||||
const staticResponse = await servePanelStatic(url.pathname, panelDistDir);
|
||||
if (staticResponse) return staticResponse;
|
||||
|
||||
return new Response("Not Found", { status: 404 });
|
||||
},
|
||||
|
||||
websocket: {
|
||||
open(ws: ServerWebSocket<WsConnectionData>) {
|
||||
activeConnections++;
|
||||
ws.subscribe("dashboard");
|
||||
ws.subscribe("lobby");
|
||||
logger.debug("web", `Client connected: ${ws.data.session.discordId}. Total: ${activeConnections}`);
|
||||
|
||||
getFullDashboardStats().then(stats => {
|
||||
ws.send(JSON.stringify({ type: "STATS_UPDATE", data: stats }));
|
||||
});
|
||||
|
||||
gameServer.handleOpen(ws);
|
||||
|
||||
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);
|
||||
}
|
||||
}, WS_CONFIG.STATS_BROADCAST_INTERVAL_MS);
|
||||
}
|
||||
},
|
||||
|
||||
async message(ws: ServerWebSocket<WsConnectionData>, message) {
|
||||
try {
|
||||
const messageStr = message.toString();
|
||||
|
||||
if (messageStr.length > WS_CONFIG.MAX_PAYLOAD_BYTES) {
|
||||
logger.error("web", "Payload exceeded maximum limit");
|
||||
return;
|
||||
}
|
||||
|
||||
const rawData = JSON.parse(messageStr);
|
||||
|
||||
// Handle dashboard-level messages (PING, etc.)
|
||||
if (rawData && typeof rawData === "object" && rawData.type === "PING") {
|
||||
ws.send(JSON.stringify({ type: "PONG" }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Route game messages — try to parse as a game client message
|
||||
const gameCheck = GameWsClientSchema.safeParse(rawData);
|
||||
if (gameCheck.success) {
|
||||
gameServer.handleMessage(ws, rawData).catch(err =>
|
||||
logger.error("web", `Game message handler error: ${err}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to full WsMessageSchema for dashboard messages that aren't PING
|
||||
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);
|
||||
}
|
||||
// Nothing else to do for PONG/STATS_UPDATE/NEW_EVENT from clients
|
||||
} catch (e) {
|
||||
logger.error("web", "Failed to handle message", e);
|
||||
}
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<WsConnectionData>) {
|
||||
activeConnections--;
|
||||
ws.unsubscribe("dashboard");
|
||||
ws.unsubscribe("lobby");
|
||||
logger.debug("web", `Client disconnected: ${ws.data.session.discordId}. Total remaining: ${activeConnections}`);
|
||||
|
||||
gameServer.handleClose(ws);
|
||||
|
||||
if (activeConnections === 0 && statsBroadcastInterval) {
|
||||
clearInterval(statsBroadcastInterval);
|
||||
statsBroadcastInterval = undefined;
|
||||
}
|
||||
},
|
||||
maxPayloadLength: WS_CONFIG.MAX_PAYLOAD_BYTES,
|
||||
idleTimeout: WS_CONFIG.IDLE_TIMEOUT_SECONDS,
|
||||
},
|
||||
});
|
||||
|
||||
// Wire gameServer to Bun server for pub/sub publishing
|
||||
gameServer.setServer(server);
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 "@modules/moderation/prune.service";
|
||||
import {
|
||||
getConfirmationMessage,
|
||||
getProgressEmbed,
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
getPruneWarningEmbed,
|
||||
getCancelledEmbed
|
||||
} from "@/modules/moderation/prune.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
import { PRUNE_CUSTOM_IDS } from "@modules/moderation/prune.types";
|
||||
|
||||
export const prune = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -38,142 +40,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 === PRUNE_CUSTOM_IDS.CANCEL) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
|
||||
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 { terminalService } from "@modules/system/terminal.service";
|
||||
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}>` });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
108
bot/commands/economy/trivia.ts
Normal file
108
bot/commands/economy/trivia.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { triviaService } from "@shared/modules/trivia/trivia.service";
|
||||
import { getTriviaQuestionView } from "@/modules/trivia/trivia.view";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { TriviaCategory } from "@shared/lib/constants";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
|
||||
export const trivia = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("trivia")
|
||||
.setDescription("Play trivia to win currency! Answer correctly within the time limit.")
|
||||
.addStringOption(option =>
|
||||
option.setName('category')
|
||||
.setDescription('Select a specific category')
|
||||
.setRequired(false)
|
||||
.addChoices(
|
||||
{ name: 'General Knowledge', value: String(TriviaCategory.GENERAL_KNOWLEDGE) },
|
||||
{ name: 'Books', value: String(TriviaCategory.BOOKS) },
|
||||
{ name: 'Film', value: String(TriviaCategory.FILM) },
|
||||
{ name: 'Music', value: String(TriviaCategory.MUSIC) },
|
||||
{ name: 'Video Games', value: String(TriviaCategory.VIDEO_GAMES) },
|
||||
{ name: 'Science & Nature', value: String(TriviaCategory.SCIENCE_NATURE) },
|
||||
{ name: 'Computers', value: String(TriviaCategory.COMPUTERS) },
|
||||
{ name: 'Mathematics', value: String(TriviaCategory.MATHEMATICS) },
|
||||
{ name: 'Mythology', value: String(TriviaCategory.MYTHOLOGY) },
|
||||
{ name: 'Sports', value: String(TriviaCategory.SPORTS) },
|
||||
{ name: 'Geography', value: String(TriviaCategory.GEOGRAPHY) },
|
||||
{ name: 'History', value: String(TriviaCategory.HISTORY) },
|
||||
{ name: 'Politics', value: String(TriviaCategory.POLITICS) },
|
||||
{ name: 'Art', value: String(TriviaCategory.ART) },
|
||||
{ name: 'Animals', value: String(TriviaCategory.ANIMALS) },
|
||||
{ name: 'Anime & Manga', value: String(TriviaCategory.ANIME_MANGA) },
|
||||
)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
try {
|
||||
const categoryId = interaction.options.getString('category');
|
||||
|
||||
// Check if user can play BEFORE deferring
|
||||
const canPlay = await triviaService.canPlayTrivia(interaction.user.id);
|
||||
|
||||
if (!canPlay.canPlay) {
|
||||
// Cooldown error - ephemeral
|
||||
const timestamp = Math.floor(canPlay.nextAvailable!.getTime() / 1000);
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You're on cooldown! Try again <t:${timestamp}:R>.`
|
||||
)],
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// User can play - 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
|
||||
);
|
||||
|
||||
// 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) {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed(error.message)],
|
||||
ephemeral: true
|
||||
});
|
||||
} else {
|
||||
console.error("Error in trivia command:", error);
|
||||
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
|
||||
|
||||
@@ -1,22 +1,83 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { SlashCommandBuilder, MessageFlags, ComponentType } from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { createWarningEmbed } from "@lib/embeds";
|
||||
import { getInventoryEmbed } from "@/modules/inventory/inventory.view";
|
||||
import { createWarningEmbed, createErrorEmbed } from "@lib/embeds";
|
||||
import {
|
||||
getInventoryListMessage,
|
||||
getEmptyInventoryMessage,
|
||||
getItemDetailMessage,
|
||||
getDiscardConfirmMessage,
|
||||
appendUseBackButton,
|
||||
sortInventoryItems,
|
||||
ITEMS_PER_PAGE,
|
||||
type InventoryEntry,
|
||||
} from "@/modules/inventory/inventory.view";
|
||||
import { getLootboxResultMessage } from "@/modules/inventory/inventory.view";
|
||||
import {
|
||||
parseInventoryCustomId,
|
||||
executeItemUse,
|
||||
} from "@/modules/inventory/inventory.interaction";
|
||||
import { UserError } from "@shared/lib/errors";
|
||||
|
||||
export const inventory = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("inventory")
|
||||
.setDescription("View your or another user's inventory")
|
||||
.addUserOption(option =>
|
||||
option.setName("user")
|
||||
.setDescription("User to view")
|
||||
.setRequired(false)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("list")
|
||||
.setDescription("View your or another user's inventory")
|
||||
.addUserOption(option =>
|
||||
option.setName("user")
|
||||
.setDescription("User to view")
|
||||
.setRequired(false)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("view")
|
||||
.setDescription("View details of a specific item")
|
||||
.addNumberOption(option =>
|
||||
option.setName("item")
|
||||
.setDescription("The item to view")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
|
||||
const viewerId = interaction.user.id;
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
if (subcommand === "view") {
|
||||
// Direct item detail view
|
||||
const itemId = interaction.options.getNumber("item", true);
|
||||
const user = await userService.getOrCreateUser(viewerId, interaction.user.username);
|
||||
if (!user) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("Failed to load user data.", "Error")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = await inventoryService.getInventory(user.id.toString());
|
||||
const entry = entries.find((e: any) => e.item.id === itemId);
|
||||
if (!entry) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("Item not found in your inventory.", "Not Found")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const ownerId = user.id.toString();
|
||||
let currentPage = 0;
|
||||
let selectedItemId: number | null = itemId;
|
||||
|
||||
const response = await interaction.editReply(
|
||||
getItemDetailMessage(entry as InventoryEntry, viewerId, ownerId) as any
|
||||
);
|
||||
|
||||
await setupCollector(interaction, response, viewerId, ownerId, user.username, currentPage, selectedItemId);
|
||||
return;
|
||||
}
|
||||
|
||||
// "list" subcommand
|
||||
const targetUser = interaction.options.getUser("user") || interaction.user;
|
||||
|
||||
if (targetUser.bot) {
|
||||
@@ -30,15 +91,232 @@ export const inventory = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
const items = await inventoryService.getInventory(user.id.toString());
|
||||
const ownerId = user.id.toString();
|
||||
const entries = await inventoryService.getInventory(ownerId);
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("Inventory is empty.", `${user.username}'s Inventory`)] });
|
||||
if (!entries || entries.length === 0) {
|
||||
await interaction.editReply(getEmptyInventoryMessage(user.username) as any);
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = getInventoryEmbed(items, user.username);
|
||||
let currentPage = 0;
|
||||
let selectedItemId: number | null = null;
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
const response = await interaction.editReply(
|
||||
getInventoryListMessage(entries as InventoryEntry[], user.username, currentPage, viewerId, ownerId) as any
|
||||
);
|
||||
|
||||
await setupCollector(interaction, response, viewerId, ownerId, user.username, currentPage, selectedItemId);
|
||||
},
|
||||
autocomplete: async (interaction) => {
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
const userId = interaction.user.id;
|
||||
const results = await inventoryService.getAutocompleteItems(userId, focusedValue);
|
||||
await interaction.respond(results);
|
||||
},
|
||||
});
|
||||
|
||||
async function setupCollector(
|
||||
interaction: any,
|
||||
response: any,
|
||||
viewerId: string,
|
||||
ownerId: string,
|
||||
username: string,
|
||||
initialPage: number,
|
||||
initialItemId: number | null,
|
||||
) {
|
||||
let currentPage = initialPage;
|
||||
let selectedItemId = initialItemId;
|
||||
|
||||
const collector = response.createMessageComponentCollector({
|
||||
time: 120_000,
|
||||
});
|
||||
|
||||
collector.on("collect", async (i: any) => {
|
||||
if (i.user.id !== viewerId) return;
|
||||
|
||||
const parsed = parseInventoryCustomId(i.customId);
|
||||
if (!parsed) return;
|
||||
|
||||
try {
|
||||
await i.deferUpdate();
|
||||
|
||||
// Re-fetch inventory for fresh data
|
||||
const entries = await inventoryService.getInventory(ownerId);
|
||||
const sorted = sortInventoryItems(entries as InventoryEntry[]);
|
||||
|
||||
switch (parsed.action) {
|
||||
case "select": {
|
||||
const itemId = parseInt(i.values[0]);
|
||||
const entry = sorted.find(e => e.item.id === itemId);
|
||||
if (!entry) break;
|
||||
selectedItemId = itemId;
|
||||
await interaction.editReply(
|
||||
getItemDetailMessage(entry, viewerId, ownerId)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "prev": {
|
||||
currentPage = Math.max(0, currentPage - 1);
|
||||
selectedItemId = null;
|
||||
await interaction.editReply(
|
||||
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "next": {
|
||||
currentPage = currentPage + 1;
|
||||
selectedItemId = null;
|
||||
await interaction.editReply(
|
||||
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "back": {
|
||||
selectedItemId = null;
|
||||
if (sorted.length === 0) {
|
||||
await interaction.editReply(getEmptyInventoryMessage(username));
|
||||
} else {
|
||||
await interaction.editReply(
|
||||
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "use": {
|
||||
if (viewerId !== ownerId || !selectedItemId) break;
|
||||
try {
|
||||
const result = await executeItemUse(i, viewerId, selectedItemId);
|
||||
const message = getLootboxResultMessage(result.results, result.item);
|
||||
await interaction.editReply(appendUseBackButton(message, viewerId) as any);
|
||||
} catch (error) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(error.message)],
|
||||
components: [],
|
||||
flags: undefined,
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "use_back": {
|
||||
// Return from use result to detail or list
|
||||
if (!selectedItemId) break;
|
||||
const entry = sorted.find(e => e.item.id === selectedItemId);
|
||||
if (entry) {
|
||||
await interaction.editReply(
|
||||
getItemDetailMessage(entry, viewerId, ownerId)
|
||||
);
|
||||
} else {
|
||||
selectedItemId = null;
|
||||
if (sorted.length === 0) {
|
||||
await interaction.editReply(getEmptyInventoryMessage(username));
|
||||
} else {
|
||||
await interaction.editReply(
|
||||
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "discard": {
|
||||
if (viewerId !== ownerId || !selectedItemId) break;
|
||||
const entry = sorted.find(e => e.item.id === selectedItemId);
|
||||
if (!entry) break;
|
||||
await interaction.editReply(
|
||||
getDiscardConfirmMessage(entry, viewerId)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "discard_confirm": {
|
||||
if (viewerId !== ownerId || !selectedItemId) break;
|
||||
try {
|
||||
await inventoryService.removeItem(ownerId, selectedItemId, 1n);
|
||||
|
||||
const freshEntries = await inventoryService.getInventory(ownerId);
|
||||
const freshSorted = sortInventoryItems(freshEntries as InventoryEntry[]);
|
||||
const freshEntry = freshSorted.find(e => e.item.id === selectedItemId);
|
||||
|
||||
if (freshEntry) {
|
||||
await interaction.editReply(
|
||||
getItemDetailMessage(freshEntry, viewerId, ownerId)
|
||||
);
|
||||
} else {
|
||||
selectedItemId = null;
|
||||
if (freshSorted.length === 0) {
|
||||
await interaction.editReply(getEmptyInventoryMessage(username));
|
||||
} else {
|
||||
await interaction.editReply(
|
||||
getInventoryListMessage(freshSorted, username, currentPage, viewerId, ownerId)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(error.message)],
|
||||
components: [],
|
||||
flags: undefined,
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "discard_cancel": {
|
||||
if (!selectedItemId) break;
|
||||
const entry = sorted.find(e => e.item.id === selectedItemId);
|
||||
if (!entry) break;
|
||||
await interaction.editReply(
|
||||
getItemDetailMessage(entry, viewerId, ownerId)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Inventory interaction error:", error);
|
||||
}
|
||||
});
|
||||
|
||||
collector.on("end", async () => {
|
||||
try {
|
||||
// Re-render current view as static (no interactive components)
|
||||
const entries = await inventoryService.getInventory(ownerId);
|
||||
const sorted = sortInventoryItems(entries as InventoryEntry[]);
|
||||
|
||||
if (selectedItemId) {
|
||||
const entry = sorted.find(e => e.item.id === selectedItemId);
|
||||
if (entry) {
|
||||
// Show detail view without action buttons
|
||||
const msg = getItemDetailMessage(entry, viewerId, ownerId);
|
||||
// Replace components with empty to remove buttons but keep container content
|
||||
await interaction.editReply(msg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (sorted.length === 0) {
|
||||
await interaction.editReply(getEmptyInventoryMessage(username));
|
||||
} else {
|
||||
await interaction.editReply(
|
||||
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// If re-rendering fails, at least try to clear gracefully
|
||||
interaction.editReply({ components: [] }).catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,10 +3,9 @@ import { SlashCommandBuilder } from "discord.js";
|
||||
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 { getLootboxResultMessage } from "@/modules/inventory/inventory.view";
|
||||
import { withCommandErrorHandling } from "@lib/commandUtils";
|
||||
import { getGuildConfig } from "@shared/lib/config";
|
||||
|
||||
export const use = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -19,54 +18,49 @@ 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 message = getLootboxResultMessage(result.results, result.item);
|
||||
await interaction.editReply(message as any);
|
||||
}
|
||||
|
||||
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,99 @@
|
||||
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";
|
||||
import { QUEST_CUSTOM_IDS } from "@modules/quest/quest.types";
|
||||
|
||||
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;
|
||||
let currentView: 'active' | 'available' = 'active';
|
||||
let currentPage = 0;
|
||||
|
||||
if (!userQuests || userQuests.length === 0) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("You have no active quests.", "Quest Log")] });
|
||||
return;
|
||||
}
|
||||
const updateView = async (viewType: 'active' | 'available', page: number = 0) => {
|
||||
currentView = viewType;
|
||||
currentPage = page;
|
||||
|
||||
const embed = getQuestListEmbed(userQuests);
|
||||
const userQuests = await questService.getUserQuests(userId);
|
||||
const availableQuests = await questService.getAvailableQuests(userId);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
|
||||
const totalItems = viewType === 'active' ? activeQuests.length : availableQuests.length;
|
||||
|
||||
const containers = viewType === 'active'
|
||||
? getQuestListComponents(userQuests, page)
|
||||
: getAvailableQuestsComponents(availableQuests, page);
|
||||
|
||||
const actionRows = getQuestActionRows(viewType, totalItems, page);
|
||||
|
||||
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_CUSTOM_IDS.VIEW_ACTIVE) {
|
||||
await i.deferUpdate();
|
||||
await updateView('active', 0);
|
||||
} else if (i.customId === QUEST_CUSTOM_IDS.VIEW_AVAILABLE) {
|
||||
await i.deferUpdate();
|
||||
await updateView('available', 0);
|
||||
} else if (i.customId === QUEST_CUSTOM_IDS.PAGE_PREV) {
|
||||
await i.deferUpdate();
|
||||
await updateView(currentView, Math.max(0, currentPage - 1));
|
||||
} else if (i.customId === QUEST_CUSTOM_IDS.PAGE_NEXT) {
|
||||
await i.deferUpdate();
|
||||
await updateView(currentView, currentPage + 1);
|
||||
} else if (i.customId.startsWith(QUEST_CUSTOM_IDS.ACCEPT_PREFIX)) {
|
||||
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
|
||||
});
|
||||
|
||||
// Stay on current view/page but refresh (accepted quest disappears from available)
|
||||
await updateView(currentView, currentPage);
|
||||
}
|
||||
} 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) {
|
||||
|
||||
@@ -15,7 +15,7 @@ const event: Event<Events.MessageCreate> = {
|
||||
levelingService.processChatXp(message.author.id);
|
||||
|
||||
// Activity Tracking for Lootdrops
|
||||
import("@shared/modules/economy/lootdrop.service").then(m => m.lootdropService.processMessage(message));
|
||||
import("@modules/economy/lootdrop.handler").then(m => m.processLootdropMessage(message));
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
15
bot/index.ts
15
bot/index.ts
@@ -1,8 +1,14 @@
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { env } from "@shared/lib/env";
|
||||
import { join } from "node:path";
|
||||
import { initializeConfig } from "@shared/lib/config";
|
||||
import { registerDomainEventListeners } from "@shared/lib/eventWiring";
|
||||
import { createWebServer } from "../api/src/server";
|
||||
|
||||
import { startWebServerFromRoot } from "../web/src/server";
|
||||
// Initialize config from database
|
||||
await initializeConfig();
|
||||
|
||||
// Register domain event listeners before loading commands/events
|
||||
registerDomainEventListeners();
|
||||
|
||||
// Load commands & events
|
||||
await AuroraClient.loadCommands();
|
||||
@@ -14,12 +20,11 @@ console.log("🌐 Starting web server...");
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
const webProjectPath = join(import.meta.dir, "../web");
|
||||
const webPort = Number(process.env.WEB_PORT) || 3000;
|
||||
const webHost = process.env.HOST || "0.0.0.0";
|
||||
|
||||
// Start web server in the same process
|
||||
const webServer = await startWebServerFromRoot(webProjectPath, {
|
||||
const webServer = await createWebServer({
|
||||
port: webPort,
|
||||
hostname: webHost,
|
||||
});
|
||||
@@ -46,4 +51,4 @@ const shutdownHandler = async () => {
|
||||
};
|
||||
|
||||
process.on("SIGINT", shutdownHandler);
|
||||
process.on("SIGTERM", shutdownHandler);
|
||||
process.on("SIGTERM", shutdownHandler);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
|
||||
import { TRADE_CUSTOM_IDS } from "@modules/trade/trade.types";
|
||||
import { SHOP_CUSTOM_IDS, LOOTDROP_CUSTOM_IDS } from "@modules/economy/economy.types";
|
||||
import { ITEM_WIZARD_CUSTOM_IDS } from "@modules/admin/item_wizard.types";
|
||||
import { TRIVIA_CUSTOM_IDS } from "@modules/trivia/trivia.types";
|
||||
import { ENROLLMENT_CUSTOM_IDS } from "@modules/user/user.types";
|
||||
import { FEEDBACK_CUSTOM_IDS } from "@modules/feedback/feedback.types";
|
||||
|
||||
// Union type for all component interactions
|
||||
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
||||
|
||||
// Type for the handler function that modules export
|
||||
type InteractionHandler = (interaction: ComponentInteraction) => Promise<void>;
|
||||
|
||||
// Type for the dynamically imported module containing the handler
|
||||
interface InteractionModule {
|
||||
[key: string]: (...args: any[]) => Promise<void> | any;
|
||||
@@ -21,40 +24,45 @@ interface InteractionRoute {
|
||||
export const interactionRoutes: InteractionRoute[] = [
|
||||
// --- TRADE MODULE ---
|
||||
{
|
||||
predicate: (i) => i.customId.startsWith("trade_") || i.customId === "amount",
|
||||
predicate: (i) => i.customId.startsWith(TRADE_CUSTOM_IDS.PREFIX) || i.customId === TRADE_CUSTOM_IDS.MONEY_AMOUNT_FIELD,
|
||||
handler: () => import("@/modules/trade/trade.interaction"),
|
||||
method: 'handleTradeInteraction'
|
||||
},
|
||||
|
||||
// --- ECONOMY MODULE ---
|
||||
{
|
||||
predicate: (i) => i.isButton() && i.customId.startsWith("shop_buy_"),
|
||||
predicate: (i) => i.isButton() && i.customId.startsWith(SHOP_CUSTOM_IDS.BUY_PREFIX),
|
||||
handler: () => import("@/modules/economy/shop.interaction"),
|
||||
method: 'handleShopInteraction'
|
||||
},
|
||||
{
|
||||
predicate: (i) => i.isButton() && i.customId.startsWith("lootdrop_"),
|
||||
predicate: (i) => i.isButton() && i.customId.startsWith(LOOTDROP_CUSTOM_IDS.PREFIX),
|
||||
handler: () => import("@/modules/economy/lootdrop.interaction"),
|
||||
method: 'handleLootdropInteraction'
|
||||
},
|
||||
{
|
||||
predicate: (i) => i.isButton() && i.customId.startsWith(TRIVIA_CUSTOM_IDS.PREFIX),
|
||||
handler: () => import("@/modules/trivia/trivia.interaction"),
|
||||
method: 'handleTriviaInteraction'
|
||||
},
|
||||
|
||||
// --- ADMIN MODULE ---
|
||||
{
|
||||
predicate: (i) => i.customId.startsWith("createitem_"),
|
||||
predicate: (i) => i.customId.startsWith(ITEM_WIZARD_CUSTOM_IDS.PREFIX),
|
||||
handler: () => import("@/modules/admin/item_wizard"),
|
||||
method: 'handleItemWizardInteraction'
|
||||
},
|
||||
|
||||
// --- USER MODULE ---
|
||||
{
|
||||
predicate: (i) => i.isButton() && i.customId === "enrollment",
|
||||
predicate: (i) => i.isButton() && i.customId === ENROLLMENT_CUSTOM_IDS.ENROLL,
|
||||
handler: () => import("@/modules/user/enrollment.interaction"),
|
||||
method: 'handleEnrollmentInteraction'
|
||||
},
|
||||
|
||||
// --- FEEDBACK MODULE ---
|
||||
{
|
||||
predicate: (i) => i.customId.startsWith("feedback_"),
|
||||
predicate: (i) => i.customId.startsWith(FEEDBACK_CUSTOM_IDS.PREFIX),
|
||||
handler: () => import("@/modules/feedback/feedback.interaction"),
|
||||
method: 'handleFeedbackInteraction'
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { items } from "@db/schema";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import type { ItemUsageData, ItemEffect } from "@shared/lib/types";
|
||||
import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
|
||||
import type { DraftItem } from "./item_wizard.types";
|
||||
import { ITEM_WIZARD_CUSTOM_IDS, type DraftItem } from "./item_wizard.types";
|
||||
import { ItemType, EffectType } from "@shared/lib/constants";
|
||||
|
||||
// --- Types ---
|
||||
@@ -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: "",
|
||||
@@ -41,13 +41,13 @@ export const renderWizard = (userId: string, isDraft = true) => {
|
||||
export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
// Only handle createitem interactions
|
||||
if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return;
|
||||
if (!interaction.customId.startsWith("createitem_")) return;
|
||||
if (!interaction.customId.startsWith(ITEM_WIZARD_CUSTOM_IDS.PREFIX)) return;
|
||||
|
||||
const userId = interaction.user.id;
|
||||
let draft = draftSession.get(userId);
|
||||
|
||||
// Special case for Cancel - doesn't need draft checks usually, but we want to clear it
|
||||
if (interaction.customId === "createitem_cancel") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.CANCEL) {
|
||||
draftSession.delete(userId);
|
||||
if (interaction.isMessageComponent()) {
|
||||
await interaction.update({ content: "❌ Item creation cancelled.", embeds: [], components: [] });
|
||||
@@ -59,7 +59,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
if (!draft) {
|
||||
if (interaction.isMessageComponent()) {
|
||||
// Create one implicitly to prevent crashes, or warn user
|
||||
if (interaction.customId === "createitem_start") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.START) {
|
||||
// Allow start
|
||||
} else {
|
||||
await interaction.reply({ content: "⚠️ Session expired. Please run `/createitem` again.", ephemeral: true });
|
||||
@@ -81,7 +81,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
// --- Routing ---
|
||||
|
||||
// 1. Details Modal
|
||||
if (interaction.customId === "createitem_details") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.DETAILS) {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = getDetailsModal(draft);
|
||||
await interaction.showModal(modal);
|
||||
@@ -89,7 +89,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
}
|
||||
|
||||
// 2. Economy Modal
|
||||
if (interaction.customId === "createitem_economy") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.ECONOMY) {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = getEconomyModal(draft);
|
||||
await interaction.showModal(modal);
|
||||
@@ -97,7 +97,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
}
|
||||
|
||||
// 3. Visuals Modal
|
||||
if (interaction.customId === "createitem_visuals") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.VISUALS) {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = getVisualsModal(draft);
|
||||
await interaction.showModal(modal);
|
||||
@@ -105,14 +105,14 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
}
|
||||
|
||||
// 4. Type Toggle (Start Select Menu)
|
||||
if (interaction.customId === "createitem_type_toggle") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.TYPE_TOGGLE) {
|
||||
if (!interaction.isButton()) return;
|
||||
const { components } = getItemTypeSelection();
|
||||
await interaction.update({ components }); // Temporary view
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.customId === "createitem_select_type") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.SELECT_TYPE) {
|
||||
if (!interaction.isStringSelectMenu()) return;
|
||||
const selected = interaction.values[0];
|
||||
if (selected) {
|
||||
@@ -125,14 +125,14 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
}
|
||||
|
||||
// 5. Add Effect Flow
|
||||
if (interaction.customId === "createitem_addeffect_start") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.ADD_EFFECT_START) {
|
||||
if (!interaction.isButton()) return;
|
||||
const { components } = getEffectTypeSelection();
|
||||
await interaction.update({ components });
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.customId === "createitem_select_effect_type") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.SELECT_EFFECT_TYPE) {
|
||||
if (!interaction.isStringSelectMenu()) return;
|
||||
const effectType = interaction.values[0];
|
||||
if (!effectType) return;
|
||||
@@ -149,7 +149,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
}
|
||||
|
||||
// Toggle Consume
|
||||
if (interaction.customId === "createitem_toggle_consume") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.TOGGLE_CONSUME) {
|
||||
if (!interaction.isButton()) return;
|
||||
draft.usageData.consume = !draft.usageData.consume;
|
||||
const payload = renderWizard(userId);
|
||||
@@ -159,43 +159,43 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
|
||||
// 6. Handle Modal Submits
|
||||
if (interaction.isModalSubmit()) {
|
||||
if (interaction.customId === "createitem_modal_details") {
|
||||
draft.name = interaction.fields.getTextInputValue("name");
|
||||
draft.description = interaction.fields.getTextInputValue("desc");
|
||||
draft.rarity = interaction.fields.getTextInputValue("rarity");
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_DETAILS) {
|
||||
draft.name = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_NAME);
|
||||
draft.description = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_DESC);
|
||||
draft.rarity = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_RARITY);
|
||||
}
|
||||
else if (interaction.customId === "createitem_modal_economy") {
|
||||
const price = parseInt(interaction.fields.getTextInputValue("price"));
|
||||
else if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_ECONOMY) {
|
||||
const price = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_PRICE));
|
||||
draft.price = isNaN(price) || price === 0 ? null : price;
|
||||
}
|
||||
else if (interaction.customId === "createitem_modal_visuals") {
|
||||
draft.iconUrl = interaction.fields.getTextInputValue("icon");
|
||||
draft.imageUrl = interaction.fields.getTextInputValue("image");
|
||||
else if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_VISUALS) {
|
||||
draft.iconUrl = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_ICON);
|
||||
draft.imageUrl = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_IMAGE);
|
||||
}
|
||||
else if (interaction.customId === "createitem_modal_effect") {
|
||||
else if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_EFFECT) {
|
||||
const type = draft.pendingEffectType;
|
||||
if (type) {
|
||||
let effect: ItemEffect | null = null;
|
||||
|
||||
if (type === EffectType.ADD_XP || type === EffectType.ADD_BALANCE) {
|
||||
const amount = parseInt(interaction.fields.getTextInputValue("amount"));
|
||||
const amount = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_AMOUNT));
|
||||
if (!isNaN(amount)) effect = { type: type as any, amount };
|
||||
}
|
||||
else if (type === EffectType.REPLY_MESSAGE) {
|
||||
effect = { type: EffectType.REPLY_MESSAGE, message: interaction.fields.getTextInputValue("message") };
|
||||
effect = { type: EffectType.REPLY_MESSAGE, message: interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_MESSAGE) };
|
||||
}
|
||||
else if (type === EffectType.XP_BOOST) {
|
||||
const multiplier = parseFloat(interaction.fields.getTextInputValue("multiplier"));
|
||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
||||
const multiplier = parseFloat(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_MULTIPLIER));
|
||||
const duration = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_DURATION));
|
||||
if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: EffectType.XP_BOOST, multiplier, durationSeconds: duration };
|
||||
}
|
||||
else if (type === EffectType.TEMP_ROLE) {
|
||||
const roleId = interaction.fields.getTextInputValue("role_id");
|
||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
||||
const roleId = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID);
|
||||
const duration = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_DURATION));
|
||||
if (roleId && !isNaN(duration)) effect = { type: EffectType.TEMP_ROLE, roleId: roleId, durationSeconds: duration };
|
||||
}
|
||||
else if (type === EffectType.COLOR_ROLE) {
|
||||
const roleId = interaction.fields.getTextInputValue("role_id");
|
||||
const roleId = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID);
|
||||
if (roleId) effect = { type: EffectType.COLOR_ROLE, roleId: roleId };
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
}
|
||||
|
||||
// 7. Save
|
||||
if (interaction.customId === "createitem_save") {
|
||||
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.SAVE) {
|
||||
if (!interaction.isButton()) return;
|
||||
|
||||
await interaction.deferUpdate(); // Prepare to save
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
import type { ItemUsageData } from "@shared/lib/types";
|
||||
|
||||
export const ITEM_WIZARD_CUSTOM_IDS = {
|
||||
PREFIX: "createitem_",
|
||||
START: "createitem_start",
|
||||
DETAILS: "createitem_details",
|
||||
ECONOMY: "createitem_economy",
|
||||
VISUALS: "createitem_visuals",
|
||||
TYPE_TOGGLE: "createitem_type_toggle",
|
||||
SELECT_TYPE: "createitem_select_type",
|
||||
ADD_EFFECT_START: "createitem_addeffect_start",
|
||||
SELECT_EFFECT_TYPE: "createitem_select_effect_type",
|
||||
TOGGLE_CONSUME: "createitem_toggle_consume",
|
||||
SAVE: "createitem_save",
|
||||
CANCEL: "createitem_cancel",
|
||||
MODAL_DETAILS: "createitem_modal_details",
|
||||
MODAL_ECONOMY: "createitem_modal_economy",
|
||||
MODAL_VISUALS: "createitem_modal_visuals",
|
||||
MODAL_EFFECT: "createitem_modal_effect",
|
||||
// Modal field IDs
|
||||
FIELD_NAME: "name",
|
||||
FIELD_DESC: "desc",
|
||||
FIELD_RARITY: "rarity",
|
||||
FIELD_PRICE: "price",
|
||||
FIELD_ICON: "icon",
|
||||
FIELD_IMAGE: "image",
|
||||
FIELD_AMOUNT: "amount",
|
||||
FIELD_MESSAGE: "message",
|
||||
FIELD_MULTIPLIER: "multiplier",
|
||||
FIELD_DURATION: "duration",
|
||||
FIELD_ROLE_ID: "role_id",
|
||||
} as const;
|
||||
|
||||
export interface DraftItem {
|
||||
name: string;
|
||||
description: string;
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type MessageActionRowComponentBuilder
|
||||
} from "discord.js";
|
||||
import { createBaseEmbed } from "@lib/embeds";
|
||||
import type { DraftItem } from "./item_wizard.types";
|
||||
import { ITEM_WIZARD_CUSTOM_IDS, type DraftItem } from "./item_wizard.types";
|
||||
import { ItemType } from "@shared/lib/constants";
|
||||
|
||||
const getItemTypeOptions = () => [
|
||||
@@ -51,18 +51,18 @@ export const getItemWizardEmbed = (draft: DraftItem) => {
|
||||
// Components
|
||||
const row1 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder().setCustomId("createitem_details").setLabel("Edit Details").setStyle(ButtonStyle.Secondary).setEmoji("📝"),
|
||||
new ButtonBuilder().setCustomId("createitem_economy").setLabel("Edit Economy").setStyle(ButtonStyle.Secondary).setEmoji("💰"),
|
||||
new ButtonBuilder().setCustomId("createitem_visuals").setLabel("Edit Visuals").setStyle(ButtonStyle.Secondary).setEmoji("🖼️"),
|
||||
new ButtonBuilder().setCustomId("createitem_type_toggle").setLabel("Change Type").setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
|
||||
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.DETAILS).setLabel("Edit Details").setStyle(ButtonStyle.Secondary).setEmoji("📝"),
|
||||
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.ECONOMY).setLabel("Edit Economy").setStyle(ButtonStyle.Secondary).setEmoji("💰"),
|
||||
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.VISUALS).setLabel("Edit Visuals").setStyle(ButtonStyle.Secondary).setEmoji("🖼️"),
|
||||
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.TYPE_TOGGLE).setLabel("Change Type").setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
|
||||
);
|
||||
|
||||
const row2 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder().setCustomId("createitem_addeffect_start").setLabel("Add Effect").setStyle(ButtonStyle.Primary).setEmoji("✨"),
|
||||
new ButtonBuilder().setCustomId("createitem_toggle_consume").setLabel(`Consume: ${draft.usageData.consume ? "ON" : "OFF"}`).setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
|
||||
new ButtonBuilder().setCustomId("createitem_save").setLabel("Save Item").setStyle(ButtonStyle.Success).setEmoji("💾"),
|
||||
new ButtonBuilder().setCustomId("createitem_cancel").setLabel("Cancel").setStyle(ButtonStyle.Danger).setEmoji("✖️")
|
||||
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.ADD_EFFECT_START).setLabel("Add Effect").setStyle(ButtonStyle.Primary).setEmoji("✨"),
|
||||
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.TOGGLE_CONSUME).setLabel(`Consume: ${draft.usageData.consume ? "ON" : "OFF"}`).setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
|
||||
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.SAVE).setLabel("Save Item").setStyle(ButtonStyle.Success).setEmoji("💾"),
|
||||
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.CANCEL).setLabel("Cancel").setStyle(ButtonStyle.Danger).setEmoji("✖️")
|
||||
);
|
||||
|
||||
return { embeds: [embed], components: [row1, row2] };
|
||||
@@ -70,65 +70,65 @@ export const getItemWizardEmbed = (draft: DraftItem) => {
|
||||
|
||||
export const getItemTypeSelection = () => {
|
||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||
new StringSelectMenuBuilder().setCustomId("createitem_select_type").setPlaceholder("Select Item Type").addOptions(getItemTypeOptions())
|
||||
new StringSelectMenuBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.SELECT_TYPE).setPlaceholder("Select Item Type").addOptions(getItemTypeOptions())
|
||||
);
|
||||
return { components: [row] };
|
||||
};
|
||||
|
||||
export const getEffectTypeSelection = () => {
|
||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||
new StringSelectMenuBuilder().setCustomId("createitem_select_effect_type").setPlaceholder("Select Effect Type").addOptions(getEffectTypeOptions())
|
||||
new StringSelectMenuBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.SELECT_EFFECT_TYPE).setPlaceholder("Select Effect Type").addOptions(getEffectTypeOptions())
|
||||
);
|
||||
return { components: [row] };
|
||||
};
|
||||
|
||||
export const getDetailsModal = (current: DraftItem) => {
|
||||
const modal = new ModalBuilder().setCustomId("createitem_modal_details").setTitle("Edit Details");
|
||||
const modal = new ModalBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.MODAL_DETAILS).setTitle("Edit Details");
|
||||
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(ITEM_WIZARD_CUSTOM_IDS.FIELD_NAME).setLabel("Name").setValue(current.name).setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_DESC).setLabel("Description").setValue(current.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_RARITY).setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("C, R, SR, SSR").setRequired(true))
|
||||
);
|
||||
return modal;
|
||||
};
|
||||
|
||||
export const getEconomyModal = (current: DraftItem) => {
|
||||
const modal = new ModalBuilder().setCustomId("createitem_modal_economy").setTitle("Edit Economy");
|
||||
const modal = new ModalBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.MODAL_ECONOMY).setTitle("Edit Economy");
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("price").setLabel("Price (0 for not for sale)").setValue(current.price?.toString() || "0").setStyle(TextInputStyle.Short).setRequired(true))
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_PRICE).setLabel("Price (0 for not for sale)").setValue(current.price?.toString() || "0").setStyle(TextInputStyle.Short).setRequired(true))
|
||||
);
|
||||
return modal;
|
||||
};
|
||||
|
||||
export const getVisualsModal = (current: DraftItem) => {
|
||||
const modal = new ModalBuilder().setCustomId("createitem_modal_visuals").setTitle("Edit Visuals");
|
||||
const modal = new ModalBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.MODAL_VISUALS).setTitle("Edit Visuals");
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("icon").setLabel("Icon URL (Emoji or Link)").setValue(current.iconUrl).setStyle(TextInputStyle.Short).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("image").setLabel("Image URL").setValue(current.imageUrl).setStyle(TextInputStyle.Short).setRequired(false))
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_ICON).setLabel("Icon URL (Emoji or Link)").setValue(current.iconUrl).setStyle(TextInputStyle.Short).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_IMAGE).setLabel("Image URL").setValue(current.imageUrl).setStyle(TextInputStyle.Short).setRequired(false))
|
||||
);
|
||||
return modal;
|
||||
};
|
||||
|
||||
export const getEffectConfigModal = (effectType: string) => {
|
||||
let modal = new ModalBuilder().setCustomId("createitem_modal_effect").setTitle(`Config ${effectType}`);
|
||||
let modal = new ModalBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.MODAL_EFFECT).setTitle(`Config ${effectType}`);
|
||||
|
||||
if (effectType === "ADD_XP" || effectType === "ADD_BALANCE") {
|
||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("amount").setLabel("Amount").setStyle(TextInputStyle.Short).setRequired(true).setPlaceholder("100")));
|
||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_AMOUNT).setLabel("Amount").setStyle(TextInputStyle.Short).setRequired(true).setPlaceholder("100")));
|
||||
} else if (effectType === "REPLY_MESSAGE") {
|
||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("message").setLabel("Message").setStyle(TextInputStyle.Paragraph).setRequired(true)));
|
||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_MESSAGE).setLabel("Message").setStyle(TextInputStyle.Paragraph).setRequired(true)));
|
||||
} else if (effectType === "XP_BOOST") {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("multiplier").setLabel("Multiplier (e.g. 1.5)").setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_MULTIPLIER).setLabel("Multiplier (e.g. 1.5)").setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_DURATION).setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||
);
|
||||
} else if (effectType === "TEMP_ROLE") {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID).setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_DURATION).setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||
);
|
||||
} else if (effectType === "COLOR_ROLE") {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true))
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID).setLabel("Role ID").setStyle(TextInputStyle.Short).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"
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user