Compare commits
111 Commits
f44b053a10
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a620a84c5 | ||
|
|
7d68652ea5 | ||
|
|
35bd1f58dd | ||
|
|
1cd3dbcd72 | ||
|
|
c97249f2ca | ||
|
|
0d923491b5 | ||
|
|
d870ef69d5 | ||
|
|
682e9d208e | ||
|
|
4a691ac71d | ||
|
|
1b84dbd36d | ||
|
|
a5b8d922e3 | ||
|
|
238d9a8803 | ||
|
|
713ea07040 | ||
|
|
bea6c33024 | ||
|
|
8fe300c8a2 | ||
|
|
9caa95a0d8 | ||
|
|
c6fd23b5fa | ||
|
|
d46434de18 | ||
|
|
cf4c28e1df | ||
|
|
39e405afde | ||
|
|
6763e3c543 | ||
|
|
11e07a0068 | ||
|
|
5d2d4bb0c6 | ||
|
|
19206b5cc7 | ||
|
|
0f6cce9b6e | ||
|
|
3f3a6c88e8 | ||
|
|
8253de9f73 | ||
|
|
1251df286e | ||
|
|
fff90804c0 | ||
|
|
8ebaf7b4ee | ||
|
|
17cb70ec00 | ||
|
|
a207d511be | ||
|
|
cf4f180124 | ||
|
|
5df1396b3f | ||
|
|
daad7be01c | ||
|
|
05f27ca604 | ||
|
|
d37059d50f | ||
|
|
caafe6b34d | ||
|
|
017f5ad818 | ||
|
|
f92415b89c | ||
|
|
3f028eb76a | ||
|
|
2b641c952d | ||
|
|
88b266f81b | ||
|
|
53a2f1ff0c | ||
|
|
dc15212ecf | ||
|
|
99e847175e | ||
|
|
b2c7fa6e83 | ||
|
|
9e7f18787b | ||
| 47507dd65a | |||
|
|
e6f94c3e71 | ||
|
|
66af870aa9 | ||
|
|
8047bce755 | ||
|
|
9804456257 | ||
|
|
259b8d6875 | ||
|
|
a2cb684b71 | ||
|
|
9c2098bc46 | ||
|
|
618d973863 | ||
|
|
63f55b6dfd | ||
|
|
ac4025e179 | ||
|
|
ff23f22337 | ||
|
|
292991c605 | ||
|
|
4640cd11a7 | ||
|
|
43a003f641 | ||
|
|
6f4426e49d | ||
|
|
894cad91a8 | ||
|
|
2a1c4e65ae | ||
|
|
022f748517 | ||
|
|
ca392749e3 | ||
|
|
4a1e72c5f3 | ||
|
|
d29a1ec2b7 | ||
|
|
1dd269bf2f | ||
|
|
69186ff3e9 | ||
|
|
b989e807dc | ||
|
|
2e6bdec38c | ||
|
|
a9d5c806ad | ||
|
|
6f73178375 | ||
|
|
dd62336571 | ||
|
|
8280111b66 | ||
|
|
34347f0c63 | ||
|
|
c807fd4fd0 | ||
|
|
47b980eff1 | ||
|
|
bc89ddf7c0 | ||
|
|
606d83a7ae | ||
|
|
3351295bdc | ||
|
|
92cb048a7a | ||
|
|
6ead0c0393 | ||
|
|
278ef4b6b0 | ||
|
|
9a32ab298d | ||
|
|
a2596d4124 | ||
|
|
fbc8952e0a | ||
|
|
d0b4cb80de | ||
|
|
599684cde8 | ||
|
|
5606fb6e2f | ||
|
|
fb260c5beb | ||
|
|
a227e5db59 | ||
|
|
66d5145885 | ||
|
|
2412098536 | ||
|
|
d0c48188b9 | ||
|
|
1523a392c2 | ||
|
|
7d6912cdee | ||
|
|
947bbc10d6 | ||
|
|
2933eaeafc | ||
|
|
77d3fafdce | ||
|
|
10a760edf4 | ||
|
|
a53d30a0b3 | ||
|
|
5420653b2b | ||
|
|
f13ef781b6 | ||
|
|
82a4281f9b | ||
|
|
0dbc532c7e | ||
|
|
953942f563 | ||
|
|
6334275d02 |
63
.agent/workflows/create-ticket.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
89
.agent/workflows/map-impact.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
72
.agent/workflows/review.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
99
.agent/workflows/work.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
5
.gitignore
vendored
@@ -1,7 +1,8 @@
|
|||||||
.env
|
.env
|
||||||
node_modules
|
node_modules
|
||||||
db-logs
|
shared/db-logs
|
||||||
db-data
|
shared/db/data
|
||||||
|
shared/db/loga
|
||||||
.cursor
|
.cursor
|
||||||
# dependencies (bun install)
|
# dependencies (bun install)
|
||||||
|
|
||||||
|
|||||||
10
Dockerfile
@@ -2,16 +2,20 @@ FROM oven/bun:latest AS base
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
# Install system dependencies
|
||||||
RUN apt-get update && apt-get install -y git
|
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install dependencies
|
# Install root project dependencies
|
||||||
COPY package.json bun.lock ./
|
COPY package.json bun.lock ./
|
||||||
RUN bun install --frozen-lockfile
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# Install web project dependencies
|
||||||
|
COPY web/package.json web/bun.lock ./web/
|
||||||
|
RUN cd web && bun install --frozen-lockfile
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Expose port
|
# Expose ports (3000 for web dashboard)
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Default command
|
# Default command
|
||||||
|
|||||||
67
README.md
@@ -7,24 +7,44 @@
|
|||||||

|

|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
|
|
||||||
Aurora is a powerful Discord bot designed to facilitate RPG-like elements within a Discord server. It features a robust economy, class system, inventory management, quests, and more, all built on top of a high-performance stack using Bun and Drizzle ORM.
|
Aurora is a powerful Discord bot designed to facilitate RPG-like elements within a Discord server. It features a robust economy, class system, inventory management, quests, and more, all built on top of a high-performance stack using Bun and Drizzle ORM.
|
||||||
|
|
||||||
|
**New in v1.0:** Aurora now includes a fully integrated **Web Dashboard** for managing the bot, viewing statistics, and configuring settings, running alongside the bot in a single process.
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
|
### Discord Bot
|
||||||
* **Class System**: Users can join different classes.
|
* **Class System**: Users can join different classes.
|
||||||
* **Economy**: Complete economy system with balance, transactions, and daily rewards.
|
* **Economy**: Complete economy system with balance, transactions, and daily rewards.
|
||||||
* **Inventory & Items**: sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management.
|
* **Inventory & Items**: Sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management.
|
||||||
* **Leveling**: XP-based leveling system to track user activity and progress.
|
* **Leveling**: XP-based leveling system to track user activity and progress.
|
||||||
* **Quests**: Quest system with requirements and rewards.
|
* **Quests**: Quest system with requirements and rewards.
|
||||||
* **Trading**: Secure trading system between users.
|
* **Trading**: Secure trading system between users.
|
||||||
* **Lootdrops**: Random loot drops in channels to engage users.
|
* **Lootdrops**: Random loot drops in channels to engage users.
|
||||||
* **Admin Tools**: Administrative commands for server management.
|
* **Admin Tools**: Administrative commands for server management.
|
||||||
|
|
||||||
|
### Web Dashboard
|
||||||
|
* **Live Analytics**: View real-time activity charts (commands, transactions).
|
||||||
|
* **Configuration Management**: Update bot settings without restarting.
|
||||||
|
* **Database Inspection**: Integrated Drizzle Studio access.
|
||||||
|
* **State Monitoring**: View internal bot state (Lootdrops, etc.).
|
||||||
|
|
||||||
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
Aurora uses a **Single Process Monolith** architecture to maximize performance and simplify resource sharing.
|
||||||
|
|
||||||
|
* **Unified Runtime**: Both the Discord Client and the Web Dashboard run within the same Bun process.
|
||||||
|
* **Shared State**: This allows the Dashboard to access live bot memory (caches, gateways) directly without complex inter-process communication (IPC).
|
||||||
|
* **Simplified Deployment**: You only need to deploy a single Docker container.
|
||||||
|
|
||||||
## 🛠️ Tech Stack
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
* **Runtime**: [Bun](https://bun.sh/)
|
* **Runtime**: [Bun](https://bun.sh/)
|
||||||
* **Framework**: [Discord.js](https://discord.js.org/)
|
* **Bot Framework**: [Discord.js](https://discord.js.org/)
|
||||||
|
* **Web Framework**: [React 19](https://react.dev/) + [Vite](https://vitejs.dev/) (served via Bun)
|
||||||
|
* **Styling**: [Tailwind CSS v4](https://tailwindcss.com/) + [Radix UI](https://www.radix-ui.com/)
|
||||||
* **Database**: [PostgreSQL](https://www.postgresql.org/)
|
* **Database**: [PostgreSQL](https://www.postgresql.org/)
|
||||||
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
||||||
* **Validation**: [Zod](https://zod.dev/)
|
* **Validation**: [Zod](https://zod.dev/)
|
||||||
@@ -74,12 +94,14 @@ Aurora is a powerful Discord bot designed to facilitate RPG-like elements within
|
|||||||
bun run db:push
|
bun run db:push
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running the Bot
|
### Running the Bot & Dashboard
|
||||||
|
|
||||||
**Development Mode** (with hot reload):
|
**Development Mode** (with hot reload):
|
||||||
```bash
|
```bash
|
||||||
bun run dev
|
bun run dev
|
||||||
```
|
```
|
||||||
|
* Bot: Online in Discord
|
||||||
|
* Dashboard: http://localhost:3000
|
||||||
|
|
||||||
**Production Mode**:
|
**Production Mode**:
|
||||||
Build and run with Docker (recommended):
|
Build and run with Docker (recommended):
|
||||||
@@ -87,27 +109,46 @@ Build and run with Docker (recommended):
|
|||||||
docker compose up -d app
|
docker compose up -d app
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 🔐 Accessing Production Services (SSH Tunnel)
|
||||||
|
|
||||||
|
For security, the Production Database and Dashboard are **not exposed** to the public internet by default. They are only accessible via localhost on the server.
|
||||||
|
|
||||||
|
To access them from your local machine, use the included SSH tunnel script.
|
||||||
|
|
||||||
|
1. Add your VPS details to your local `.env` file:
|
||||||
|
```env
|
||||||
|
VPS_USER=root
|
||||||
|
VPS_HOST=123.45.67.89
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run the remote connection script:
|
||||||
|
```bash
|
||||||
|
bun run remote
|
||||||
|
```
|
||||||
|
|
||||||
|
This will establish secure tunnels for:
|
||||||
|
* **Dashboard**: http://localhost:3000
|
||||||
|
* **Drizzle Studio**: http://localhost:4983
|
||||||
|
|
||||||
## 📜 Scripts
|
## 📜 Scripts
|
||||||
|
|
||||||
* `bun run dev`: Start the bot in watch mode.
|
* `bun run dev`: Start the bot and dashboard in watch mode.
|
||||||
|
* `bun run remote`: Open SSH tunnel to production services.
|
||||||
* `bun run generate`: Generate Drizzle migrations.
|
* `bun run generate`: Generate Drizzle migrations.
|
||||||
* `bun run migrate`: Apply migrations (via Docker).
|
* `bun run migrate`: Apply migrations (via Docker).
|
||||||
* `bun run db:push`: Push, schema to DB (via Docker).
|
|
||||||
* `bun run db:studio`: Open Drizzle Studio to inspect the database.
|
* `bun run db:studio`: Open Drizzle Studio to inspect the database.
|
||||||
* `bun test`: Run tests.
|
* `bun test`: Run tests.
|
||||||
|
|
||||||
## 📂 Project Structure
|
## 📂 Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
├── src
|
├── bot # Discord Bot logic & entry point
|
||||||
│ ├── commands # Slash commands
|
├── web # React Web Dashboard (Frontend + Server)
|
||||||
│ ├── events # Discord event handlers
|
├── shared # Shared code (Database, Config, Types)
|
||||||
│ ├── modules # Feature modules (Economy, Inventory, etc.)
|
|
||||||
│ ├── db # Database schema and connection
|
|
||||||
│ └── lib # Shared utilities
|
|
||||||
├── drizzle # Drizzle migration files
|
├── drizzle # Drizzle migration files
|
||||||
├── config # Configuration files
|
├── scripts # Utility scripts
|
||||||
└── scripts # Utility scripts
|
├── docker-compose.yml
|
||||||
|
└── package.json
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|||||||
BIN
bot/assets/graphics/lootdrop/template.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@/modules/moderation/moderation.service";
|
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
export const moderationCase = createCommand({
|
export const moderationCase = createCommand({
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@/modules/moderation/moderation.service";
|
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
export const cases = createCommand({
|
export const cases = createCommand({
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@/modules/moderation/moderation.service";
|
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
export const clearwarning = createCommand({
|
export const clearwarning = createCommand({
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createCommand } from "@lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ModalSubmitInteraction } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ModalSubmitInteraction } from "discord.js";
|
||||||
import { config, saveConfig } from "@lib/config";
|
import { config, saveConfig } from "@shared/lib/config";
|
||||||
import type { GameConfigType } from "@lib/config";
|
import type { GameConfigType } from "@shared/lib/config";
|
||||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
export const configCommand = createCommand({
|
export const configCommand = createCommand({
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js";
|
||||||
import { config, saveConfig } from "@/lib/config";
|
import { config, saveConfig } from "@shared/lib/config";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
import { items } from "@/db/schema";
|
import { items } from "@db/schema";
|
||||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
export const createColor = createCommand({
|
export const createColor = createCommand({
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { renderWizard } from "@/modules/admin/item_wizard";
|
import { renderWizard } from "@/modules/admin/item_wizard";
|
||||||
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { createBaseEmbed } from "@lib/embeds";
|
import { createBaseEmbed } from "@lib/embeds";
|
||||||
import { configManager } from "@/lib/configManager";
|
import { config, reloadConfig, toggleCommand } from "@shared/lib/config";
|
||||||
import { config, reloadConfig } from "@/lib/config";
|
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
|
|
||||||
export const features = createCommand({
|
export const features = createCommand({
|
||||||
@@ -79,11 +78,11 @@ export const features = createCommand({
|
|||||||
|
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
configManager.toggleCommand(commandName, enabled);
|
toggleCommand(commandName, enabled);
|
||||||
|
|
||||||
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Reloading configuration...` });
|
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Reloading configuration...` });
|
||||||
|
|
||||||
// Reload config from disk (which was updated by configManager)
|
// Reload config from disk (which was updated by toggleCommand)
|
||||||
reloadConfig();
|
reloadConfig();
|
||||||
|
|
||||||
await AuroraClient.loadCommands(true);
|
await AuroraClient.loadCommands(true);
|
||||||
84
bot/commands/admin/health.test.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||||
|
import { health } from "./health";
|
||||||
|
import { ChatInputCommandInteraction, Colors } from "discord.js";
|
||||||
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
|
|
||||||
|
// Mock DrizzleClient
|
||||||
|
const executeMock = mock(() => Promise.resolve());
|
||||||
|
mock.module("@shared/db/DrizzleClient", () => ({
|
||||||
|
DrizzleClient: {
|
||||||
|
execute: executeMock
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock BotClient (already has lastCommandTimestamp if imported, but we might want to control it)
|
||||||
|
AuroraClient.lastCommandTimestamp = 1641481200000; // Fixed timestamp for testing
|
||||||
|
|
||||||
|
describe("Health Command", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
executeMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should execute successfully and return health embed", async () => {
|
||||||
|
const interaction = {
|
||||||
|
deferReply: mock(() => Promise.resolve()),
|
||||||
|
editReply: mock(() => Promise.resolve()),
|
||||||
|
client: {
|
||||||
|
ws: {
|
||||||
|
ping: 42
|
||||||
|
}
|
||||||
|
},
|
||||||
|
user: { id: "123", username: "testuser" },
|
||||||
|
commandName: "health"
|
||||||
|
} as unknown as ChatInputCommandInteraction;
|
||||||
|
|
||||||
|
await health.execute(interaction);
|
||||||
|
|
||||||
|
expect(interaction.deferReply).toHaveBeenCalled();
|
||||||
|
expect(executeMock).toHaveBeenCalled();
|
||||||
|
expect(interaction.editReply).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const editReplyCall = (interaction.editReply as any).mock.calls[0][0];
|
||||||
|
const embed = editReplyCall.embeds[0];
|
||||||
|
|
||||||
|
expect(embed.data.title).toBe("System Health Status");
|
||||||
|
expect(embed.data.color).toBe(Colors.Aqua);
|
||||||
|
|
||||||
|
// Check fields
|
||||||
|
const fields = embed.data.fields;
|
||||||
|
expect(fields).toBeDefined();
|
||||||
|
|
||||||
|
// Connectivity field
|
||||||
|
const connectivityField = fields.find((f: any) => f.name === "📡 Connectivity");
|
||||||
|
expect(connectivityField.value).toContain("42ms");
|
||||||
|
expect(connectivityField.value).toContain("Connected");
|
||||||
|
|
||||||
|
// Activity field
|
||||||
|
const activityField = fields.find((f: any) => f.name === "⌨️ Activity");
|
||||||
|
expect(activityField.value).toContain("R>"); // Relative Discord timestamp
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle database disconnection", async () => {
|
||||||
|
executeMock.mockImplementationOnce(() => Promise.reject(new Error("DB Down")));
|
||||||
|
|
||||||
|
const interaction = {
|
||||||
|
deferReply: mock(() => Promise.resolve()),
|
||||||
|
editReply: mock(() => Promise.resolve()),
|
||||||
|
client: {
|
||||||
|
ws: {
|
||||||
|
ping: 42
|
||||||
|
}
|
||||||
|
},
|
||||||
|
user: { id: "123", username: "testuser" },
|
||||||
|
commandName: "health"
|
||||||
|
} as unknown as ChatInputCommandInteraction;
|
||||||
|
|
||||||
|
await health.execute(interaction);
|
||||||
|
|
||||||
|
const editReplyCall = (interaction.editReply as any).mock.calls[0][0];
|
||||||
|
const embed = editReplyCall.embeds[0];
|
||||||
|
const connectivityField = embed.data.fields.find((f: any) => f.name === "📡 Connectivity");
|
||||||
|
|
||||||
|
expect(connectivityField.value).toContain("Disconnected");
|
||||||
|
});
|
||||||
|
});
|
||||||
60
bot/commands/admin/health.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { createCommand } from "@shared/lib/utils";
|
||||||
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
|
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, Colors } from "discord.js";
|
||||||
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { createBaseEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
|
export const health = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("health")
|
||||||
|
.setDescription("Check the bot's health status")
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
// 1. Check Discord API latency
|
||||||
|
const wsPing = interaction.client.ws.ping;
|
||||||
|
|
||||||
|
// 2. Verify database connection
|
||||||
|
let dbStatus = "Connected";
|
||||||
|
let dbPing = -1;
|
||||||
|
try {
|
||||||
|
const start = Date.now();
|
||||||
|
await DrizzleClient.execute(sql`SELECT 1`);
|
||||||
|
dbPing = Date.now() - start;
|
||||||
|
} catch (error) {
|
||||||
|
dbStatus = "Disconnected";
|
||||||
|
console.error("Health check DB error:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Uptime
|
||||||
|
const uptime = process.uptime();
|
||||||
|
const days = Math.floor(uptime / 86400);
|
||||||
|
const hours = Math.floor((uptime % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((uptime % 3600) / 60);
|
||||||
|
const seconds = Math.floor(uptime % 60);
|
||||||
|
const uptimeString = `${days}d ${hours}h ${minutes}m ${seconds}s`;
|
||||||
|
|
||||||
|
// 4. Memory usage
|
||||||
|
const memory = process.memoryUsage();
|
||||||
|
const heapUsed = (memory.heapUsed / 1024 / 1024).toFixed(2);
|
||||||
|
const heapTotal = (memory.heapTotal / 1024 / 1024).toFixed(2);
|
||||||
|
const rss = (memory.rss / 1024 / 1024).toFixed(2);
|
||||||
|
|
||||||
|
// 5. Last successful command
|
||||||
|
const lastCommand = AuroraClient.lastCommandTimestamp
|
||||||
|
? `<t:${Math.floor(AuroraClient.lastCommandTimestamp / 1000)}:R>`
|
||||||
|
: "None since startup";
|
||||||
|
|
||||||
|
const embed = createBaseEmbed("System Health Status", undefined, Colors.Aqua)
|
||||||
|
.addFields(
|
||||||
|
{ name: "📡 Connectivity", value: `**Discord WS:** ${wsPing}ms\n**Database:** ${dbStatus} ${dbPing >= 0 ? `(${dbPing}ms)` : ""}`, inline: true },
|
||||||
|
{ name: "⏱️ Uptime", value: uptimeString, inline: true },
|
||||||
|
{ name: "🧠 Memory Usage", value: `**RSS:** ${rss} MB\n**Heap:** ${heapUsed} / ${heapTotal} MB`, inline: false },
|
||||||
|
{ name: "⌨️ Activity", value: `**Last Command:** ${lastCommand}`, inline: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import {
|
import {
|
||||||
SlashCommandBuilder,
|
SlashCommandBuilder,
|
||||||
ActionRowBuilder,
|
ActionRowBuilder,
|
||||||
@@ -8,12 +8,12 @@ import {
|
|||||||
PermissionFlagsBits,
|
PermissionFlagsBits,
|
||||||
MessageFlags
|
MessageFlags
|
||||||
} from "discord.js";
|
} from "discord.js";
|
||||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds";
|
import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds";
|
||||||
import { UserError } from "@/lib/errors";
|
import { UserError } from "@/lib/errors";
|
||||||
import { items } from "@/db/schema";
|
import { items } from "@db/schema";
|
||||||
import { ilike, isNotNull, and } from "drizzle-orm";
|
import { ilike, isNotNull, and } from "drizzle-orm";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
import { getShopListingMessage } from "@/modules/economy/shop.view";
|
import { getShopListingMessage } from "@/modules/economy/shop.view";
|
||||||
|
|
||||||
export const listing = createCommand({
|
export const listing = createCommand({
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@/modules/moderation/moderation.service";
|
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||||
|
import { CaseType } from "@shared/lib/constants";
|
||||||
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
export const note = createCommand({
|
export const note = createCommand({
|
||||||
@@ -31,7 +32,7 @@ export const note = createCommand({
|
|||||||
|
|
||||||
// Create the note case
|
// Create the note case
|
||||||
const moderationCase = await ModerationService.createCase({
|
const moderationCase = await ModerationService.createCase({
|
||||||
type: 'note',
|
type: CaseType.NOTE,
|
||||||
userId: targetUser.id,
|
userId: targetUser.id,
|
||||||
username: targetUser.username,
|
username: targetUser.username,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@/modules/moderation/moderation.service";
|
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
export const notes = createCommand({
|
export const notes = createCommand({
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||||
import { config } from "@/lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
import { PruneService } from "@/modules/moderation/prune.service";
|
import { PruneService } from "@shared/modules/moderation/prune.service";
|
||||||
import {
|
import {
|
||||||
getConfirmationMessage,
|
getConfirmationMessage,
|
||||||
getProgressEmbed,
|
getProgressEmbed,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createCommand } from "@lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
|
||||||
import { terminalService } from "@/modules/terminal/terminal.service";
|
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
||||||
import { createBaseEmbed, createErrorEmbed } from "@/lib/embeds";
|
import { createBaseEmbed, createErrorEmbed } from "@/lib/embeds";
|
||||||
|
|
||||||
export const terminal = createCommand({
|
export const terminal = createCommand({
|
||||||
176
bot/commands/admin/update.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { createCommand } from "@shared/lib/utils";
|
||||||
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||||
|
import { UpdateService } from "@shared/modules/admin/update.service";
|
||||||
|
import {
|
||||||
|
getCheckingEmbed,
|
||||||
|
getNoUpdatesEmbed,
|
||||||
|
getUpdatesAvailableMessage,
|
||||||
|
getPreparingEmbed,
|
||||||
|
getUpdatingEmbed,
|
||||||
|
getCancelledEmbed,
|
||||||
|
getTimeoutEmbed,
|
||||||
|
getErrorEmbed,
|
||||||
|
getRollbackSuccessEmbed,
|
||||||
|
getRollbackFailedEmbed
|
||||||
|
} from "@/modules/admin/update.view";
|
||||||
|
|
||||||
|
export const update = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("update")
|
||||||
|
.setDescription("Check for updates and restart the bot")
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("check")
|
||||||
|
.setDescription("Check for and apply available updates")
|
||||||
|
.addBooleanOption(option =>
|
||||||
|
option.setName("force")
|
||||||
|
.setDescription("Force update even if no changes detected")
|
||||||
|
.setRequired(false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("rollback")
|
||||||
|
.setDescription("Rollback to the previous version")
|
||||||
|
)
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
|
execute: async (interaction) => {
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
if (subcommand === "rollback") {
|
||||||
|
await handleRollback(interaction);
|
||||||
|
} else {
|
||||||
|
await handleUpdate(interaction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleUpdate(interaction: any) {
|
||||||
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
const force = interaction.options.getBoolean("force") || false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Check for updates
|
||||||
|
await interaction.editReply({ embeds: [getCheckingEmbed()] });
|
||||||
|
const updateInfo = await UpdateService.checkForUpdates();
|
||||||
|
|
||||||
|
if (!updateInfo.hasUpdates && !force) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getNoUpdatesEmbed(updateInfo.currentCommit)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Analyze requirements
|
||||||
|
const requirements = await UpdateService.checkUpdateRequirements(updateInfo.branch);
|
||||||
|
const categories = UpdateService.categorizeChanges(requirements.changedFiles);
|
||||||
|
|
||||||
|
// 3. Show confirmation with details
|
||||||
|
const { embeds, components } = getUpdatesAvailableMessage(
|
||||||
|
updateInfo,
|
||||||
|
requirements,
|
||||||
|
categories,
|
||||||
|
force
|
||||||
|
);
|
||||||
|
const response = await interaction.editReply({ embeds, components });
|
||||||
|
|
||||||
|
// 4. Wait for confirmation
|
||||||
|
try {
|
||||||
|
const confirmation = await response.awaitMessageComponent({
|
||||||
|
filter: (i: any) => i.user.id === interaction.user.id,
|
||||||
|
componentType: ComponentType.Button,
|
||||||
|
time: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmation.customId === "confirm_update") {
|
||||||
|
await confirmation.update({
|
||||||
|
embeds: [getPreparingEmbed()],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Save rollback point
|
||||||
|
const previousCommit = await UpdateService.saveRollbackPoint();
|
||||||
|
|
||||||
|
// 6. Prepare restart context
|
||||||
|
await UpdateService.prepareRestartContext({
|
||||||
|
channelId: interaction.channelId,
|
||||||
|
userId: interaction.user.id,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
runMigrations: requirements.needsMigrations,
|
||||||
|
installDependencies: requirements.needsRootInstall || requirements.needsWebInstall,
|
||||||
|
previousCommit: previousCommit.substring(0, 7),
|
||||||
|
newCommit: updateInfo.latestCommit
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. Show updating status
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getUpdatingEmbed(requirements)]
|
||||||
|
});
|
||||||
|
|
||||||
|
// 8. Perform update
|
||||||
|
await UpdateService.performUpdate(updateInfo.branch);
|
||||||
|
|
||||||
|
// 9. Trigger restart
|
||||||
|
await UpdateService.triggerRestart();
|
||||||
|
|
||||||
|
} else {
|
||||||
|
await confirmation.update({
|
||||||
|
embeds: [getCancelledEmbed()],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message.includes("time")) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getTimeoutEmbed()],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Update failed:", error);
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getErrorEmbed(error)],
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRollback(interaction: any) {
|
||||||
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hasRollback = await UpdateService.hasRollbackPoint();
|
||||||
|
|
||||||
|
if (!hasRollback) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getRollbackFailedEmbed("No rollback point available. Rollback is only possible after a recent update.")]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await UpdateService.rollback();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getRollbackSuccessEmbed(result.message.split(" ").pop() || "unknown")]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restart after rollback
|
||||||
|
setTimeout(() => UpdateService.triggerRestart(), 1000);
|
||||||
|
} else {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getRollbackFailedEmbed(result.message)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Rollback failed:", error);
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [getErrorEmbed(error)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
87
bot/commands/admin/warn.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { createCommand } from "@shared/lib/utils";
|
||||||
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
|
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||||
|
import {
|
||||||
|
getWarnSuccessEmbed,
|
||||||
|
getModerationErrorEmbed,
|
||||||
|
getUserWarningEmbed
|
||||||
|
} from "@/modules/moderation/moderation.view";
|
||||||
|
import { config } from "@shared/lib/config";
|
||||||
|
|
||||||
|
export const warn = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("warn")
|
||||||
|
.setDescription("Issue a warning to a user")
|
||||||
|
.addUserOption(option =>
|
||||||
|
option
|
||||||
|
.setName("user")
|
||||||
|
.setDescription("The user to warn")
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
.addStringOption(option =>
|
||||||
|
option
|
||||||
|
.setName("reason")
|
||||||
|
.setDescription("Reason for the warning")
|
||||||
|
.setRequired(true)
|
||||||
|
.setMaxLength(1000)
|
||||||
|
)
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
|
execute: async (interaction) => {
|
||||||
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
|
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 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.")]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@/modules/moderation/moderation.service";
|
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||||
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
export const warnings = createCommand({
|
export const warnings = createCommand({
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { createErrorEmbed } from "@/lib/embeds";
|
import { createErrorEmbed } from "@/lib/embeds";
|
||||||
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { createBaseEmbed } from "@lib/embeds";
|
import { createBaseEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
export const balance = createCommand({
|
export const balance = createCommand({
|
||||||
@@ -23,6 +23,8 @@ export const balance = createCommand({
|
|||||||
|
|
||||||
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
||||||
|
|
||||||
|
if (!user) throw new Error("Failed to retrieve user data.");
|
||||||
|
|
||||||
const embed = createBaseEmbed(undefined, `**Balance**: ${user.balance || 0n} AU`, "Yellow")
|
const embed = createBaseEmbed(undefined, `**Balance**: ${user.balance || 0n} AU`, "Yellow")
|
||||||
.setAuthor({ name: targetUser.username, iconURL: targetUser.displayAvatarURL() });
|
.setAuthor({ name: targetUser.username, iconURL: targetUser.displayAvatarURL() });
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { economyService } from "@/modules/economy/economy.service";
|
import { economyService } from "@shared/modules/economy/economy.service";
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
import { UserError } from "@/lib/errors";
|
import { UserError } from "@/lib/errors";
|
||||||
|
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
import { UserError } from "@/lib/errors";
|
import { UserError } from "@/lib/errors";
|
||||||
import { userTimers, users } from "@/db/schema";
|
import { userTimers, users } from "@db/schema";
|
||||||
import { eq, and, sql } from "drizzle-orm";
|
import { eq, and, sql } from "drizzle-orm";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
import { config } from "@lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
|
import { TimerType } from "@shared/lib/constants";
|
||||||
|
|
||||||
const EXAM_TIMER_TYPE = 'EXAM_SYSTEM';
|
const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
|
||||||
const EXAM_TIMER_KEY = 'default';
|
const EXAM_TIMER_KEY = 'default';
|
||||||
|
|
||||||
interface ExamMetadata {
|
interface ExamMetadata {
|
||||||
@@ -25,6 +26,10 @@ export const exam = createCommand({
|
|||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply();
|
await interaction.deferReply();
|
||||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
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 now = new Date();
|
||||||
const currentDay = now.getDay();
|
const currentDay = now.getDay();
|
||||||
|
|
||||||
@@ -43,11 +48,12 @@ export const exam = createCommand({
|
|||||||
// Set exam day to today
|
// Set exam day to today
|
||||||
const nextExamDate = new Date(now);
|
const nextExamDate = new Date(now);
|
||||||
nextExamDate.setDate(now.getDate() + 7);
|
nextExamDate.setDate(now.getDate() + 7);
|
||||||
|
nextExamDate.setHours(0, 0, 0, 0);
|
||||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||||
|
|
||||||
const metadata: ExamMetadata = {
|
const metadata: ExamMetadata = {
|
||||||
examDay: currentDay,
|
examDay: currentDay,
|
||||||
lastXp: user.xp.toString()
|
lastXp: (user.xp ?? 0n).toString()
|
||||||
};
|
};
|
||||||
|
|
||||||
await DrizzleClient.insert(userTimers).values({
|
await DrizzleClient.insert(userTimers).values({
|
||||||
@@ -61,7 +67,7 @@ export const exam = createCommand({
|
|||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [createSuccessEmbed(
|
embeds: [createSuccessEmbed(
|
||||||
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}** (Server Time).\n` +
|
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}** (Server Time).\n` +
|
||||||
`Come back on <t:${nextExamTimestamp}:F> (<t:${nextExamTimestamp}:R>) to take your first exam!`,
|
`Come back on <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>) to take your first exam!`,
|
||||||
"Exam Registration Successful"
|
"Exam Registration Successful"
|
||||||
)]
|
)]
|
||||||
});
|
});
|
||||||
@@ -72,15 +78,17 @@ export const exam = createCommand({
|
|||||||
const examDay = metadata.examDay;
|
const examDay = metadata.examDay;
|
||||||
|
|
||||||
// 3. Cooldown Check
|
// 3. Cooldown Check
|
||||||
if (now < new Date(timer.expiresAt)) {
|
|
||||||
// Calculate time remaining
|
|
||||||
const expiresAt = new Date(timer.expiresAt);
|
const expiresAt = new Date(timer.expiresAt);
|
||||||
|
expiresAt.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
if (now < expiresAt) {
|
||||||
|
// Calculate time remaining
|
||||||
const timestamp = Math.floor(expiresAt.getTime() / 1000);
|
const timestamp = Math.floor(expiresAt.getTime() / 1000);
|
||||||
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [createErrorEmbed(
|
embeds: [createErrorEmbed(
|
||||||
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
||||||
`Next exam available: <t:${timestamp}:F> (<t:${timestamp}:R>)`
|
`Next exam available: <t:${timestamp}:D> (<t:${timestamp}:R>)`
|
||||||
)]
|
)]
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -94,11 +102,12 @@ export const exam = createCommand({
|
|||||||
|
|
||||||
const nextExamDate = new Date(now);
|
const nextExamDate = new Date(now);
|
||||||
nextExamDate.setDate(now.getDate() + daysUntil);
|
nextExamDate.setDate(now.getDate() + daysUntil);
|
||||||
|
nextExamDate.setHours(0, 0, 0, 0);
|
||||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||||
|
|
||||||
const newMetadata: ExamMetadata = {
|
const newMetadata: ExamMetadata = {
|
||||||
examDay: examDay,
|
examDay: examDay,
|
||||||
lastXp: user.xp.toString() // Reset tracking
|
lastXp: (user.xp ?? 0n).toString()
|
||||||
};
|
};
|
||||||
|
|
||||||
await DrizzleClient.update(userTimers)
|
await DrizzleClient.update(userTimers)
|
||||||
@@ -116,7 +125,7 @@ export const exam = createCommand({
|
|||||||
embeds: [createErrorEmbed(
|
embeds: [createErrorEmbed(
|
||||||
`You missed your exam day! Your exam day is **${DAYS[examDay]}** (Server Time).\n` +
|
`You missed your exam day! Your exam day is **${DAYS[examDay]}** (Server Time).\n` +
|
||||||
`You verify your attendance but score a **0**.\n` +
|
`You verify your attendance but score a **0**.\n` +
|
||||||
`Your next exam opportunity is: <t:${nextExamTimestamp}:F> (<t:${nextExamTimestamp}:R>)`,
|
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
|
||||||
"Exam Failed"
|
"Exam Failed"
|
||||||
)]
|
)]
|
||||||
});
|
});
|
||||||
@@ -125,7 +134,7 @@ export const exam = createCommand({
|
|||||||
|
|
||||||
// 5. Reward Calculation
|
// 5. Reward Calculation
|
||||||
const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case
|
const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case
|
||||||
const currentXp = user.xp;
|
const currentXp = user.xp ?? 0n;
|
||||||
const diff = currentXp - lastXp;
|
const diff = currentXp - lastXp;
|
||||||
|
|
||||||
// Calculate Reward
|
// Calculate Reward
|
||||||
@@ -143,6 +152,7 @@ export const exam = createCommand({
|
|||||||
// 6. Update State
|
// 6. Update State
|
||||||
const nextExamDate = new Date(now);
|
const nextExamDate = new Date(now);
|
||||||
nextExamDate.setDate(now.getDate() + 7);
|
nextExamDate.setDate(now.getDate() + 7);
|
||||||
|
nextExamDate.setHours(0, 0, 0, 0);
|
||||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||||
|
|
||||||
const newMetadata: ExamMetadata = {
|
const newMetadata: ExamMetadata = {
|
||||||
@@ -178,7 +188,7 @@ export const exam = createCommand({
|
|||||||
`**XP Gained:** ${diff.toString()}\n` +
|
`**XP Gained:** ${diff.toString()}\n` +
|
||||||
`**Multiplier:** x${multiplier.toFixed(2)}\n` +
|
`**Multiplier:** x${multiplier.toFixed(2)}\n` +
|
||||||
`**Reward:** ${reward.toString()} Currency\n\n` +
|
`**Reward:** ${reward.toString()} Currency\n\n` +
|
||||||
`See you next week: <t:${nextExamTimestamp}:F>`,
|
`See you next week: <t:${nextExamTimestamp}:D>`,
|
||||||
"Exam Passed!"
|
"Exam Passed!"
|
||||||
)]
|
)]
|
||||||
});
|
});
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
|
|
||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
||||||
import { economyService } from "@/modules/economy/economy.service";
|
import { economyService } from "@shared/modules/economy/economy.service";
|
||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { config } from "@/lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
import { UserError } from "@/lib/errors";
|
import { UserError } from "@/lib/errors";
|
||||||
|
|
||||||
@@ -33,6 +33,11 @@ export const pay = createCommand({
|
|||||||
|
|
||||||
const amount = BigInt(interaction.options.getInteger("amount", true));
|
const amount = BigInt(interaction.options.getInteger("amount", true));
|
||||||
const senderId = interaction.user.id;
|
const senderId = interaction.user.id;
|
||||||
|
if (!targetUser) {
|
||||||
|
await interaction.reply({ embeds: [createErrorEmbed("User not found.")], flags: MessageFlags.Ephemeral });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const receiverId = targetUser.id;
|
const receiverId = targetUser.id;
|
||||||
|
|
||||||
if (amount < config.economy.transfers.minAmount) {
|
if (amount < config.economy.transfers.minAmount) {
|
||||||
@@ -40,14 +45,14 @@ export const pay = createCommand({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (senderId === receiverId) {
|
if (senderId === receiverId.toString()) {
|
||||||
await interaction.reply({ embeds: [createErrorEmbed("You cannot pay yourself.")], flags: MessageFlags.Ephemeral });
|
await interaction.reply({ embeds: [createErrorEmbed("You cannot pay yourself.")], flags: MessageFlags.Ephemeral });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await interaction.deferReply();
|
await interaction.deferReply();
|
||||||
await economyService.transfer(senderId, receiverId, amount);
|
await economyService.transfer(senderId, receiverId.toString(), amount);
|
||||||
|
|
||||||
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
|
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
|
||||||
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
|
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags } from "discord.js";
|
||||||
import { TradeService } from "@/modules/trade/trade.service";
|
import { tradeService } from "@shared/modules/trade/trade.service";
|
||||||
import { getTradeDashboard } from "@/modules/trade/trade.view";
|
import { getTradeDashboard } from "@/modules/trade/trade.view";
|
||||||
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ export const trade = createCommand({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Setup Session
|
// Setup Session
|
||||||
const session = TradeService.createSession(thread.id,
|
const session = tradeService.createSession(thread.id,
|
||||||
{ id: interaction.user.id, username: interaction.user.username },
|
{ id: interaction.user.id, username: interaction.user.username },
|
||||||
{ id: targetUser.id, username: targetUser.username }
|
{ id: targetUser.id, username: targetUser.username }
|
||||||
);
|
);
|
||||||
117
bot/commands/economy/trivia.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { createCommand } from "@shared/lib/utils";
|
||||||
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
|
import { triviaService } from "@shared/modules/trivia/trivia.service";
|
||||||
|
import { getTriviaQuestionView } from "@/modules/trivia/trivia.view";
|
||||||
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
|
import { UserError } from "@/lib/errors";
|
||||||
|
import { config } from "@shared/lib/config";
|
||||||
|
import { TriviaCategory } from "@shared/lib/constants";
|
||||||
|
|
||||||
|
export const trivia = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("trivia")
|
||||||
|
.setDescription("Play trivia to win currency! Answer correctly within the time limit.")
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('category')
|
||||||
|
.setDescription('Select a specific category')
|
||||||
|
.setRequired(false)
|
||||||
|
.addChoices(
|
||||||
|
{ name: 'General Knowledge', value: String(TriviaCategory.GENERAL_KNOWLEDGE) },
|
||||||
|
{ name: 'Books', value: String(TriviaCategory.BOOKS) },
|
||||||
|
{ name: 'Film', value: String(TriviaCategory.FILM) },
|
||||||
|
{ name: 'Music', value: String(TriviaCategory.MUSIC) },
|
||||||
|
{ name: 'Video Games', value: String(TriviaCategory.VIDEO_GAMES) },
|
||||||
|
{ name: 'Science & Nature', value: String(TriviaCategory.SCIENCE_NATURE) },
|
||||||
|
{ name: 'Computers', value: String(TriviaCategory.COMPUTERS) },
|
||||||
|
{ name: 'Mathematics', value: String(TriviaCategory.MATHEMATICS) },
|
||||||
|
{ name: 'Mythology', value: String(TriviaCategory.MYTHOLOGY) },
|
||||||
|
{ name: 'Sports', value: String(TriviaCategory.SPORTS) },
|
||||||
|
{ name: 'Geography', value: String(TriviaCategory.GEOGRAPHY) },
|
||||||
|
{ name: 'History', value: String(TriviaCategory.HISTORY) },
|
||||||
|
{ name: 'Politics', value: String(TriviaCategory.POLITICS) },
|
||||||
|
{ name: 'Art', value: String(TriviaCategory.ART) },
|
||||||
|
{ name: 'Animals', value: String(TriviaCategory.ANIMALS) },
|
||||||
|
{ name: 'Anime & Manga', value: String(TriviaCategory.ANIME_MANGA) },
|
||||||
|
)
|
||||||
|
),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
try {
|
||||||
|
const categoryId = interaction.options.getString('category');
|
||||||
|
|
||||||
|
// Check if user can play BEFORE deferring
|
||||||
|
const canPlay = await triviaService.canPlayTrivia(interaction.user.id);
|
||||||
|
|
||||||
|
if (!canPlay.canPlay) {
|
||||||
|
// Cooldown error - ephemeral
|
||||||
|
const timestamp = Math.floor(canPlay.nextAvailable!.getTime() / 1000);
|
||||||
|
await interaction.reply({
|
||||||
|
embeds: [createErrorEmbed(
|
||||||
|
`You're on cooldown! Try again <t:${timestamp}:R>.`
|
||||||
|
)],
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User can play - defer publicly for trivia question
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
// Start trivia session (deducts entry fee)
|
||||||
|
const session = await triviaService.startTrivia(
|
||||||
|
interaction.user.id,
|
||||||
|
interaction.user.username,
|
||||||
|
categoryId ? parseInt(categoryId) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate Components v2 message
|
||||||
|
const { components, flags } = getTriviaQuestionView(session, interaction.user.username);
|
||||||
|
|
||||||
|
// Reply with Components v2 question
|
||||||
|
await interaction.editReply({
|
||||||
|
components,
|
||||||
|
flags
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up automatic timeout cleanup
|
||||||
|
setTimeout(async () => {
|
||||||
|
const stillActive = triviaService.getSession(session.sessionId);
|
||||||
|
if (stillActive) {
|
||||||
|
// User didn't answer - clean up session with no reward
|
||||||
|
try {
|
||||||
|
await triviaService.submitAnswer(session.sessionId, interaction.user.id, false);
|
||||||
|
} catch (error) {
|
||||||
|
// Session already cleaned up, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, config.trivia.timeoutSeconds * 1000 + 5000); // 5 seconds grace period
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error instanceof UserError) {
|
||||||
|
// Check if we've already deferred
|
||||||
|
if (interaction.deferred) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed(error.message)]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await interaction.reply({
|
||||||
|
embeds: [createErrorEmbed(error.message)],
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error("Error in trivia command:", error);
|
||||||
|
// Check if we've already deferred
|
||||||
|
if (interaction.deferred) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await interaction.reply({
|
||||||
|
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { config } from "@/lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
import { createErrorEmbed } from "@/lib/embeds";
|
import { createErrorEmbed } from "@/lib/embeds";
|
||||||
import { getFeedbackTypeMenu } from "@/modules/feedback/feedback.view";
|
import { getFeedbackTypeMenu } from "@/modules/feedback/feedback.view";
|
||||||
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { createWarningEmbed, createBaseEmbed } from "@lib/embeds";
|
import { createWarningEmbed } from "@lib/embeds";
|
||||||
|
import { getInventoryEmbed } from "@/modules/inventory/inventory.view";
|
||||||
|
|
||||||
export const inventory = createCommand({
|
export const inventory = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -24,18 +25,19 @@ export const inventory = createCommand({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
||||||
const items = await inventoryService.getInventory(user.id);
|
if (!user) {
|
||||||
|
await interaction.editReply({ embeds: [createWarningEmbed("Failed to load user data.", "Error")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await inventoryService.getInventory(user.id.toString());
|
||||||
|
|
||||||
if (!items || items.length === 0) {
|
if (!items || items.length === 0) {
|
||||||
await interaction.editReply({ embeds: [createWarningEmbed("Inventory is empty.", `${user.username}'s Inventory`)] });
|
await interaction.editReply({ embeds: [createWarningEmbed("Inventory is empty.", `${user.username}'s Inventory`)] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const description = items.map(entry => {
|
const embed = getInventoryEmbed(items, user.username);
|
||||||
return `**${entry.item.name}** x${entry.quantity}`;
|
|
||||||
}).join("\n");
|
|
||||||
|
|
||||||
const embed = createBaseEmbed(`${user.username}'s Inventory`, description, "Blue");
|
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
await interaction.editReply({ embeds: [embed] });
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
import { inventory, items } from "@/db/schema";
|
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
||||||
import { eq, and, like } from "drizzle-orm";
|
import type { ItemUsageData } from "@shared/lib/types";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
|
||||||
import type { ItemUsageData } from "@/lib/types";
|
|
||||||
import { UserError } from "@/lib/errors";
|
import { UserError } from "@/lib/errors";
|
||||||
import { config } from "@/lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
|
|
||||||
export const use = createCommand({
|
export const use = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -25,9 +23,13 @@ export const use = createCommand({
|
|||||||
|
|
||||||
const itemId = interaction.options.getNumber("item", true);
|
const itemId = interaction.options.getNumber("item", true);
|
||||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
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 {
|
try {
|
||||||
const result = await inventoryService.useItem(user.id, itemId);
|
const result = await inventoryService.useItem(user.id.toString(), itemId);
|
||||||
|
|
||||||
const usageData = result.usageData;
|
const usageData = result.usageData;
|
||||||
if (usageData) {
|
if (usageData) {
|
||||||
@@ -53,11 +55,7 @@ export const use = createCommand({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const embed = createSuccessEmbed(
|
const embed = getItemUseResultEmbed(result.results, result.item);
|
||||||
result.results.map(r => `• ${r}`).join("\n"),
|
|
||||||
`Used ${result.usageData.effects.length > 0 ? 'Item' : 'Item'}` // Generic title, improves below
|
|
||||||
);
|
|
||||||
embed.setTitle("Item Used!");
|
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
|
||||||
@@ -74,28 +72,8 @@ export const use = createCommand({
|
|||||||
const focusedValue = interaction.options.getFocused();
|
const focusedValue = interaction.options.getFocused();
|
||||||
const userId = interaction.user.id;
|
const userId = interaction.user.id;
|
||||||
|
|
||||||
// Fetch owned items that match the search query
|
const results = await inventoryService.getAutocompleteItems(userId, focusedValue);
|
||||||
// We join with items table to filter by name directly in the database
|
|
||||||
const entries = await DrizzleClient.select({
|
|
||||||
quantity: inventory.quantity,
|
|
||||||
item: items
|
|
||||||
})
|
|
||||||
.from(inventory)
|
|
||||||
.innerJoin(items, eq(inventory.itemId, items.id))
|
|
||||||
.where(and(
|
|
||||||
eq(inventory.userId, BigInt(userId)),
|
|
||||||
like(items.name, `%${focusedValue}%`)
|
|
||||||
))
|
|
||||||
.limit(20); // Fetch up to 20 matching items
|
|
||||||
|
|
||||||
const filtered = entries.filter(entry => {
|
await interaction.respond(results);
|
||||||
const usageData = entry.item.usageData as ItemUsageData | null;
|
|
||||||
const isUsable = usageData && usageData.effects && usageData.effects.length > 0;
|
|
||||||
return isUsable;
|
|
||||||
});
|
|
||||||
|
|
||||||
await interaction.respond(
|
|
||||||
filtered.map(entry => ({ name: `${entry.item.name} (${entry.quantity})`, value: entry.item.id }))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
61
bot/commands/leveling/leaderboard.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { createCommand } from "@shared/lib/utils";
|
||||||
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
|
import { users, items, inventory } from "@db/schema";
|
||||||
|
import { desc, sql, eq } from "drizzle-orm";
|
||||||
|
import { createWarningEmbed } from "@lib/embeds";
|
||||||
|
import { getLeaderboardEmbed } from "@/modules/leveling/leveling.view";
|
||||||
|
|
||||||
|
export const leaderboard = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("leaderboard")
|
||||||
|
.setDescription("View the top players")
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName("type")
|
||||||
|
.setDescription("Sort by XP, Balance, or Net Worth")
|
||||||
|
.setRequired(true)
|
||||||
|
.addChoices(
|
||||||
|
{ name: "Level / XP", value: "xp" },
|
||||||
|
{ name: "Balance", value: "balance" },
|
||||||
|
{ name: "Net Worth", value: "networth" }
|
||||||
|
)
|
||||||
|
),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
const type = interaction.options.getString("type", true);
|
||||||
|
|
||||||
|
let leaders;
|
||||||
|
|
||||||
|
if (type === 'networth') {
|
||||||
|
leaders = await DrizzleClient.select({
|
||||||
|
username: users.username,
|
||||||
|
level: users.level,
|
||||||
|
xp: users.xp,
|
||||||
|
balance: users.balance,
|
||||||
|
netWorth: sql<bigint>`${users.balance} + COALESCE(SUM(${items.price} * ${inventory.quantity}), 0)`.as('net_worth')
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.leftJoin(inventory, eq(users.id, inventory.userId))
|
||||||
|
.leftJoin(items, eq(inventory.itemId, items.id))
|
||||||
|
.groupBy(users.id)
|
||||||
|
.orderBy(desc(sql`net_worth`))
|
||||||
|
.limit(10);
|
||||||
|
} else {
|
||||||
|
const isXp = type === "xp";
|
||||||
|
leaders = await DrizzleClient.query.users.findMany({
|
||||||
|
orderBy: isXp ? desc(users.xp) : desc(users.balance),
|
||||||
|
limit: 10
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leaders.length === 0) {
|
||||||
|
await interaction.editReply({ embeds: [createWarningEmbed("No users found.", "Leaderboard")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = getLeaderboardEmbed(leaders, type as 'xp' | 'balance' | 'networth');
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
}
|
||||||
|
});
|
||||||
25
bot/commands/quest/quests.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
export const quests = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("quests")
|
||||||
|
.setDescription("View your active quests"),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
|
const userQuests = await questService.getUserQuests(interaction.user.id);
|
||||||
|
|
||||||
|
if (!userQuests || userQuests.length === 0) {
|
||||||
|
await interaction.editReply({ embeds: [createWarningEmbed("You have no active quests.", "Quest Log")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = getQuestListEmbed(userQuests);
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, AttachmentBuilder } from "discord.js";
|
import { SlashCommandBuilder, AttachmentBuilder } from "discord.js";
|
||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { generateStudentIdCard } from "@/graphics/studentID";
|
import { generateStudentIdCard } from "@/graphics/studentID";
|
||||||
import { createWarningEmbed } from "@/lib/embeds";
|
import { createWarningEmbed } from "@/lib/embeds";
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Events } from "discord.js";
|
import { Events } from "discord.js";
|
||||||
import type { Event } from "@lib/types";
|
import type { Event } from "@shared/lib/types";
|
||||||
import { config } from "@lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
import { userService } from "@modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
|
|
||||||
// Visitor role
|
// Visitor role
|
||||||
const event: Event<Events.GuildMemberAdd> = {
|
const event: Event<Events.GuildMemberAdd> = {
|
||||||
22
bot/events/interactionCreate.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Events } from "discord.js";
|
||||||
|
import { ComponentInteractionHandler, AutocompleteHandler, CommandHandler } from "@/lib/handlers";
|
||||||
|
import type { Event } from "@shared/lib/types";
|
||||||
|
|
||||||
|
const event: Event<Events.InteractionCreate> = {
|
||||||
|
name: Events.InteractionCreate,
|
||||||
|
execute: async (interaction) => {
|
||||||
|
if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
|
||||||
|
return ComponentInteractionHandler.handle(interaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.isAutocomplete()) {
|
||||||
|
return AutocompleteHandler.handle(interaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.isChatInputCommand()) {
|
||||||
|
return CommandHandler.handle(interaction);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default event;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Events } from "discord.js";
|
import { Events } from "discord.js";
|
||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { levelingService } from "@/modules/leveling/leveling.service";
|
import { levelingService } from "@shared/modules/leveling/leveling.service";
|
||||||
import type { Event } from "@lib/types";
|
import type { Event } from "@shared/lib/types";
|
||||||
|
|
||||||
const event: Event<Events.MessageCreate> = {
|
const event: Event<Events.MessageCreate> = {
|
||||||
name: Events.MessageCreate,
|
name: Events.MessageCreate,
|
||||||
@@ -15,7 +15,7 @@ const event: Event<Events.MessageCreate> = {
|
|||||||
levelingService.processChatXp(message.author.id);
|
levelingService.processChatXp(message.author.id);
|
||||||
|
|
||||||
// Activity Tracking for Lootdrops
|
// Activity Tracking for Lootdrops
|
||||||
import("@/modules/economy/lootdrop.service").then(m => m.lootdropService.processMessage(message));
|
import("@shared/modules/economy/lootdrop.service").then(m => m.lootdropService.processMessage(message));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Events } from "discord.js";
|
import { Events } from "discord.js";
|
||||||
import { schedulerService } from "@/modules/system/scheduler";
|
import { schedulerService } from "@/modules/system/scheduler";
|
||||||
import type { Event } from "@lib/types";
|
import type { Event } from "@shared/lib/types";
|
||||||
|
|
||||||
const event: Event<Events.ClientReady> = {
|
const event: Event<Events.ClientReady> = {
|
||||||
name: Events.ClientReady,
|
name: Events.ClientReady,
|
||||||
@@ -10,7 +10,7 @@ const event: Event<Events.ClientReady> = {
|
|||||||
schedulerService.start();
|
schedulerService.start();
|
||||||
|
|
||||||
// Handle post-update tasks
|
// Handle post-update tasks
|
||||||
const { UpdateService } = await import("@/modules/admin/update.service");
|
const { UpdateService } = await import("@shared/modules/admin/update.service");
|
||||||
await UpdateService.handlePostRestart(c);
|
await UpdateService.handlePostRestart(c);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
135
bot/graphics/lootdrop.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// Register Fonts (same as studentID.ts)
|
||||||
|
const fontDir = path.join(process.cwd(), 'bot', 'assets', 'fonts');
|
||||||
|
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold');
|
||||||
|
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
|
||||||
|
|
||||||
|
export async function generateLootdropCard(amount: number, currency: string): Promise<Buffer> {
|
||||||
|
const templatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'lootdrop', 'template.png');
|
||||||
|
const template = await loadImage(templatePath);
|
||||||
|
|
||||||
|
const canvas = createCanvas(template.width, template.height);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Draw Template
|
||||||
|
ctx.drawImage(template, 0, 0);
|
||||||
|
|
||||||
|
// Draw Lootdrop Text (Title-ish)
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '48px IBMPlexSansCondensed-SemiBold';
|
||||||
|
ctx.fillStyle = '#FFFFFF';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.shadowBlur = 10;
|
||||||
|
ctx.shadowColor = 'rgba(255, 255, 255, 0.5)';
|
||||||
|
// Center of lower half (512-1024) is roughly 768
|
||||||
|
ctx.fillText('A STAR IS FALLING', canvas.width / 2, 660);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Draw Reward Amount
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '72px IBMPlexMono-Bold';
|
||||||
|
ctx.fillStyle = '#DAC7A1';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
//ctx.shadowBlur = 15;
|
||||||
|
//ctx.shadowColor = 'rgba(255, 215, 0, 0.8)';
|
||||||
|
ctx.fillText(`${amount} ${currency}`, canvas.width / 2, 760); // Below title
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Crop the image by 64px on all sides
|
||||||
|
const croppedWidth = template.width - 128;
|
||||||
|
const croppedHeight = template.height - 128;
|
||||||
|
const croppedCanvas = createCanvas(croppedWidth, croppedHeight);
|
||||||
|
const croppedCtx = croppedCanvas.getContext('2d');
|
||||||
|
|
||||||
|
// Draw the original canvas onto the cropped canvas, shifted by -64
|
||||||
|
croppedCtx.drawImage(canvas, -64, -64);
|
||||||
|
|
||||||
|
return croppedCanvas.toBuffer('image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateClaimedLootdropCard(amount: number, currency: string, username: string, avatarUrl: string): Promise<Buffer> {
|
||||||
|
const templatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'lootdrop', 'template.png');
|
||||||
|
const template = await loadImage(templatePath);
|
||||||
|
|
||||||
|
const canvas = createCanvas(template.width, template.height);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Draw Template
|
||||||
|
ctx.drawImage(template, 0, 0);
|
||||||
|
|
||||||
|
// Add a colored overlay to signify "claimed"
|
||||||
|
ctx.fillStyle = 'rgba(10, 10, 20, 0.85)';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// Draw Claimed Text (Title-ish)
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '48px IBMPlexSansCondensed-SemiBold';
|
||||||
|
ctx.fillStyle = '#FFFFFF';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.shadowBlur = 10;
|
||||||
|
ctx.shadowColor = 'rgba(255, 255, 255, 0.5)';
|
||||||
|
ctx.fillText('STAR CLAIMED', canvas.width / 2, 660);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Draw "by username" with Avatar
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '36px IBMPlexSansCondensed-SemiBold';
|
||||||
|
ctx.fillStyle = '#AAAAAA';
|
||||||
|
|
||||||
|
// Calculate layout for centering Group (Avatar + Text)
|
||||||
|
const text = `by ${username}`;
|
||||||
|
const textMetrics = ctx.measureText(text);
|
||||||
|
const textWidth = textMetrics.width;
|
||||||
|
const avatarSize = 50;
|
||||||
|
const gap = 15;
|
||||||
|
const totalWidth = avatarSize + gap + textWidth;
|
||||||
|
|
||||||
|
const startX = (canvas.width - totalWidth) / 2;
|
||||||
|
const baselineY = 830;
|
||||||
|
|
||||||
|
// Draw Avatar
|
||||||
|
try {
|
||||||
|
const avatar = await loadImage(avatarUrl);
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
// Center avatar vertically relative to text roughly (baseline - ~half cap height)
|
||||||
|
// 36px text ~ 27px cap height. Center roughly at baselineY - 14
|
||||||
|
const avatarCenterY = baselineY - 14;
|
||||||
|
ctx.arc(startX + avatarSize / 2, avatarCenterY, avatarSize / 2, 0, Math.PI * 2);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.clip();
|
||||||
|
ctx.drawImage(avatar, startX, avatarCenterY - avatarSize / 2, avatarSize, avatarSize);
|
||||||
|
ctx.restore();
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback if avatar fails to load, just don't draw it (or maybe shift text?)
|
||||||
|
// For now, let's just proceed, the text will be off-center if avatar is missing but that's acceptable edge case
|
||||||
|
console.error("Failed to load avatar", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw Text
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(text, startX + avatarSize + gap, baselineY);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '72px IBMPlexMono-Bold'; // Match Amount size
|
||||||
|
ctx.fillStyle = '#E6D2B5'; // Lighter gold/beige for better contrast
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.shadowBlur = 10;
|
||||||
|
ctx.shadowColor = 'rgba(0, 0, 0, 0.8)'; // Dark shadow for contrast
|
||||||
|
ctx.fillText(`${amount} ${currency}`, canvas.width / 2, 760); // Same position as Unclaimed Amount
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Crop the image by 64px on all sides
|
||||||
|
const croppedWidth = template.width - 128;
|
||||||
|
const croppedHeight = template.height - 128;
|
||||||
|
const croppedCanvas = createCanvas(croppedWidth, croppedHeight);
|
||||||
|
const croppedCtx = croppedCanvas.getContext('2d');
|
||||||
|
|
||||||
|
// Draw the original canvas onto the cropped canvas, shifted by -64
|
||||||
|
croppedCtx.drawImage(canvas, -64, -64);
|
||||||
|
|
||||||
|
return croppedCanvas.toBuffer('image/png');
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas';
|
import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas';
|
||||||
import { levelingService } from '@/modules/leveling/leveling.service';
|
import { levelingService } from '@shared/modules/leveling/leveling.service';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
// Register Fonts
|
// Register Fonts
|
||||||
const fontDir = path.join(process.cwd(), 'src', 'assets', 'fonts');
|
const fontDir = path.join(process.cwd(), 'bot', 'assets', 'fonts');
|
||||||
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold');
|
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold');
|
||||||
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
|
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
|
||||||
|
|
||||||
@@ -18,8 +18,8 @@ interface StudentCardData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateStudentIdCard(data: StudentCardData): Promise<Buffer> {
|
export async function generateStudentIdCard(data: StudentCardData): Promise<Buffer> {
|
||||||
const templatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'studentID', 'template.png');
|
const templatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'studentID', 'template.png');
|
||||||
const classTemplatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'studentID', `Constellation-${data.className}.png`);
|
const classTemplatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'studentID', `Constellation-${data.className}.png`);
|
||||||
|
|
||||||
const template = await loadImage(templatePath);
|
const template = await loadImage(templatePath);
|
||||||
const classTemplate = await loadImage(classTemplatePath);
|
const classTemplate = await loadImage(classTemplatePath);
|
||||||
49
bot/index.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
|
import { env } from "@shared/lib/env";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { startWebServerFromRoot } from "../web/src/server";
|
||||||
|
|
||||||
|
// Load commands & events
|
||||||
|
await AuroraClient.loadCommands();
|
||||||
|
await AuroraClient.loadEvents();
|
||||||
|
await AuroraClient.deployCommands();
|
||||||
|
await AuroraClient.setupSystemEvents();
|
||||||
|
|
||||||
|
console.log("🌐 Starting web server...");
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
|
||||||
|
const webProjectPath = join(import.meta.dir, "../web");
|
||||||
|
const webPort = Number(process.env.WEB_PORT) || 3000;
|
||||||
|
const webHost = process.env.HOST || "0.0.0.0";
|
||||||
|
|
||||||
|
// Start web server in the same process
|
||||||
|
const webServer = await startWebServerFromRoot(webProjectPath, {
|
||||||
|
port: webPort,
|
||||||
|
hostname: webHost,
|
||||||
|
});
|
||||||
|
|
||||||
|
// login with the token from .env
|
||||||
|
if (!env.DISCORD_BOT_TOKEN) {
|
||||||
|
throw new Error("❌ DISCORD_BOT_TOKEN is not set in environment variables.");
|
||||||
|
}
|
||||||
|
AuroraClient.login(env.DISCORD_BOT_TOKEN);
|
||||||
|
|
||||||
|
// Handle graceful shutdown
|
||||||
|
const shutdownHandler = async () => {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
console.log("🛑 Shutdown signal received. Stopping services...");
|
||||||
|
|
||||||
|
// Stop web server
|
||||||
|
await webServer.stop();
|
||||||
|
|
||||||
|
// Stop bot
|
||||||
|
AuroraClient.shutdown();
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on("SIGINT", shutdownHandler);
|
||||||
|
process.on("SIGTERM", shutdownHandler);
|
||||||
111
bot/lib/BotClient.test.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { describe, expect, test, mock, beforeEach, spyOn } from "bun:test";
|
||||||
|
import { systemEvents, EVENTS } from "@shared/lib/events";
|
||||||
|
|
||||||
|
// Mock Discord.js Client and related classes
|
||||||
|
mock.module("discord.js", () => ({
|
||||||
|
Client: class {
|
||||||
|
constructor() { }
|
||||||
|
on() { }
|
||||||
|
once() { }
|
||||||
|
login() { }
|
||||||
|
destroy() { }
|
||||||
|
removeAllListeners() { }
|
||||||
|
},
|
||||||
|
Collection: Map,
|
||||||
|
GatewayIntentBits: { Guilds: 1, MessageContent: 1, GuildMessages: 1, GuildMembers: 1 },
|
||||||
|
REST: class {
|
||||||
|
setToken() { return this; }
|
||||||
|
put() { return Promise.resolve([]); }
|
||||||
|
},
|
||||||
|
Routes: {
|
||||||
|
applicationGuildCommands: () => 'guild_route',
|
||||||
|
applicationCommands: () => 'global_route'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock loaders to avoid filesystem access during client init
|
||||||
|
mock.module("../lib/loaders/CommandLoader", () => ({
|
||||||
|
CommandLoader: class {
|
||||||
|
constructor() { }
|
||||||
|
loadFromDirectory() { return Promise.resolve({ loaded: 0, skipped: 0, errors: [] }); }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
mock.module("../lib/loaders/EventLoader", () => ({
|
||||||
|
EventLoader: class {
|
||||||
|
constructor() { }
|
||||||
|
loadFromDirectory() { return Promise.resolve({ loaded: 0, skipped: 0, errors: [] }); }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock dashboard service to prevent network/db calls during event handling
|
||||||
|
mock.module("@shared/modules/economy/lootdrop.service", () => ({
|
||||||
|
lootdropService: { clearCaches: mock(async () => { }) }
|
||||||
|
}));
|
||||||
|
mock.module("@shared/modules/trade/trade.service", () => ({
|
||||||
|
tradeService: { clearSessions: mock(() => { }) }
|
||||||
|
}));
|
||||||
|
mock.module("@/modules/admin/item_wizard", () => ({
|
||||||
|
clearDraftSessions: mock(() => { })
|
||||||
|
}));
|
||||||
|
mock.module("@shared/modules/dashboard/dashboard.service", () => ({
|
||||||
|
dashboardService: {
|
||||||
|
recordEvent: mock(() => Promise.resolve())
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("AuroraClient System Events", () => {
|
||||||
|
let AuroraClient: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
systemEvents.removeAllListeners();
|
||||||
|
const module = await import("./BotClient");
|
||||||
|
AuroraClient = module.AuroraClient;
|
||||||
|
AuroraClient.maintenanceMode = false;
|
||||||
|
// MUST call explicitly now
|
||||||
|
await AuroraClient.setupSystemEvents();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Case: Maintenance Mode Toggle
|
||||||
|
* Requirement: Client state should update when event is received
|
||||||
|
*/
|
||||||
|
test("should toggle maintenanceMode when MAINTENANCE_MODE event is received", async () => {
|
||||||
|
systemEvents.emit(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled: true, reason: "Testing" });
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 30));
|
||||||
|
expect(AuroraClient.maintenanceMode).toBe(true);
|
||||||
|
|
||||||
|
systemEvents.emit(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled: false });
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 30));
|
||||||
|
expect(AuroraClient.maintenanceMode).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Case: Command Reload
|
||||||
|
* Requirement: loadCommands and deployCommands should be called
|
||||||
|
*/
|
||||||
|
test("should reload commands when RELOAD_COMMANDS event is received", async () => {
|
||||||
|
const loadSpy = spyOn(AuroraClient, "loadCommands").mockImplementation(() => Promise.resolve());
|
||||||
|
const deploySpy = spyOn(AuroraClient, "deployCommands").mockImplementation(() => Promise.resolve());
|
||||||
|
|
||||||
|
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(loadSpy).toHaveBeenCalled();
|
||||||
|
expect(deploySpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Case: Cache Clearance
|
||||||
|
* Requirement: Service clear methods should be triggered
|
||||||
|
*/
|
||||||
|
test("should trigger service cache clearance when CLEAR_CACHE is received", async () => {
|
||||||
|
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||||
|
const { tradeService } = await import("@shared/modules/trade/trade.service");
|
||||||
|
|
||||||
|
systemEvents.emit(EVENTS.ACTIONS.CLEAR_CACHE);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(lootdropService.clearCaches).toHaveBeenCalled();
|
||||||
|
expect(tradeService.clearSessions).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
179
bot/lib/BotClient.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { Command } from "@shared/lib/types";
|
||||||
|
import { env } from "@shared/lib/env";
|
||||||
|
import { CommandLoader } from "@lib/loaders/CommandLoader";
|
||||||
|
import { EventLoader } from "@lib/loaders/EventLoader";
|
||||||
|
|
||||||
|
export class Client extends DiscordClient {
|
||||||
|
|
||||||
|
commands: Collection<string, Command>;
|
||||||
|
knownCommands: Map<string, string>;
|
||||||
|
lastCommandTimestamp: number | null = null;
|
||||||
|
maintenanceMode: boolean = false;
|
||||||
|
private commandLoader: CommandLoader;
|
||||||
|
private eventLoader: EventLoader;
|
||||||
|
|
||||||
|
constructor({ intents }: { intents: number[] }) {
|
||||||
|
super({ intents });
|
||||||
|
this.commands = new Collection<string, Command>();
|
||||||
|
this.knownCommands = new Map<string, string>();
|
||||||
|
this.commandLoader = new CommandLoader(this);
|
||||||
|
this.eventLoader = new EventLoader(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setupSystemEvents() {
|
||||||
|
const { systemEvents, EVENTS } = await import("@shared/lib/events");
|
||||||
|
|
||||||
|
systemEvents.on(EVENTS.ACTIONS.RELOAD_COMMANDS, async () => {
|
||||||
|
console.log("🔄 System Action: Reloading commands...");
|
||||||
|
try {
|
||||||
|
await this.loadCommands(true);
|
||||||
|
await this.deployCommands();
|
||||||
|
|
||||||
|
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||||
|
await dashboardService.recordEvent({
|
||||||
|
type: "success",
|
||||||
|
message: "Bot: Commands reloaded and redeployed",
|
||||||
|
icon: "✅"
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to reload commands:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
systemEvents.on(EVENTS.ACTIONS.CLEAR_CACHE, async () => {
|
||||||
|
console.log("<22> System Action: Clearing all internal caches...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Lootdrop Service
|
||||||
|
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||||
|
await lootdropService.clearCaches();
|
||||||
|
|
||||||
|
// 2. Trade Service
|
||||||
|
const { tradeService } = await import("@shared/modules/trade/trade.service");
|
||||||
|
tradeService.clearSessions();
|
||||||
|
|
||||||
|
// 3. Item Wizard
|
||||||
|
const { clearDraftSessions } = await import("@/modules/admin/item_wizard");
|
||||||
|
clearDraftSessions();
|
||||||
|
|
||||||
|
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||||
|
await dashboardService.recordEvent({
|
||||||
|
type: "success",
|
||||||
|
message: "Bot: All internal caches and sessions cleared",
|
||||||
|
icon: "🧼"
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to clear caches:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
systemEvents.on(EVENTS.ACTIONS.MAINTENANCE_MODE, async (data: { enabled: boolean, reason?: string }) => {
|
||||||
|
const { enabled, reason } = data;
|
||||||
|
console.log(`🛠️ System Action: Maintenance mode ${enabled ? "ON" : "OFF"}${reason ? ` (${reason})` : ""}`);
|
||||||
|
this.maintenanceMode = enabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadCommands(reload: boolean = false) {
|
||||||
|
if (reload) {
|
||||||
|
this.commands.clear();
|
||||||
|
this.knownCommands.clear();
|
||||||
|
console.log("♻️ Reloading commands...");
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandsPath = join(import.meta.dir, '../commands');
|
||||||
|
const result = await this.commandLoader.loadFromDirectory(commandsPath, reload);
|
||||||
|
|
||||||
|
console.log(`📦 Command loading complete: ${result.loaded} loaded, ${result.skipped} skipped, ${result.errors.length} errors`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadEvents(reload: boolean = false) {
|
||||||
|
if (reload) {
|
||||||
|
this.removeAllListeners();
|
||||||
|
console.log("♻️ Reloading events...");
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventsPath = join(import.meta.dir, '../events');
|
||||||
|
const result = await this.eventLoader.loadFromDirectory(eventsPath, reload);
|
||||||
|
|
||||||
|
console.log(`📦 Event loading complete: ${result.loaded} loaded, ${result.skipped} skipped, ${result.errors.length} errors`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async deployCommands() {
|
||||||
|
// We use env.DISCORD_BOT_TOKEN directly so this can run without client.login()
|
||||||
|
const token = env.DISCORD_BOT_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
console.error("DISCORD_BOT_TOKEN is not set.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rest = new REST().setToken(token);
|
||||||
|
const commandsData = this.commands.map(c => c.data.toJSON());
|
||||||
|
const guildId = env.DISCORD_GUILD_ID;
|
||||||
|
const clientId = env.DISCORD_CLIENT_ID;
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
console.error("DISCORD_CLIENT_ID is not set.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Started refreshing ${commandsData.length} application (/) commands.`);
|
||||||
|
|
||||||
|
let data;
|
||||||
|
if (guildId) {
|
||||||
|
console.log(`Registering commands to guild: ${guildId}`);
|
||||||
|
data = await rest.put(
|
||||||
|
Routes.applicationGuildCommands(clientId, guildId),
|
||||||
|
{ body: commandsData },
|
||||||
|
);
|
||||||
|
// Clear global commands to avoid duplicates
|
||||||
|
await rest.put(Routes.applicationCommands(clientId), { body: [] });
|
||||||
|
} else {
|
||||||
|
console.log('Registering commands globally');
|
||||||
|
data = await rest.put(
|
||||||
|
Routes.applicationCommands(clientId),
|
||||||
|
{ body: commandsData },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Successfully reloaded ${(data as any).length} application (/) commands.`);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 50001) {
|
||||||
|
console.warn("Missing Access: The bot is not in the guild or lacks 'applications.commands' scope.");
|
||||||
|
console.warn(" If you are testing locally, make sure you invited the bot with scope 'bot applications.commands'.");
|
||||||
|
} else {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdown() {
|
||||||
|
const { setShuttingDown, waitForTransactions } = await import("./shutdown");
|
||||||
|
const { closeDatabase } = await import("@shared/db/DrizzleClient");
|
||||||
|
|
||||||
|
console.log("🛑 Shutdown signal received. Starting graceful shutdown...");
|
||||||
|
setShuttingDown(true);
|
||||||
|
|
||||||
|
// Wait for transactions to complete
|
||||||
|
console.log("⏳ Waiting for active transactions to complete...");
|
||||||
|
await waitForTransactions(10000);
|
||||||
|
|
||||||
|
// Destroy Discord client
|
||||||
|
console.log("🔌 Disconnecting from Discord...");
|
||||||
|
this.destroy();
|
||||||
|
|
||||||
|
// Close database
|
||||||
|
console.log("🗄️ Closing database connection...");
|
||||||
|
await closeDatabase();
|
||||||
|
|
||||||
|
console.log("👋 Graceful shutdown complete. Exiting.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers] });
|
||||||
74
bot/lib/clientStats.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, test, expect, beforeEach, mock, afterEach } from "bun:test";
|
||||||
|
import { getClientStats, clearStatsCache } from "./clientStats";
|
||||||
|
|
||||||
|
// Mock AuroraClient
|
||||||
|
mock.module("./BotClient", () => ({
|
||||||
|
AuroraClient: {
|
||||||
|
guilds: {
|
||||||
|
cache: {
|
||||||
|
size: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ws: {
|
||||||
|
ping: 42,
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
cache: {
|
||||||
|
size: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
commands: {
|
||||||
|
size: 20,
|
||||||
|
},
|
||||||
|
lastCommandTimestamp: 1641481200000,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("clientStats", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearStatsCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return client stats", () => {
|
||||||
|
const stats = getClientStats();
|
||||||
|
|
||||||
|
expect(stats.guilds).toBe(5);
|
||||||
|
expect(stats.ping).toBe(42);
|
||||||
|
expect(stats.cachedUsers).toBe(100);
|
||||||
|
expect(stats.commandsRegistered).toBe(20);
|
||||||
|
expect(typeof stats.uptime).toBe("number"); // Can't mock process.uptime easily
|
||||||
|
expect(stats.lastCommandTimestamp).toBe(1641481200000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should cache stats for 30 seconds", () => {
|
||||||
|
const stats1 = getClientStats();
|
||||||
|
const stats2 = getClientStats();
|
||||||
|
|
||||||
|
// Should return same object (cached)
|
||||||
|
expect(stats1).toBe(stats2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should refresh cache after TTL expires", async () => {
|
||||||
|
const stats1 = getClientStats();
|
||||||
|
|
||||||
|
// Wait for cache to expire (simulate by clearing and waiting)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 35));
|
||||||
|
clearStatsCache();
|
||||||
|
|
||||||
|
const stats2 = getClientStats();
|
||||||
|
|
||||||
|
// Should be different objects (new fetch)
|
||||||
|
expect(stats1).not.toBe(stats2);
|
||||||
|
// But values should be the same (mocked client)
|
||||||
|
expect(stats1.guilds).toBe(stats2.guilds);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearStatsCache should invalidate cache", () => {
|
||||||
|
const stats1 = getClientStats();
|
||||||
|
clearStatsCache();
|
||||||
|
const stats2 = getClientStats();
|
||||||
|
|
||||||
|
// Should be different objects
|
||||||
|
expect(stats1).not.toBe(stats2);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
bot/lib/clientStats.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { AuroraClient } from "./BotClient";
|
||||||
|
import type { ClientStats } from "@shared/modules/dashboard/dashboard.types";
|
||||||
|
|
||||||
|
// Cache for client stats (30 second TTL)
|
||||||
|
let cachedStats: ClientStats | null = null;
|
||||||
|
let lastFetchTime: number = 0;
|
||||||
|
const CACHE_TTL_MS = 30 * 1000; // 30 seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Discord client statistics with caching
|
||||||
|
* Respects rate limits by caching for 30 seconds
|
||||||
|
*/
|
||||||
|
export function getClientStats(): ClientStats {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Return cached stats if still valid
|
||||||
|
if (cachedStats && (now - lastFetchTime) < CACHE_TTL_MS) {
|
||||||
|
return cachedStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh stats
|
||||||
|
const stats: ClientStats = {
|
||||||
|
bot: {
|
||||||
|
name: AuroraClient.user?.username || "Aurora",
|
||||||
|
avatarUrl: AuroraClient.user?.displayAvatarURL() || null,
|
||||||
|
},
|
||||||
|
guilds: AuroraClient.guilds.cache.size,
|
||||||
|
ping: AuroraClient.ws.ping,
|
||||||
|
cachedUsers: AuroraClient.users.cache.size,
|
||||||
|
commandsRegistered: AuroraClient.commands.size,
|
||||||
|
commandsKnown: AuroraClient.knownCommands.size,
|
||||||
|
uptime: process.uptime(),
|
||||||
|
lastCommandTimestamp: AuroraClient.lastCommandTimestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
cachedStats = stats;
|
||||||
|
lastFetchTime = now;
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the stats cache (useful for testing)
|
||||||
|
*/
|
||||||
|
export function clearStatsCache(): void {
|
||||||
|
cachedStats = null;
|
||||||
|
lastFetchTime = 0;
|
||||||
|
}
|
||||||
47
bot/lib/db.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||||
|
|
||||||
|
// Mock DrizzleClient
|
||||||
|
mock.module("./DrizzleClient", () => ({
|
||||||
|
DrizzleClient: {
|
||||||
|
transaction: async (cb: any) => cb("MOCK_TX")
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { withTransaction } from "./db";
|
||||||
|
import { setShuttingDown, getActiveTransactions, decrementTransactions } from "./shutdown";
|
||||||
|
|
||||||
|
describe("db withTransaction", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setShuttingDown(false);
|
||||||
|
// Reset transaction count
|
||||||
|
while (getActiveTransactions() > 0) {
|
||||||
|
decrementTransactions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow transactions when not shutting down", async () => {
|
||||||
|
const result = await withTransaction(async (tx) => {
|
||||||
|
return "success";
|
||||||
|
});
|
||||||
|
expect(result).toBe("success");
|
||||||
|
expect(getActiveTransactions()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error when shutting down", async () => {
|
||||||
|
setShuttingDown(true);
|
||||||
|
expect(withTransaction(async (tx) => {
|
||||||
|
return "success";
|
||||||
|
})).rejects.toThrow("System is shutting down, no new transactions allowed.");
|
||||||
|
expect(getActiveTransactions()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should increment and decrement transaction count", async () => {
|
||||||
|
let countDuring = 0;
|
||||||
|
await withTransaction(async (tx) => {
|
||||||
|
countDuring = getActiveTransactions();
|
||||||
|
return "ok";
|
||||||
|
});
|
||||||
|
expect(countDuring).toBe(1);
|
||||||
|
expect(getActiveTransactions()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
25
bot/lib/db.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
|
import type { Transaction } from "@shared/lib/types";
|
||||||
|
import { isShuttingDown, incrementTransactions, decrementTransactions } from "./shutdown";
|
||||||
|
|
||||||
|
export const withTransaction = async <T>(
|
||||||
|
callback: (tx: Transaction) => Promise<T>,
|
||||||
|
tx?: Transaction
|
||||||
|
): Promise<T> => {
|
||||||
|
if (tx) {
|
||||||
|
return await callback(tx);
|
||||||
|
} else {
|
||||||
|
if (isShuttingDown()) {
|
||||||
|
throw new Error("System is shutting down, no new transactions allowed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
incrementTransactions();
|
||||||
|
try {
|
||||||
|
return await DrizzleClient.transaction(async (newTx) => {
|
||||||
|
return await callback(newTx);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
decrementTransactions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
22
bot/lib/handlers/AutocompleteHandler.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { AutocompleteInteraction } from "discord.js";
|
||||||
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles autocomplete interactions for slash commands
|
||||||
|
*/
|
||||||
|
export class AutocompleteHandler {
|
||||||
|
static async handle(interaction: AutocompleteInteraction): Promise<void> {
|
||||||
|
const command = AuroraClient.commands.get(interaction.commandName);
|
||||||
|
|
||||||
|
if (!command || !command.autocomplete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await command.autocomplete(interaction);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error handling autocomplete for ${interaction.commandName}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
bot/lib/handlers/CommandHandler.test.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||||
|
import { CommandHandler } from "./CommandHandler";
|
||||||
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
|
import { ChatInputCommandInteraction } from "discord.js";
|
||||||
|
|
||||||
|
// Mock UserService
|
||||||
|
mock.module("@shared/modules/user/user.service", () => ({
|
||||||
|
userService: {
|
||||||
|
getOrCreateUser: mock(() => Promise.resolve())
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("CommandHandler", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
AuroraClient.commands.clear();
|
||||||
|
AuroraClient.lastCommandTimestamp = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should update lastCommandTimestamp on successful execution", async () => {
|
||||||
|
const executeSuccess = mock(() => Promise.resolve());
|
||||||
|
AuroraClient.commands.set("test", {
|
||||||
|
data: { name: "test" } as any,
|
||||||
|
execute: executeSuccess
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const interaction = {
|
||||||
|
commandName: "test",
|
||||||
|
user: { id: "123", username: "testuser" }
|
||||||
|
} as unknown as ChatInputCommandInteraction;
|
||||||
|
|
||||||
|
await CommandHandler.handle(interaction);
|
||||||
|
|
||||||
|
expect(executeSuccess).toHaveBeenCalled();
|
||||||
|
expect(AuroraClient.lastCommandTimestamp).not.toBeNull();
|
||||||
|
expect(AuroraClient.lastCommandTimestamp).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not update lastCommandTimestamp on failed execution", async () => {
|
||||||
|
const executeError = mock(() => Promise.reject(new Error("Command Failed")));
|
||||||
|
AuroraClient.commands.set("fail", {
|
||||||
|
data: { name: "fail" } as any,
|
||||||
|
execute: executeError
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const interaction = {
|
||||||
|
commandName: "fail",
|
||||||
|
user: { id: "123", username: "testuser" },
|
||||||
|
replied: false,
|
||||||
|
deferred: false,
|
||||||
|
reply: mock(() => Promise.resolve()),
|
||||||
|
followUp: mock(() => Promise.resolve())
|
||||||
|
} as unknown as ChatInputCommandInteraction;
|
||||||
|
|
||||||
|
await CommandHandler.handle(interaction);
|
||||||
|
|
||||||
|
expect(executeError).toHaveBeenCalled();
|
||||||
|
expect(AuroraClient.lastCommandTimestamp).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should block execution when maintenance mode is active", async () => {
|
||||||
|
AuroraClient.maintenanceMode = true;
|
||||||
|
const executeSpy = mock(() => Promise.resolve());
|
||||||
|
AuroraClient.commands.set("maint-test", {
|
||||||
|
data: { name: "maint-test" } as any,
|
||||||
|
execute: executeSpy
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const interaction = {
|
||||||
|
commandName: "maint-test",
|
||||||
|
user: { id: "123", username: "testuser" },
|
||||||
|
reply: mock(() => Promise.resolve())
|
||||||
|
} as unknown as ChatInputCommandInteraction;
|
||||||
|
|
||||||
|
await CommandHandler.handle(interaction);
|
||||||
|
|
||||||
|
expect(executeSpy).not.toHaveBeenCalled();
|
||||||
|
expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
flags: expect.anything()
|
||||||
|
}));
|
||||||
|
|
||||||
|
AuroraClient.maintenanceMode = false; // Reset for other tests
|
||||||
|
});
|
||||||
|
});
|
||||||
48
bot/lib/handlers/CommandHandler.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
|
||||||
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles slash command execution
|
||||||
|
* Includes user validation and comprehensive error handling
|
||||||
|
*/
|
||||||
|
export class CommandHandler {
|
||||||
|
static async handle(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||||
|
const command = AuroraClient.commands.get(interaction.commandName);
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
console.error(`No command matching ${interaction.commandName} was found.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check maintenance mode
|
||||||
|
if (AuroraClient.maintenanceMode) {
|
||||||
|
const errorEmbed = createErrorEmbed('The bot is currently undergoing maintenance. Please try again later.');
|
||||||
|
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await command.execute(interaction);
|
||||||
|
AuroraClient.lastCommandTimestamp = Date.now();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(String(error));
|
||||||
|
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
|
||||||
|
|
||||||
|
if (interaction.replied || interaction.deferred) {
|
||||||
|
await interaction.followUp({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
|
||||||
|
} else {
|
||||||
|
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
bot/lib/handlers/ComponentInteractionHandler.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js";
|
||||||
|
|
||||||
|
import { UserError } from "@lib/errors";
|
||||||
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
|
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles component interactions (buttons, select menus, modals)
|
||||||
|
* Routes to appropriate handlers based on customId patterns
|
||||||
|
* Provides centralized error handling with UserError differentiation
|
||||||
|
*/
|
||||||
|
export class ComponentInteractionHandler {
|
||||||
|
static async handle(interaction: ComponentInteraction): Promise<void> {
|
||||||
|
const { interactionRoutes } = await import("@lib/interaction.routes");
|
||||||
|
|
||||||
|
for (const route of interactionRoutes) {
|
||||||
|
if (route.predicate(interaction)) {
|
||||||
|
const module = await route.handler();
|
||||||
|
const handlerMethod = module[route.method];
|
||||||
|
|
||||||
|
if (typeof handlerMethod === 'function') {
|
||||||
|
try {
|
||||||
|
await handlerMethod(interaction);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
await this.handleError(interaction, error, route.method);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error(`Handler method ${route.method} not found in module`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles errors from interaction handlers
|
||||||
|
* Differentiates between UserError (user-facing) and system errors
|
||||||
|
*/
|
||||||
|
private static async handleError(
|
||||||
|
interaction: ComponentInteraction,
|
||||||
|
error: unknown,
|
||||||
|
handlerName: string
|
||||||
|
): Promise<void> {
|
||||||
|
const isUserError = error instanceof UserError;
|
||||||
|
|
||||||
|
// Determine error message
|
||||||
|
const errorMessage = isUserError
|
||||||
|
? (error as Error).message
|
||||||
|
: 'An unexpected error occurred. Please try again later.';
|
||||||
|
|
||||||
|
// Log system errors (non-user errors) for debugging
|
||||||
|
if (!isUserError) {
|
||||||
|
console.error(`Error in ${handlerName}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorEmbed = createErrorEmbed(errorMessage);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Handle different interaction states
|
||||||
|
if (interaction.replied || interaction.deferred) {
|
||||||
|
await interaction.followUp({
|
||||||
|
embeds: [errorEmbed],
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await interaction.reply({
|
||||||
|
embeds: [errorEmbed],
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (replyError) {
|
||||||
|
// If we can't send a reply, log it
|
||||||
|
console.error(`Failed to send error response in ${handlerName}:`, replyError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
bot/lib/handlers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { ComponentInteractionHandler } from "./ComponentInteractionHandler";
|
||||||
|
export { AutocompleteHandler } from "./AutocompleteHandler";
|
||||||
|
export { CommandHandler } from "./CommandHandler";
|
||||||
@@ -1,19 +1,32 @@
|
|||||||
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
|
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
|
||||||
|
|
||||||
type InteractionHandler = (interaction: any) => Promise<void>;
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route definition
|
||||||
interface InteractionRoute {
|
interface InteractionRoute {
|
||||||
predicate: (interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction) => boolean;
|
predicate: (interaction: ComponentInteraction) => boolean;
|
||||||
handler: () => Promise<any>;
|
handler: () => Promise<InteractionModule>;
|
||||||
method: string;
|
method: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const interactionRoutes: InteractionRoute[] = [
|
export const interactionRoutes: InteractionRoute[] = [
|
||||||
|
// --- TRADE MODULE ---
|
||||||
{
|
{
|
||||||
predicate: (i) => i.customId.startsWith("trade_") || i.customId === "amount",
|
predicate: (i) => i.customId.startsWith("trade_") || i.customId === "amount",
|
||||||
handler: () => import("@/modules/trade/trade.interaction"),
|
handler: () => import("@/modules/trade/trade.interaction"),
|
||||||
method: 'handleTradeInteraction'
|
method: 'handleTradeInteraction'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- ECONOMY MODULE ---
|
||||||
{
|
{
|
||||||
predicate: (i) => i.isButton() && i.customId.startsWith("shop_buy_"),
|
predicate: (i) => i.isButton() && i.customId.startsWith("shop_buy_"),
|
||||||
handler: () => import("@/modules/economy/shop.interaction"),
|
handler: () => import("@/modules/economy/shop.interaction"),
|
||||||
@@ -24,16 +37,27 @@ export const interactionRoutes: InteractionRoute[] = [
|
|||||||
handler: () => import("@/modules/economy/lootdrop.interaction"),
|
handler: () => import("@/modules/economy/lootdrop.interaction"),
|
||||||
method: 'handleLootdropInteraction'
|
method: 'handleLootdropInteraction'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
predicate: (i) => i.isButton() && i.customId.startsWith("trivia_"),
|
||||||
|
handler: () => import("@/modules/trivia/trivia.interaction"),
|
||||||
|
method: 'handleTriviaInteraction'
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- ADMIN MODULE ---
|
||||||
{
|
{
|
||||||
predicate: (i) => i.customId.startsWith("createitem_"),
|
predicate: (i) => i.customId.startsWith("createitem_"),
|
||||||
handler: () => import("@/modules/admin/item_wizard"),
|
handler: () => import("@/modules/admin/item_wizard"),
|
||||||
method: 'handleItemWizardInteraction'
|
method: 'handleItemWizardInteraction'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- USER MODULE ---
|
||||||
{
|
{
|
||||||
predicate: (i) => i.isButton() && i.customId === "enrollment",
|
predicate: (i) => i.isButton() && i.customId === "enrollment",
|
||||||
handler: () => import("@/modules/user/enrollment.interaction"),
|
handler: () => import("@/modules/user/enrollment.interaction"),
|
||||||
method: 'handleEnrollmentInteraction'
|
method: 'handleEnrollmentInteraction'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- FEEDBACK MODULE ---
|
||||||
{
|
{
|
||||||
predicate: (i) => i.customId.startsWith("feedback_"),
|
predicate: (i) => i.customId.startsWith("feedback_"),
|
||||||
handler: () => import("@/modules/feedback/feedback.interaction"),
|
handler: () => import("@/modules/feedback/feedback.interaction"),
|
||||||
114
bot/lib/loaders/CommandLoader.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { readdir } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { Command } from "@shared/lib/types";
|
||||||
|
import { config } from "@shared/lib/config";
|
||||||
|
import type { LoadResult, LoadError } from "./types";
|
||||||
|
import type { Client } from "../BotClient";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles loading commands from the file system
|
||||||
|
*/
|
||||||
|
export class CommandLoader {
|
||||||
|
private client: Client;
|
||||||
|
|
||||||
|
constructor(client: Client) {
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load commands from a directory recursively
|
||||||
|
*/
|
||||||
|
async loadFromDirectory(dir: string, reload: boolean = false): Promise<LoadResult> {
|
||||||
|
const result: LoadResult = { loaded: 0, skipped: 0, errors: [] };
|
||||||
|
await this.scanDirectory(dir, reload, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively scan directory for command files
|
||||||
|
*/
|
||||||
|
private async scanDirectory(dir: string, reload: boolean, result: LoadResult): Promise<void> {
|
||||||
|
try {
|
||||||
|
const files = await readdir(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = join(dir, file.name);
|
||||||
|
|
||||||
|
if (file.isDirectory()) {
|
||||||
|
await this.scanDirectory(filePath, reload, result);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((!file.name.endsWith('.ts') && !file.name.endsWith('.js')) || file.name.endsWith('.test.ts') || file.name.endsWith('.spec.ts')) continue;
|
||||||
|
|
||||||
|
await this.loadCommandFile(filePath, reload, result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error reading directory ${dir}:`, error);
|
||||||
|
result.errors.push({ file: dir, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a single command file
|
||||||
|
*/
|
||||||
|
private async loadCommandFile(filePath: string, reload: boolean, result: LoadResult): Promise<void> {
|
||||||
|
try {
|
||||||
|
const importPath = reload ? `${filePath}?t=${Date.now()}` : filePath;
|
||||||
|
const commandModule = await import(importPath);
|
||||||
|
const commands = Object.values(commandModule);
|
||||||
|
|
||||||
|
if (commands.length === 0) {
|
||||||
|
console.warn(`No commands found in ${filePath}`);
|
||||||
|
result.skipped++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = this.extractCategory(filePath);
|
||||||
|
|
||||||
|
for (const command of commands) {
|
||||||
|
if (this.isValidCommand(command)) {
|
||||||
|
command.category = category;
|
||||||
|
|
||||||
|
// Track all known commands regardless of enabled status
|
||||||
|
this.client.knownCommands.set(command.data.name, category);
|
||||||
|
|
||||||
|
const isEnabled = config.commands[command.data.name] !== false;
|
||||||
|
|
||||||
|
if (!isEnabled) {
|
||||||
|
console.log(`🚫 Skipping disabled command: ${command.data.name}`);
|
||||||
|
result.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client.commands.set(command.data.name, command);
|
||||||
|
console.log(`Loaded command: ${command.data.name}`);
|
||||||
|
result.loaded++;
|
||||||
|
} else {
|
||||||
|
console.warn(`Skipping invalid command in ${filePath}`);
|
||||||
|
result.skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load command from ${filePath}:`, error);
|
||||||
|
result.errors.push({ file: filePath, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract category from file path
|
||||||
|
* e.g., /path/to/commands/admin/features.ts -> "admin"
|
||||||
|
*/
|
||||||
|
private extractCategory(filePath: string): string {
|
||||||
|
const pathParts = filePath.split('/');
|
||||||
|
return pathParts[pathParts.length - 2] ?? "uncategorized";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to validate command structure
|
||||||
|
*/
|
||||||
|
private isValidCommand(command: any): command is Command {
|
||||||
|
return command && typeof command === 'object' && 'data' in command && 'execute' in command;
|
||||||
|
}
|
||||||
|
}
|
||||||
85
bot/lib/loaders/EventLoader.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { readdir } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { Event } from "@shared/lib/types";
|
||||||
|
import type { LoadResult } from "./types";
|
||||||
|
import type { Client } from "../BotClient";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles loading events from the file system
|
||||||
|
*/
|
||||||
|
export class EventLoader {
|
||||||
|
private client: Client;
|
||||||
|
|
||||||
|
constructor(client: Client) {
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load events from a directory recursively
|
||||||
|
*/
|
||||||
|
async loadFromDirectory(dir: string, reload: boolean = false): Promise<LoadResult> {
|
||||||
|
const result: LoadResult = { loaded: 0, skipped: 0, errors: [] };
|
||||||
|
await this.scanDirectory(dir, reload, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively scan directory for event files
|
||||||
|
*/
|
||||||
|
private async scanDirectory(dir: string, reload: boolean, result: LoadResult): Promise<void> {
|
||||||
|
try {
|
||||||
|
const files = await readdir(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = join(dir, file.name);
|
||||||
|
|
||||||
|
if (file.isDirectory()) {
|
||||||
|
await this.scanDirectory(filePath, reload, result);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.name.endsWith('.ts') && !file.name.endsWith('.js')) continue;
|
||||||
|
|
||||||
|
await this.loadEventFile(filePath, reload, result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error reading directory ${dir}:`, error);
|
||||||
|
result.errors.push({ file: dir, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a single event file
|
||||||
|
*/
|
||||||
|
private async loadEventFile(filePath: string, reload: boolean, result: LoadResult): Promise<void> {
|
||||||
|
try {
|
||||||
|
const importPath = reload ? `${filePath}?t=${Date.now()}` : filePath;
|
||||||
|
const eventModule = await import(importPath);
|
||||||
|
const event = eventModule.default;
|
||||||
|
|
||||||
|
if (this.isValidEvent(event)) {
|
||||||
|
if (event.once) {
|
||||||
|
this.client.once(event.name, (...args) => event.execute(...args));
|
||||||
|
} else {
|
||||||
|
this.client.on(event.name, (...args) => event.execute(...args));
|
||||||
|
}
|
||||||
|
console.log(`Loaded event: ${event.name}`);
|
||||||
|
result.loaded++;
|
||||||
|
} else {
|
||||||
|
console.warn(`Skipping invalid event in ${filePath}`);
|
||||||
|
result.skipped++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load event from ${filePath}:`, error);
|
||||||
|
result.errors.push({ file: filePath, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to validate event structure
|
||||||
|
*/
|
||||||
|
private isValidEvent(event: any): event is Event<any> {
|
||||||
|
return event && typeof event === 'object' && 'name' in event && 'execute' in event;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
bot/lib/loaders/types.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Result of loading commands or events
|
||||||
|
*/
|
||||||
|
export interface LoadResult {
|
||||||
|
loaded: number;
|
||||||
|
skipped: number;
|
||||||
|
errors: LoadError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error that occurred during loading
|
||||||
|
*/
|
||||||
|
export interface LoadError {
|
||||||
|
file: string;
|
||||||
|
error: unknown;
|
||||||
|
}
|
||||||
56
bot/lib/shutdown.test.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "bun:test";
|
||||||
|
import { isShuttingDown, setShuttingDown, incrementTransactions, decrementTransactions, getActiveTransactions, waitForTransactions } from "./shutdown";
|
||||||
|
|
||||||
|
describe("shutdown logic", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setShuttingDown(false);
|
||||||
|
while (getActiveTransactions() > 0) {
|
||||||
|
decrementTransactions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should initialize with shuttingDown as false", () => {
|
||||||
|
expect(isShuttingDown()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update shuttingDown state", () => {
|
||||||
|
setShuttingDown(true);
|
||||||
|
expect(isShuttingDown()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should track active transactions", () => {
|
||||||
|
expect(getActiveTransactions()).toBe(0);
|
||||||
|
incrementTransactions();
|
||||||
|
expect(getActiveTransactions()).toBe(1);
|
||||||
|
decrementTransactions();
|
||||||
|
expect(getActiveTransactions()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should wait for transactions to complete", async () => {
|
||||||
|
incrementTransactions();
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
const waitPromise = waitForTransactions(1000);
|
||||||
|
|
||||||
|
// Simulate completion after 200ms
|
||||||
|
setTimeout(() => {
|
||||||
|
decrementTransactions();
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
await waitPromise;
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
|
expect(duration).toBeGreaterThanOrEqual(200);
|
||||||
|
expect(getActiveTransactions()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should timeout if transactions never complete", async () => {
|
||||||
|
incrementTransactions();
|
||||||
|
const start = Date.now();
|
||||||
|
await waitForTransactions(500);
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
|
expect(duration).toBeGreaterThanOrEqual(500);
|
||||||
|
expect(getActiveTransactions()).toBe(1); // Still 1 because we didn't decrement
|
||||||
|
});
|
||||||
|
});
|
||||||
30
bot/lib/shutdown.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
let activeTransactions = 0;
|
||||||
|
|
||||||
|
export const isShuttingDown = () => shuttingDown;
|
||||||
|
export const setShuttingDown = (value: boolean) => {
|
||||||
|
shuttingDown = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const incrementTransactions = () => {
|
||||||
|
activeTransactions++;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decrementTransactions = () => {
|
||||||
|
activeTransactions--;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getActiveTransactions = () => activeTransactions;
|
||||||
|
|
||||||
|
export const waitForTransactions = async (timeoutMs: number = 10000) => {
|
||||||
|
const start = Date.now();
|
||||||
|
while (activeTransactions > 0) {
|
||||||
|
if (Date.now() - start > timeoutMs) {
|
||||||
|
console.warn(`Shutdown timed out waiting for ${activeTransactions} transactions after ${timeoutMs}ms`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -6,13 +6,13 @@ import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction
|
|||||||
const valuesMock = mock((_args: any) => Promise.resolve());
|
const valuesMock = mock((_args: any) => Promise.resolve());
|
||||||
const insertMock = mock(() => ({ values: valuesMock }));
|
const insertMock = mock(() => ({ values: valuesMock }));
|
||||||
|
|
||||||
mock.module("@/lib/DrizzleClient", () => ({
|
mock.module("@shared/db/DrizzleClient", () => ({
|
||||||
DrizzleClient: {
|
DrizzleClient: {
|
||||||
insert: insertMock
|
insert: insertMock
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mock.module("@/db/schema", () => ({
|
mock.module("@db/schema", () => ({
|
||||||
items: "items_schema"
|
items: "items_schema"
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { type Interaction } from "discord.js";
|
import { type Interaction } from "discord.js";
|
||||||
import { items } from "@/db/schema";
|
import { items } from "@db/schema";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
import type { ItemUsageData, ItemEffect } from "@/lib/types";
|
import type { ItemUsageData, ItemEffect } from "@shared/lib/types";
|
||||||
import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
|
import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
|
||||||
import type { DraftItem } from "./item_wizard.types";
|
import type { DraftItem } from "./item_wizard.types";
|
||||||
|
import { ItemType, EffectType } from "@shared/lib/constants";
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ export const renderWizard = (userId: string, isDraft = true) => {
|
|||||||
name: "New Item",
|
name: "New Item",
|
||||||
description: "No description",
|
description: "No description",
|
||||||
rarity: "Common",
|
rarity: "Common",
|
||||||
type: "MATERIAL",
|
type: ItemType.MATERIAL,
|
||||||
price: null,
|
price: null,
|
||||||
iconUrl: "",
|
iconUrl: "",
|
||||||
imageUrl: "",
|
imageUrl: "",
|
||||||
@@ -176,26 +177,26 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
if (type) {
|
if (type) {
|
||||||
let effect: ItemEffect | null = null;
|
let effect: ItemEffect | null = null;
|
||||||
|
|
||||||
if (type === "ADD_XP" || type === "ADD_BALANCE") {
|
if (type === EffectType.ADD_XP || type === EffectType.ADD_BALANCE) {
|
||||||
const amount = parseInt(interaction.fields.getTextInputValue("amount"));
|
const amount = parseInt(interaction.fields.getTextInputValue("amount"));
|
||||||
if (!isNaN(amount)) effect = { type: type as any, amount };
|
if (!isNaN(amount)) effect = { type: type as any, amount };
|
||||||
}
|
}
|
||||||
else if (type === "REPLY_MESSAGE") {
|
else if (type === EffectType.REPLY_MESSAGE) {
|
||||||
effect = { type: "REPLY_MESSAGE", message: interaction.fields.getTextInputValue("message") };
|
effect = { type: EffectType.REPLY_MESSAGE, message: interaction.fields.getTextInputValue("message") };
|
||||||
}
|
}
|
||||||
else if (type === "XP_BOOST") {
|
else if (type === EffectType.XP_BOOST) {
|
||||||
const multiplier = parseFloat(interaction.fields.getTextInputValue("multiplier"));
|
const multiplier = parseFloat(interaction.fields.getTextInputValue("multiplier"));
|
||||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
||||||
if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: "XP_BOOST", multiplier, durationSeconds: duration };
|
if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: EffectType.XP_BOOST, multiplier, durationSeconds: duration };
|
||||||
}
|
}
|
||||||
else if (type === "TEMP_ROLE") {
|
else if (type === EffectType.TEMP_ROLE) {
|
||||||
const roleId = interaction.fields.getTextInputValue("role_id");
|
const roleId = interaction.fields.getTextInputValue("role_id");
|
||||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
||||||
if (roleId && !isNaN(duration)) effect = { type: "TEMP_ROLE", roleId: roleId, durationSeconds: duration };
|
if (roleId && !isNaN(duration)) effect = { type: EffectType.TEMP_ROLE, roleId: roleId, durationSeconds: duration };
|
||||||
}
|
}
|
||||||
else if (type === "COLOR_ROLE") {
|
else if (type === EffectType.COLOR_ROLE) {
|
||||||
const roleId = interaction.fields.getTextInputValue("role_id");
|
const roleId = interaction.fields.getTextInputValue("role_id");
|
||||||
if (roleId) effect = { type: "COLOR_ROLE", roleId: roleId };
|
if (roleId) effect = { type: EffectType.COLOR_ROLE, roleId: roleId };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (effect) {
|
if (effect) {
|
||||||
@@ -240,3 +241,8 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const clearDraftSessions = () => {
|
||||||
|
draftSession.clear();
|
||||||
|
console.log("[ItemWizard] All draft item creation sessions cleared.");
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ItemUsageData } from "@/lib/types";
|
import type { ItemUsageData } from "@shared/lib/types";
|
||||||
|
|
||||||
export interface DraftItem {
|
export interface DraftItem {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -10,12 +10,13 @@ import {
|
|||||||
} from "discord.js";
|
} from "discord.js";
|
||||||
import { createBaseEmbed } from "@lib/embeds";
|
import { createBaseEmbed } from "@lib/embeds";
|
||||||
import type { DraftItem } from "./item_wizard.types";
|
import type { DraftItem } from "./item_wizard.types";
|
||||||
|
import { ItemType } from "@shared/lib/constants";
|
||||||
|
|
||||||
const getItemTypeOptions = () => [
|
const getItemTypeOptions = () => [
|
||||||
{ label: "Material", value: "MATERIAL", description: "Used for crafting or trading" },
|
{ label: "Material", value: ItemType.MATERIAL, description: "Used for crafting or trading" },
|
||||||
{ label: "Consumable", value: "CONSUMABLE", description: "Can be used to gain effects" },
|
{ label: "Consumable", value: ItemType.CONSUMABLE, description: "Can be used to gain effects" },
|
||||||
{ label: "Equipment", value: "EQUIPMENT", description: "Can be equipped (Not yet implemented)" },
|
{ label: "Equipment", value: ItemType.EQUIPMENT, description: "Can be equipped (Not yet implemented)" },
|
||||||
{ label: "Quest Item", value: "QUEST", description: "Required for quests" },
|
{ label: "Quest Item", value: ItemType.QUEST, description: "Required for quests" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const getEffectTypeOptions = () => [
|
const getEffectTypeOptions = () => [
|
||||||
33
bot/modules/admin/update.types.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
|
||||||
|
export interface RestartContext {
|
||||||
|
channelId: string;
|
||||||
|
userId: string;
|
||||||
|
timestamp: number;
|
||||||
|
runMigrations: boolean;
|
||||||
|
installDependencies: boolean;
|
||||||
|
previousCommit: string;
|
||||||
|
newCommit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCheckResult {
|
||||||
|
needsRootInstall: boolean;
|
||||||
|
needsWebInstall: boolean;
|
||||||
|
needsMigrations: boolean;
|
||||||
|
changedFiles: string[];
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateInfo {
|
||||||
|
hasUpdates: boolean;
|
||||||
|
branch: string;
|
||||||
|
currentCommit: string;
|
||||||
|
latestCommit: string;
|
||||||
|
commitCount: number;
|
||||||
|
commits: CommitInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommitInfo {
|
||||||
|
hash: string;
|
||||||
|
message: string;
|
||||||
|
author: string;
|
||||||
|
}
|
||||||
274
bot/modules/admin/update.view.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js";
|
||||||
|
import { createInfoEmbed, createSuccessEmbed, createWarningEmbed, createErrorEmbed } from "@lib/embeds";
|
||||||
|
import type { UpdateInfo, UpdateCheckResult } from "./update.types";
|
||||||
|
|
||||||
|
// Constants for UI
|
||||||
|
const LOG_TRUNCATE_LENGTH = 800;
|
||||||
|
const OUTPUT_TRUNCATE_LENGTH = 400;
|
||||||
|
|
||||||
|
function truncate(text: string, maxLength: number): string {
|
||||||
|
if (!text) return "";
|
||||||
|
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Pre-Update Embeds ============
|
||||||
|
|
||||||
|
export function getCheckingEmbed() {
|
||||||
|
return createInfoEmbed("🔍 Fetching latest changes from remote...", "Checking for Updates");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNoUpdatesEmbed(currentCommit: string) {
|
||||||
|
return createSuccessEmbed(
|
||||||
|
`You're running the latest version.\n\n**Current:** \`${currentCommit}\``,
|
||||||
|
"✅ Already Up to Date"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUpdatesAvailableMessage(
|
||||||
|
updateInfo: UpdateInfo,
|
||||||
|
requirements: UpdateCheckResult,
|
||||||
|
changeCategories: Record<string, number>,
|
||||||
|
force: boolean
|
||||||
|
) {
|
||||||
|
const { branch, currentCommit, latestCommit, commitCount, commits } = updateInfo;
|
||||||
|
const { needsRootInstall, needsWebInstall, needsMigrations } = requirements;
|
||||||
|
|
||||||
|
// Build commit list (max 5)
|
||||||
|
const commitList = commits
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(c => `\`${c.hash}\` ${truncate(c.message, 50)}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const moreCommits = commitCount > 5 ? `\n*...and ${commitCount - 5} more*` : "";
|
||||||
|
|
||||||
|
// Build change categories
|
||||||
|
const categoryList = Object.entries(changeCategories)
|
||||||
|
.map(([cat, count]) => `• ${cat}: ${count} file${count > 1 ? "s" : ""}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
// Build requirements list
|
||||||
|
const reqs: string[] = [];
|
||||||
|
if (needsRootInstall) reqs.push("📦 Install root dependencies");
|
||||||
|
if (needsWebInstall) reqs.push("🌐 Install web dependencies");
|
||||||
|
if (needsMigrations) reqs.push("🗃️ Run database migrations");
|
||||||
|
if (reqs.length === 0) reqs.push("⚡ Quick update (no extra steps)");
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle("📥 Updates Available")
|
||||||
|
.setColor(force ? 0xFF6B6B : 0x5865F2)
|
||||||
|
.addFields(
|
||||||
|
{
|
||||||
|
name: "Version",
|
||||||
|
value: `\`${currentCommit}\` → \`${latestCommit}\``,
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Branch",
|
||||||
|
value: `\`${branch}\``,
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Commits",
|
||||||
|
value: `${commitCount} new commit${commitCount > 1 ? "s" : ""}`,
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Recent Changes",
|
||||||
|
value: commitList + moreCommits || "No commits",
|
||||||
|
inline: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Files Changed",
|
||||||
|
value: categoryList || "Unknown",
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Update Actions",
|
||||||
|
value: reqs.join("\n"),
|
||||||
|
inline: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.setFooter({ text: force ? "⚠️ Force mode enabled" : "This will restart the bot" })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
const confirmButton = new ButtonBuilder()
|
||||||
|
.setCustomId("confirm_update")
|
||||||
|
.setLabel(force ? "Force Update" : "Update Now")
|
||||||
|
.setEmoji(force ? "⚠️" : "🚀")
|
||||||
|
.setStyle(force ? ButtonStyle.Danger : ButtonStyle.Success);
|
||||||
|
|
||||||
|
const cancelButton = new ButtonBuilder()
|
||||||
|
.setCustomId("cancel_update")
|
||||||
|
.setLabel("Cancel")
|
||||||
|
.setStyle(ButtonStyle.Secondary);
|
||||||
|
|
||||||
|
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(confirmButton, cancelButton);
|
||||||
|
|
||||||
|
return { embeds: [embed], components: [row] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Update Progress Embeds ============
|
||||||
|
|
||||||
|
export function getPreparingEmbed() {
|
||||||
|
return createInfoEmbed(
|
||||||
|
"🔒 Saving rollback point...\n📥 Preparing to download updates...",
|
||||||
|
"⏳ Preparing Update"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUpdatingEmbed(requirements: UpdateCheckResult) {
|
||||||
|
const steps: string[] = ["✅ Rollback point saved"];
|
||||||
|
|
||||||
|
steps.push("📥 Downloading updates...");
|
||||||
|
|
||||||
|
if (requirements.needsRootInstall || requirements.needsWebInstall) {
|
||||||
|
steps.push("📦 Dependencies will be installed after restart");
|
||||||
|
}
|
||||||
|
if (requirements.needsMigrations) {
|
||||||
|
steps.push("🗃️ Migrations will run after restart");
|
||||||
|
}
|
||||||
|
|
||||||
|
steps.push("\n🔄 **Restarting now...**");
|
||||||
|
|
||||||
|
return createWarningEmbed(steps.join("\n"), "🚀 Updating");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCancelledEmbed() {
|
||||||
|
return createInfoEmbed("Update cancelled. No changes were made.", "❌ Cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTimeoutEmbed() {
|
||||||
|
return createWarningEmbed(
|
||||||
|
"No response received within 30 seconds.\nRun `/update` again when ready.",
|
||||||
|
"⏰ Timed Out"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getErrorEmbed(error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return createErrorEmbed(
|
||||||
|
`The update could not be completed:\n\`\`\`\n${truncate(message, 500)}\n\`\`\``,
|
||||||
|
"❌ Update Failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Post-Restart Embeds ============
|
||||||
|
|
||||||
|
export interface PostRestartResult {
|
||||||
|
installSuccess: boolean;
|
||||||
|
installOutput: string;
|
||||||
|
migrationSuccess: boolean;
|
||||||
|
migrationOutput: string;
|
||||||
|
ranInstall: boolean;
|
||||||
|
ranMigrations: boolean;
|
||||||
|
previousCommit?: string;
|
||||||
|
newCommit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPostRestartEmbed(result: PostRestartResult, hasRollback: boolean) {
|
||||||
|
const isSuccess = result.installSuccess && result.migrationSuccess;
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(isSuccess ? "✅ Update Complete" : "⚠️ Update Completed with Issues")
|
||||||
|
.setColor(isSuccess ? 0x57F287 : 0xFEE75C)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
// Version info
|
||||||
|
if (result.previousCommit && result.newCommit) {
|
||||||
|
embed.addFields({
|
||||||
|
name: "Version",
|
||||||
|
value: `\`${result.previousCommit}\` → \`${result.newCommit}\``,
|
||||||
|
inline: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Results summary
|
||||||
|
const results: string[] = [];
|
||||||
|
|
||||||
|
if (result.ranInstall) {
|
||||||
|
results.push(result.installSuccess
|
||||||
|
? "✅ Dependencies installed"
|
||||||
|
: "❌ Dependency installation failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.ranMigrations) {
|
||||||
|
results.push(result.migrationSuccess
|
||||||
|
? "✅ Migrations applied"
|
||||||
|
: "❌ Migration failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length > 0) {
|
||||||
|
embed.addFields({
|
||||||
|
name: "Actions Performed",
|
||||||
|
value: results.join("\n"),
|
||||||
|
inline: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output details (collapsed if too long)
|
||||||
|
if (result.installOutput && !result.installSuccess) {
|
||||||
|
embed.addFields({
|
||||||
|
name: "Install Output",
|
||||||
|
value: `\`\`\`\n${truncate(result.installOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
|
||||||
|
inline: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.migrationOutput && !result.migrationSuccess) {
|
||||||
|
embed.addFields({
|
||||||
|
name: "Migration Output",
|
||||||
|
value: `\`\`\`\n${truncate(result.migrationOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
|
||||||
|
inline: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer with rollback hint
|
||||||
|
if (!isSuccess && hasRollback) {
|
||||||
|
embed.setFooter({ text: "💡 Use /update rollback to revert if needed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build components
|
||||||
|
const components: ActionRowBuilder<ButtonBuilder>[] = [];
|
||||||
|
|
||||||
|
if (!isSuccess && hasRollback) {
|
||||||
|
const rollbackButton = new ButtonBuilder()
|
||||||
|
.setCustomId("rollback_update")
|
||||||
|
.setLabel("Rollback")
|
||||||
|
.setEmoji("↩️")
|
||||||
|
.setStyle(ButtonStyle.Danger);
|
||||||
|
|
||||||
|
components.push(new ActionRowBuilder<ButtonBuilder>().addComponents(rollbackButton));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { embeds: [embed], components };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInstallingDependenciesEmbed() {
|
||||||
|
return createInfoEmbed(
|
||||||
|
"📦 Installing dependencies for root and web projects...\nThis may take a moment.",
|
||||||
|
"⏳ Installing Dependencies"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRunningMigrationsEmbed() {
|
||||||
|
return createInfoEmbed(
|
||||||
|
"🗃️ Applying database migrations...",
|
||||||
|
"⏳ Running Migrations"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRollbackSuccessEmbed(commit: string) {
|
||||||
|
return createSuccessEmbed(
|
||||||
|
`Successfully rolled back to commit \`${commit}\`.\nThe bot will restart now.`,
|
||||||
|
"↩️ Rollback Complete"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRollbackFailedEmbed(error: string) {
|
||||||
|
return createErrorEmbed(
|
||||||
|
`Could not rollback:\n\`\`\`\n${error}\n\`\`\``,
|
||||||
|
"❌ Rollback Failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
35
bot/modules/economy/lootdrop.interaction.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { ButtonInteraction } from "discord.js";
|
||||||
|
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
|
||||||
|
import { UserError } from "@/lib/errors";
|
||||||
|
import { getLootdropClaimedMessage } from "./lootdrop.view";
|
||||||
|
|
||||||
|
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
|
||||||
|
if (interaction.customId === "lootdrop_claim") {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
|
const result = await lootdropService.tryClaim(interaction.message.id, interaction.user.id, interaction.user.username);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new UserError(result.error || "Failed to claim.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `🎉 You successfully claimed **${result.amount} ${result.currency}**!`
|
||||||
|
});
|
||||||
|
|
||||||
|
const { content, files, components } = await getLootdropClaimedMessage(
|
||||||
|
interaction.user.id,
|
||||||
|
interaction.user.username,
|
||||||
|
interaction.user.displayAvatarURL({ extension: "png" }),
|
||||||
|
result.amount || 0,
|
||||||
|
result.currency || "Coins"
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.message.edit({
|
||||||
|
content,
|
||||||
|
embeds: [],
|
||||||
|
files,
|
||||||
|
components
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
43
bot/modules/economy/lootdrop.view.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
|
||||||
|
import { generateLootdropCard, generateClaimedLootdropCard } from "@/graphics/lootdrop";
|
||||||
|
|
||||||
|
export async function getLootdropMessage(reward: number, currency: string) {
|
||||||
|
const cardBuffer = await generateLootdropCard(reward, currency);
|
||||||
|
const attachment = new AttachmentBuilder(cardBuffer, { name: "lootdrop.png" });
|
||||||
|
|
||||||
|
const claimButton = new ButtonBuilder()
|
||||||
|
.setCustomId("lootdrop_claim")
|
||||||
|
.setLabel("CLAIM REWARD")
|
||||||
|
.setStyle(ButtonStyle.Secondary) // Changed to Secondary to fit the darker theme better? Or keep Success? Let's try Secondary with custom emoji
|
||||||
|
.setEmoji("🌠");
|
||||||
|
|
||||||
|
const row = new ActionRowBuilder<ButtonBuilder>()
|
||||||
|
.addComponents(claimButton);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: "",
|
||||||
|
files: [attachment],
|
||||||
|
components: [row]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLootdropClaimedMessage(userId: string, username: string, avatarUrl: string, amount: number, currency: string) {
|
||||||
|
const cardBuffer = await generateClaimedLootdropCard(amount, currency, username, avatarUrl);
|
||||||
|
const attachment = new AttachmentBuilder(cardBuffer, { name: "lootdrop_claimed.png" });
|
||||||
|
|
||||||
|
const newRow = new ActionRowBuilder<ButtonBuilder>()
|
||||||
|
.addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId("lootdrop_claim_disabled")
|
||||||
|
.setLabel("CLAIMED")
|
||||||
|
.setStyle(ButtonStyle.Secondary)
|
||||||
|
.setEmoji("✅")
|
||||||
|
.setDisabled(true)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: ``, // Remove content as the image says it all
|
||||||
|
files: [attachment],
|
||||||
|
components: [newRow]
|
||||||
|
};
|
||||||
|
}
|
||||||
34
bot/modules/economy/shop.interaction.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { ButtonInteraction, MessageFlags } from "discord.js";
|
||||||
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
|
import { UserError } from "@/lib/errors";
|
||||||
|
|
||||||
|
export async function handleShopInteraction(interaction: ButtonInteraction) {
|
||||||
|
if (!interaction.customId.startsWith("shop_buy_")) return;
|
||||||
|
|
||||||
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
|
const itemId = parseInt(interaction.customId.replace("shop_buy_", ""));
|
||||||
|
if (isNaN(itemId)) {
|
||||||
|
throw new UserError("Invalid Item ID.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await inventoryService.getItem(itemId);
|
||||||
|
if (!item || !item.price) {
|
||||||
|
throw new UserError("Item not found or not for sale.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||||
|
if (!user) {
|
||||||
|
throw new UserError("User profiles could not be loaded. Please try again later.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double check balance here too, although service handles it, we want a nice message
|
||||||
|
if ((user.balance ?? 0n) < item.price) {
|
||||||
|
throw new UserError(`You need ${item.price} 🪙 to buy this item. You have ${user.balance?.toString() ?? "0"} 🪙.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await inventoryService.buyItem(user.id.toString(), item.id, 1n);
|
||||||
|
|
||||||
|
await interaction.editReply({ content: `✅ **Success!** You bought **${item.name}** for ${item.price} 🪙.` });
|
||||||
|
}
|
||||||
79
bot/modules/feedback/feedback.interaction.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { Interaction } from "discord.js";
|
||||||
|
import { TextChannel, MessageFlags } from "discord.js";
|
||||||
|
import { config } from "@shared/lib/config";
|
||||||
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
|
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
|
||||||
|
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
|
||||||
|
import { UserError } from "@/lib/errors";
|
||||||
|
|
||||||
|
export const handleFeedbackInteraction = async (interaction: Interaction) => {
|
||||||
|
// Handle select menu for choosing feedback type
|
||||||
|
if (interaction.isStringSelectMenu() && interaction.customId === "feedback_select_type") {
|
||||||
|
const feedbackType = interaction.values[0] as FeedbackType;
|
||||||
|
|
||||||
|
if (!feedbackType) {
|
||||||
|
throw new UserError("Invalid feedback type selected.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = getFeedbackModal(feedbackType);
|
||||||
|
await interaction.showModal(modal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle modal submission
|
||||||
|
if (interaction.isModalSubmit() && interaction.customId.startsWith(FEEDBACK_CUSTOM_IDS.MODAL)) {
|
||||||
|
// Extract feedback type from customId (format: feedback_modal_FEATURE_REQUEST)
|
||||||
|
const parts = interaction.customId.split("_");
|
||||||
|
const feedbackType = parts.slice(2).join("_") as FeedbackType;
|
||||||
|
|
||||||
|
console.log(`Processing feedback modal. CustomId: ${interaction.customId}, Extracted type: ${feedbackType}`);
|
||||||
|
|
||||||
|
if (!feedbackType || !["FEATURE_REQUEST", "BUG_REPORT", "GENERAL"].includes(feedbackType)) {
|
||||||
|
console.error(`Invalid feedback type extracted: ${feedbackType} from customId: ${interaction.customId}`);
|
||||||
|
throw new UserError("An error occurred processing your feedback. Please try again.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.feedbackChannelId) {
|
||||||
|
throw new UserError("Feedback channel is not configured. Please contact an administrator.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse modal inputs
|
||||||
|
const title = interaction.fields.getTextInputValue(FEEDBACK_CUSTOM_IDS.TITLE_FIELD);
|
||||||
|
const description = interaction.fields.getTextInputValue(FEEDBACK_CUSTOM_IDS.DESCRIPTION_FIELD);
|
||||||
|
|
||||||
|
// Build feedback data
|
||||||
|
const feedbackData: FeedbackData = {
|
||||||
|
type: feedbackType,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
userId: interaction.user.id,
|
||||||
|
username: interaction.user.username,
|
||||||
|
timestamp: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get feedback channel
|
||||||
|
const channel = await AuroraClient.channels.fetch(config.feedbackChannelId).catch(() => null) as TextChannel | null;
|
||||||
|
|
||||||
|
if (!channel) {
|
||||||
|
throw new UserError("Feedback channel not found. Please contact an administrator.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build and send beautiful message
|
||||||
|
const containers = buildFeedbackMessage(feedbackData);
|
||||||
|
|
||||||
|
const feedbackMessage = await channel.send({
|
||||||
|
components: containers as any,
|
||||||
|
flags: MessageFlags.IsComponentsV2
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add reaction votes
|
||||||
|
await feedbackMessage.react("👍");
|
||||||
|
await feedbackMessage.react("👎");
|
||||||
|
|
||||||
|
// Confirm to user
|
||||||
|
await interaction.reply({
|
||||||
|
content: "✨ **Feedback Submitted**\nYour feedback has been submitted successfully! Thank you for helping improve Aurora.",
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
137
bot/modules/inventory/effects/handlers.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { levelingService } from "@shared/modules/leveling/leveling.service";
|
||||||
|
import { economyService } from "@shared/modules/economy/economy.service";
|
||||||
|
import { userTimers } from "@db/schema";
|
||||||
|
import type { EffectHandler } from "./types";
|
||||||
|
import type { LootTableItem } from "@shared/lib/types";
|
||||||
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
|
import { inventory, items } from "@db/schema";
|
||||||
|
import { TimerType, TransactionType, LootType } from "@shared/lib/constants";
|
||||||
|
|
||||||
|
|
||||||
|
// Helper to extract duration in seconds
|
||||||
|
const getDuration = (effect: any): number => {
|
||||||
|
if (effect.durationHours) return effect.durationHours * 3600;
|
||||||
|
if (effect.durationMinutes) return effect.durationMinutes * 60;
|
||||||
|
return effect.durationSeconds || 60; // Default to 60s if nothing provided
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleAddXp: EffectHandler = async (userId, effect, txFn) => {
|
||||||
|
await levelingService.addXp(userId, BigInt(effect.amount), txFn);
|
||||||
|
return `Gained ${effect.amount} XP`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleAddBalance: EffectHandler = async (userId, effect, txFn) => {
|
||||||
|
await economyService.modifyUserBalance(userId, BigInt(effect.amount), TransactionType.ITEM_USE, `Used Item`, null, txFn);
|
||||||
|
return `Gained ${effect.amount} 🪙`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleReplyMessage: EffectHandler = async (_userId, effect, _txFn) => {
|
||||||
|
return effect.message;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
|
||||||
|
const boostDuration = getDuration(effect);
|
||||||
|
const expiresAt = new Date(Date.now() + boostDuration * 1000);
|
||||||
|
await txFn.insert(userTimers).values({
|
||||||
|
userId: BigInt(userId),
|
||||||
|
type: TimerType.EFFECT,
|
||||||
|
key: 'xp_boost',
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
metadata: { multiplier: effect.multiplier }
|
||||||
|
}).onConflictDoUpdate({
|
||||||
|
target: [userTimers.userId, userTimers.type, userTimers.key],
|
||||||
|
set: { expiresAt: expiresAt, metadata: { multiplier: effect.multiplier } }
|
||||||
|
});
|
||||||
|
return `XP Boost (${effect.multiplier}x) active for ${Math.floor(boostDuration / 60)}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
|
||||||
|
const roleDuration = getDuration(effect);
|
||||||
|
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
|
||||||
|
await txFn.insert(userTimers).values({
|
||||||
|
userId: BigInt(userId),
|
||||||
|
type: TimerType.ACCESS,
|
||||||
|
key: `role_${effect.roleId}`,
|
||||||
|
expiresAt: roleExpiresAt,
|
||||||
|
metadata: { roleId: effect.roleId }
|
||||||
|
}).onConflictDoUpdate({
|
||||||
|
target: [userTimers.userId, userTimers.type, userTimers.key],
|
||||||
|
set: { expiresAt: roleExpiresAt }
|
||||||
|
});
|
||||||
|
// Actual role assignment happens in the Command layer
|
||||||
|
return `Temporary Role granted for ${Math.floor(roleDuration / 60)}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleColorRole: EffectHandler = async (_userId, _effect, _txFn) => {
|
||||||
|
return "Color Role Equipped";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
||||||
|
const pool = effect.pool as LootTableItem[];
|
||||||
|
if (!pool || pool.length === 0) return "The box is empty...";
|
||||||
|
|
||||||
|
const totalWeight = pool.reduce((sum, item) => sum + item.weight, 0);
|
||||||
|
let random = Math.random() * totalWeight;
|
||||||
|
|
||||||
|
let winner: LootTableItem | null = null;
|
||||||
|
for (const item of pool) {
|
||||||
|
if (random < item.weight) {
|
||||||
|
winner = item;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
random -= item.weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!winner) return "The box is empty..."; // Should not happen
|
||||||
|
|
||||||
|
// Process Winner
|
||||||
|
if (winner.type === LootType.NOTHING) {
|
||||||
|
return winner.message || "You found nothing inside.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (winner.type === LootType.CURRENCY) {
|
||||||
|
let amount = winner.amount || 0;
|
||||||
|
if (winner.minAmount && winner.maxAmount) {
|
||||||
|
amount = Math.floor(Math.random() * (winner.maxAmount - winner.minAmount + 1)) + winner.minAmount;
|
||||||
|
}
|
||||||
|
if (amount > 0) {
|
||||||
|
await economyService.modifyUserBalance(userId, BigInt(amount), TransactionType.LOOTBOX, 'Lootbox Reward', null, txFn);
|
||||||
|
return winner.message || `You found ${amount} 🪙!`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (winner.type === LootType.XP) {
|
||||||
|
let amount = winner.amount || 0;
|
||||||
|
if (winner.minAmount && winner.maxAmount) {
|
||||||
|
amount = Math.floor(Math.random() * (winner.maxAmount - winner.minAmount + 1)) + winner.minAmount;
|
||||||
|
}
|
||||||
|
if (amount > 0) {
|
||||||
|
await levelingService.addXp(userId, BigInt(amount), txFn);
|
||||||
|
return winner.message || `You gained ${amount} XP!`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (winner.type === LootType.ITEM) {
|
||||||
|
if (winner.itemId) {
|
||||||
|
const quantity = BigInt(winner.amount || 1);
|
||||||
|
|
||||||
|
await inventoryService.addItem(userId, winner.itemId, quantity, txFn);
|
||||||
|
|
||||||
|
// Try to fetch item name for the message
|
||||||
|
try {
|
||||||
|
const item = await txFn.query.items.findFirst({
|
||||||
|
where: (items: any, { eq }: any) => eq(items.id, winner.itemId!)
|
||||||
|
});
|
||||||
|
if (item) {
|
||||||
|
return winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch item name for lootbox message", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return winner.message || `You found an item! (ID: ${winner.itemId})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "You found nothing suitable inside.";
|
||||||
|
};
|
||||||
@@ -4,7 +4,8 @@ import {
|
|||||||
handleReplyMessage,
|
handleReplyMessage,
|
||||||
handleXpBoost,
|
handleXpBoost,
|
||||||
handleTempRole,
|
handleTempRole,
|
||||||
handleColorRole
|
handleColorRole,
|
||||||
|
handleLootbox
|
||||||
} from "./handlers";
|
} from "./handlers";
|
||||||
import type { EffectHandler } from "./types";
|
import type { EffectHandler } from "./types";
|
||||||
|
|
||||||
@@ -14,5 +15,6 @@ export const effectHandlers: Record<string, EffectHandler> = {
|
|||||||
'REPLY_MESSAGE': handleReplyMessage,
|
'REPLY_MESSAGE': handleReplyMessage,
|
||||||
'XP_BOOST': handleXpBoost,
|
'XP_BOOST': handleXpBoost,
|
||||||
'TEMP_ROLE': handleTempRole,
|
'TEMP_ROLE': handleTempRole,
|
||||||
'COLOR_ROLE': handleColorRole
|
'COLOR_ROLE': handleColorRole,
|
||||||
|
'LOOTBOX': handleLootbox
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
|
|
||||||
import type { Transaction } from "@/lib/types";
|
import type { Transaction } from "@shared/lib/types";
|
||||||
|
|
||||||
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<string>;
|
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<string>;
|
||||||
54
bot/modules/inventory/inventory.view.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
import type { ItemUsageData } from "@shared/lib/types";
|
||||||
|
import { EffectType } from "@shared/lib/constants";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inventory entry with item details
|
||||||
|
*/
|
||||||
|
interface InventoryEntry {
|
||||||
|
quantity: bigint | null;
|
||||||
|
item: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an embed displaying a user's inventory
|
||||||
|
*/
|
||||||
|
export function getInventoryEmbed(items: InventoryEntry[], username: string): EmbedBuilder {
|
||||||
|
const description = items.map(entry => {
|
||||||
|
return `**${entry.item.name}** x${entry.quantity}`;
|
||||||
|
}).join("\n");
|
||||||
|
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTitle(`📦 ${username}'s Inventory`)
|
||||||
|
.setDescription(description)
|
||||||
|
.setColor(0x3498db); // Blue
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an embed showing the results of using an item
|
||||||
|
*/
|
||||||
|
export function getItemUseResultEmbed(results: string[], item?: { name: string, iconUrl: string | null, usageData: any }): EmbedBuilder {
|
||||||
|
const description = results.map(r => `• ${r}`).join("\n");
|
||||||
|
|
||||||
|
// Check if it was a lootbox
|
||||||
|
const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === EffectType.LOOTBOX);
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setDescription(description)
|
||||||
|
.setColor(isLootbox ? 0xFFD700 : 0x2ecc71); // Gold for lootbox, Green otherwise
|
||||||
|
|
||||||
|
if (isLootbox && item) {
|
||||||
|
embed.setTitle(`🎁 ${item.name} Opened!`);
|
||||||
|
if (item.iconUrl) {
|
||||||
|
embed.setThumbnail(item.iconUrl);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!");
|
||||||
|
}
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
71
bot/modules/leveling/leveling.view.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User data for leaderboard display
|
||||||
|
*/
|
||||||
|
interface LeaderboardUser {
|
||||||
|
username: string;
|
||||||
|
level: number | null;
|
||||||
|
xp: bigint | null;
|
||||||
|
balance: bigint | null;
|
||||||
|
netWorth?: bigint | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the appropriate medal emoji for a ranking position
|
||||||
|
*/
|
||||||
|
function getMedalEmoji(index: number): string {
|
||||||
|
if (index === 0) return "🥇";
|
||||||
|
if (index === 1) return "🥈";
|
||||||
|
if (index === 2) return "🥉";
|
||||||
|
return `${index + 1}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a single leaderboard entry based on type
|
||||||
|
*/
|
||||||
|
function formatLeaderEntry(user: LeaderboardUser, index: number, type: 'xp' | 'balance' | 'networth'): string {
|
||||||
|
const medal = getMedalEmoji(index);
|
||||||
|
let value = '';
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'xp':
|
||||||
|
value = `Lvl ${user.level ?? 1} (${user.xp ?? 0n} XP)`;
|
||||||
|
break;
|
||||||
|
case 'balance':
|
||||||
|
value = `${user.balance ?? 0n} 🪙`;
|
||||||
|
break;
|
||||||
|
case 'networth':
|
||||||
|
value = `${user.netWorth ?? 0n} 🪙 (Net Worth)`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${medal} **${user.username}** — ${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a leaderboard embed for either XP, Balance or Net Worth rankings
|
||||||
|
*/
|
||||||
|
export function getLeaderboardEmbed(leaders: LeaderboardUser[], type: 'xp' | 'balance' | 'networth'): EmbedBuilder {
|
||||||
|
const description = leaders.map((user, index) =>
|
||||||
|
formatLeaderEntry(user, index, type)
|
||||||
|
).join("\n");
|
||||||
|
|
||||||
|
let title = '';
|
||||||
|
switch (type) {
|
||||||
|
case 'xp':
|
||||||
|
title = "🏆 XP Leaderboard";
|
||||||
|
break;
|
||||||
|
case 'balance':
|
||||||
|
title = "💰 Richest Players";
|
||||||
|
break;
|
||||||
|
case 'networth':
|
||||||
|
title = "💎 Net Worth Leaderboard";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTitle(title)
|
||||||
|
.setDescription(description)
|
||||||
|
.setColor(0xFFD700); // Gold
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
export type CaseType = 'warn' | 'timeout' | 'kick' | 'ban' | 'note' | 'prune';
|
import { CaseType } from "@shared/lib/constants";
|
||||||
|
|
||||||
|
export { CaseType };
|
||||||
|
|
||||||
export interface CreateCaseOptions {
|
export interface CreateCaseOptions {
|
||||||
type: CaseType;
|
type: CaseType;
|
||||||
54
bot/modules/quest/quest.view.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quest entry with quest details and progress
|
||||||
|
*/
|
||||||
|
interface QuestEntry {
|
||||||
|
progress: number | null;
|
||||||
|
completedAt: Date | null;
|
||||||
|
quest: {
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
rewards: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats quest rewards object into a human-readable string
|
||||||
|
*/
|
||||||
|
function formatQuestRewards(rewards: { xp?: number, balance?: number }): string {
|
||||||
|
const rewardStr: string[] = [];
|
||||||
|
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
|
||||||
|
if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`);
|
||||||
|
return rewardStr.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the quest status display string
|
||||||
|
*/
|
||||||
|
function getQuestStatus(completedAt: Date | null): string {
|
||||||
|
return completedAt ? "✅ Completed" : "📝 In Progress";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an embed displaying a user's quest log
|
||||||
|
*/
|
||||||
|
export function getQuestListEmbed(userQuests: QuestEntry[]): EmbedBuilder {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle("📜 Quest Log")
|
||||||
|
.setColor(0x3498db); // Blue
|
||||||
|
|
||||||
|
userQuests.forEach(entry => {
|
||||||
|
const status = getQuestStatus(entry.completedAt);
|
||||||
|
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
||||||
|
const rewardsText = formatQuestRewards(rewards);
|
||||||
|
|
||||||
|
embed.addFields({
|
||||||
|
name: `${entry.quest.name} (${status})`,
|
||||||
|
value: `${entry.quest.description}\n**Rewards:** ${rewardsText}\n**Progress:** ${entry.progress}%`,
|
||||||
|
inline: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
21
bot/modules/system/scheduler.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { temporaryRoleService } from "@shared/modules/system/temp-role.service";
|
||||||
|
|
||||||
|
export const schedulerService = {
|
||||||
|
start: () => {
|
||||||
|
console.log("🕒 Scheduler started: Maintenance loops initialized.");
|
||||||
|
|
||||||
|
// 1. Temporary Role Revocation (every 60s)
|
||||||
|
setInterval(() => {
|
||||||
|
temporaryRoleService.processExpiredRoles();
|
||||||
|
}, 60 * 1000);
|
||||||
|
|
||||||
|
// 2. Terminal Update Loop (every 60s)
|
||||||
|
const { terminalService } = require("@shared/modules/terminal/terminal.service");
|
||||||
|
setInterval(() => {
|
||||||
|
terminalService.update();
|
||||||
|
}, 60 * 1000);
|
||||||
|
|
||||||
|
// Run an initial check on start
|
||||||
|
temporaryRoleService.processExpiredRoles();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -7,9 +7,10 @@ import {
|
|||||||
TextChannel,
|
TextChannel,
|
||||||
EmbedBuilder
|
EmbedBuilder
|
||||||
} from "discord.js";
|
} from "discord.js";
|
||||||
import { TradeService } from "./trade.service";
|
import { tradeService } from "@shared/modules/trade/trade.service";
|
||||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
|
||||||
|
import { UserError } from "@lib/errors";
|
||||||
import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view";
|
import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view";
|
||||||
|
|
||||||
|
|
||||||
@@ -22,7 +23,6 @@ export async function handleTradeInteraction(interaction: Interaction) {
|
|||||||
|
|
||||||
if (!threadId) return;
|
if (!threadId) return;
|
||||||
|
|
||||||
try {
|
|
||||||
if (customId === 'trade_cancel') {
|
if (customId === 'trade_cancel') {
|
||||||
await handleCancel(interaction, threadId);
|
await handleCancel(interaction, threadId);
|
||||||
} else if (customId === 'trade_lock') {
|
} else if (customId === 'trade_lock') {
|
||||||
@@ -44,21 +44,13 @@ export async function handleTradeInteraction(interaction: Interaction) {
|
|||||||
} else if (customId === 'trade_remove_item_select') {
|
} else if (customId === 'trade_remove_item_select') {
|
||||||
await handleRemoveItemSelect(interaction as StringSelectMenuInteraction, threadId);
|
await handleRemoveItemSelect(interaction as StringSelectMenuInteraction, threadId);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
|
||||||
const errorEmbed = createErrorEmbed(error.message);
|
|
||||||
if (interaction.replied || interaction.deferred) {
|
|
||||||
await interaction.followUp({ embeds: [errorEmbed], ephemeral: true });
|
|
||||||
} else {
|
|
||||||
await interaction.reply({ embeds: [errorEmbed], ephemeral: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
|
async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
|
||||||
const session = TradeService.getSession(threadId);
|
const session = tradeService.getSession(threadId);
|
||||||
const user = interaction.user;
|
const user = interaction.user;
|
||||||
|
|
||||||
TradeService.endSession(threadId);
|
tradeService.endSession(threadId);
|
||||||
|
|
||||||
await interaction.deferUpdate();
|
await interaction.deferUpdate();
|
||||||
|
|
||||||
@@ -70,11 +62,11 @@ async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInt
|
|||||||
|
|
||||||
async function handleLock(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
|
async function handleLock(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
|
||||||
await interaction.deferUpdate();
|
await interaction.deferUpdate();
|
||||||
const isLocked = TradeService.toggleLock(threadId, interaction.user.id);
|
const isLocked = tradeService.toggleLock(threadId, interaction.user.id);
|
||||||
await updateTradeDashboard(interaction, threadId);
|
await updateTradeDashboard(interaction, threadId);
|
||||||
|
|
||||||
// Check if trade executed (both locked)
|
// Check if trade executed (both locked)
|
||||||
const session = TradeService.getSession(threadId);
|
const session = tradeService.getSession(threadId);
|
||||||
if (session && session.state === 'COMPLETED') {
|
if (session && session.state === 'COMPLETED') {
|
||||||
// Trade executed during updateTradeDashboard
|
// Trade executed during updateTradeDashboard
|
||||||
return;
|
return;
|
||||||
@@ -93,9 +85,9 @@ async function handleMoneySubmit(interaction: ModalSubmitInteraction, threadId:
|
|||||||
const amountStr = interaction.fields.getTextInputValue('amount');
|
const amountStr = interaction.fields.getTextInputValue('amount');
|
||||||
const amount = BigInt(amountStr);
|
const amount = BigInt(amountStr);
|
||||||
|
|
||||||
if (amount < 0n) throw new Error("Amount must be positive");
|
if (amount < 0n) throw new UserError("Amount must be positive");
|
||||||
|
|
||||||
TradeService.updateMoney(threadId, interaction.user.id, amount);
|
tradeService.updateMoney(threadId, interaction.user.id, amount);
|
||||||
await interaction.deferUpdate(); // Acknowledge modal
|
await interaction.deferUpdate(); // Acknowledge modal
|
||||||
await updateTradeDashboard(interaction, threadId);
|
await updateTradeDashboard(interaction, threadId);
|
||||||
}
|
}
|
||||||
@@ -109,7 +101,7 @@ async function handleAddItemClick(interaction: ButtonInteraction, threadId: stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Slice top 25 for select menu
|
// Slice top 25 for select menu
|
||||||
const options = inventory.slice(0, 25).map(entry => ({
|
const options = inventory.slice(0, 25).map((entry: any) => ({
|
||||||
label: `${entry.item.name} (${entry.quantity})`,
|
label: `${entry.item.name} (${entry.quantity})`,
|
||||||
value: entry.item.id.toString(),
|
value: entry.item.id.toString(),
|
||||||
description: `Rarity: ${entry.item.rarity} `
|
description: `Rarity: ${entry.item.rarity} `
|
||||||
@@ -126,16 +118,16 @@ async function handleItemSelect(interaction: StringSelectMenuInteraction, thread
|
|||||||
|
|
||||||
// Assuming implementation implies adding 1 item for now
|
// Assuming implementation implies adding 1 item for now
|
||||||
const item = await inventoryService.getItem(itemId);
|
const item = await inventoryService.getItem(itemId);
|
||||||
if (!item) throw new Error("Item not found");
|
if (!item) throw new UserError("Item not found");
|
||||||
|
|
||||||
TradeService.addItem(threadId, interaction.user.id, { id: item.id, name: item.name }, 1n);
|
tradeService.addItem(threadId, interaction.user.id, { id: item.id, name: item.name }, 1n);
|
||||||
|
|
||||||
await interaction.update({ content: `Added ${item.name} x1`, components: [] });
|
await interaction.update({ content: `Added ${item.name} x1`, components: [] });
|
||||||
await updateTradeDashboard(interaction, threadId);
|
await updateTradeDashboard(interaction, threadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRemoveItemClick(interaction: ButtonInteraction, threadId: string) {
|
async function handleRemoveItemClick(interaction: ButtonInteraction, threadId: string) {
|
||||||
const session = TradeService.getSession(threadId);
|
const session = tradeService.getSession(threadId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|
||||||
const participant = session.userA.id === interaction.user.id ? session.userA : session.userB;
|
const participant = session.userA.id === interaction.user.id ? session.userA : session.userB;
|
||||||
@@ -158,7 +150,7 @@ async function handleRemoveItemSelect(interaction: StringSelectMenuInteraction,
|
|||||||
const value = interaction.values[0];
|
const value = interaction.values[0];
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
const itemId = parseInt(value);
|
const itemId = parseInt(value);
|
||||||
TradeService.removeItem(threadId, interaction.user.id, itemId);
|
tradeService.removeItem(threadId, interaction.user.id, itemId);
|
||||||
|
|
||||||
await interaction.update({ content: `Removed item.`, components: [] });
|
await interaction.update({ content: `Removed item.`, components: [] });
|
||||||
await updateTradeDashboard(interaction, threadId);
|
await updateTradeDashboard(interaction, threadId);
|
||||||
@@ -168,14 +160,14 @@ async function handleRemoveItemSelect(interaction: StringSelectMenuInteraction,
|
|||||||
// --- DASHBOARD UPDATER ---
|
// --- DASHBOARD UPDATER ---
|
||||||
|
|
||||||
export async function updateTradeDashboard(interaction: Interaction, threadId: string) {
|
export async function updateTradeDashboard(interaction: Interaction, threadId: string) {
|
||||||
const session = TradeService.getSession(threadId);
|
const session = tradeService.getSession(threadId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|
||||||
// Check Auto-Execute (If both locked)
|
// Check Auto-Execute (If both locked)
|
||||||
if (session.userA.locked && session.userB.locked) {
|
if (session.userA.locked && session.userB.locked) {
|
||||||
// Execute Trade
|
// Execute Trade
|
||||||
try {
|
try {
|
||||||
await TradeService.executeTrade(threadId);
|
await tradeService.executeTrade(threadId);
|
||||||
const embed = getTradeCompletedEmbed(session);
|
const embed = getTradeCompletedEmbed(session);
|
||||||
await updateDashboardMessage(interaction, { embeds: [embed], components: [] });
|
await updateDashboardMessage(interaction, { embeds: [embed], components: [] });
|
||||||
|
|
||||||