Compare commits
207 Commits
| 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 | ||
|
|
f44b053a10 | ||
|
|
fe58380d58 | ||
|
|
64cf47ee03 | ||
|
|
37ac0ee934 | ||
|
|
5ab19bf826 | ||
|
|
42d2313933 | ||
|
|
cddd8cdf57 | ||
|
|
eaaf569f4f | ||
|
|
8c28fe60fc | ||
|
|
6d725b73db | ||
|
|
da048eaad1 | ||
|
|
56da4818dc | ||
|
|
ca443491cb | ||
|
|
345e05f821 | ||
|
|
419059904c | ||
|
|
7698a3abaa | ||
|
|
83984faeae | ||
|
|
2106f06f8f | ||
|
|
16d507991c | ||
|
|
e2aa5ee760 | ||
|
|
e084b6fa4e | ||
|
|
3f6da16f89 | ||
|
|
71de87d3da | ||
|
|
fc7afd7d22 | ||
|
|
fcc82292f2 | ||
|
|
f75cc217e9 | ||
|
|
5c36b9be25 | ||
|
|
eaf97572a4 | ||
|
|
1189483244 | ||
|
|
f39ccee0d3 | ||
|
|
10282a2570 | ||
|
|
a3099b80c5 | ||
|
|
67d6298793 | ||
|
|
808fbef11b | ||
|
|
b833796fb9 | ||
|
|
58ea8b92f1 | ||
|
|
fbd2bd990f | ||
|
|
f859618367 | ||
|
|
b7b1dd87b8 | ||
|
|
f3b6af019d | ||
|
|
0dea266a6d | ||
|
|
fbcac51370 | ||
|
|
75e586cee8 | ||
|
|
6e1e6abf2d | ||
|
|
4a0a2a5878 | ||
|
|
216189b0a4 | ||
|
|
ca1339728a | ||
|
|
5833224ba9 | ||
|
|
65f5dc3721 | ||
|
|
637f0826db | ||
|
|
578987caea | ||
|
|
064efb0ed2 | ||
|
|
4229e5338f | ||
|
|
1f7679e5a1 | ||
|
|
4e228bb7a3 | ||
|
|
95d5202d7f | ||
|
|
6c150f753e | ||
|
|
c881b305f0 | ||
|
|
ae5ef4c802 | ||
|
|
2b365cb96d | ||
|
|
bcbbcaa6a4 | ||
|
|
bdb8456f34 | ||
|
|
acaca46298 | ||
|
|
7b831fa17c | ||
|
|
c128c96aa8 | ||
|
|
d0f53dc37b | ||
|
|
28936a7f7a | ||
|
|
4642cf7f6a | ||
|
|
528a66a7ef | ||
|
|
a97a24f72a | ||
|
|
7bd4d811cd | ||
|
|
2ce768013d | ||
|
|
3c20b23cc1 | ||
|
|
71fefb3a14 | ||
|
|
1d650bb2c7 | ||
|
|
7cf8d68d39 | ||
|
|
83cd33e439 | ||
|
|
34cbea2753 | ||
|
|
ce7d4525b2 | ||
|
|
4ac8b4759e | ||
|
|
56ad5b49cd | ||
|
|
e8f6a56057 | ||
|
|
a7f66a98b9 | ||
|
|
6d54695325 | ||
|
|
8c1f80981b | ||
|
|
3a96b67e89 | ||
|
|
d3ade218ec | ||
|
|
1d4263e178 | ||
|
|
727b63b4dc | ||
|
|
d2edde77e6 | ||
|
|
3acb5304f5 | ||
|
|
9333d6ac6c | ||
|
|
7e986fae5a | ||
|
|
3c81fd8396 | ||
|
|
3984d6112b | ||
|
|
ac6283e60c |
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.
|
||||
11
.env.example
@@ -1,9 +1,12 @@
|
||||
DB_USER=kyoko
|
||||
DB_PASSWORD=kyoko
|
||||
DB_NAME=kyoko
|
||||
DB_USER=aurora
|
||||
DB_PASSWORD=aurora
|
||||
DB_NAME=aurora
|
||||
DB_PORT=5432
|
||||
DB_HOST=db
|
||||
DISCORD_BOT_TOKEN=your-discord-bot-token
|
||||
DISCORD_CLIENT_ID=your-discord-client-id
|
||||
DISCORD_GUILD_ID=your-discord-guild-id
|
||||
DATABASE_URL=postgres://kyoko:kyoko@db:5432/kyoko
|
||||
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
|
||||
|
||||
VPS_USER=your-vps-user
|
||||
VPS_HOST=your-vps-ip
|
||||
|
||||
11
.gitignore
vendored
@@ -1,11 +1,15 @@
|
||||
.env
|
||||
node_modules
|
||||
db-logs
|
||||
db-data
|
||||
shared/db-logs
|
||||
shared/db/data
|
||||
shared/db/loga
|
||||
.cursor
|
||||
# dependencies (bun install)
|
||||
|
||||
node_modules
|
||||
|
||||
config/
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
@@ -39,4 +43,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
.DS_Store
|
||||
|
||||
src/db/data
|
||||
src/db/log
|
||||
src/db/log
|
||||
scratchpad/
|
||||
11
Dockerfile
@@ -1,14 +1,21 @@
|
||||
FROM oven/bun:latest AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install root project dependencies
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Install web project dependencies
|
||||
COPY web/package.json web/bun.lock ./web/
|
||||
RUN cd web && bun install --frozen-lockfile
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
# Expose ports (3000 for web dashboard)
|
||||
EXPOSE 3000
|
||||
|
||||
# Default command
|
||||
|
||||
174
README.md
@@ -1,42 +1,160 @@
|
||||
# Kyoko - Discord Rpg
|
||||
# Aurora
|
||||
|
||||
A Discord bot built with [Bun](https://bun.sh), [Discord.js](https://discord.js.org/), and [Drizzle ORM](https://orm.drizzle.team/).
|
||||
> A comprehensive, feature-rich Discord RPG bot built with modern technologies.
|
||||
|
||||
## Architecture
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
This project uses a modular architecture:
|
||||
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.
|
||||
|
||||
- **`src/index.ts`**: Entry point. initializes the client.
|
||||
- **`src/lib/KyokoClient.ts`**: Custom Discord Client wrapper handling command loading and events.
|
||||
- **`src/lib/env.ts`**: **Centralized Environment Configuration**. Validates environment variables using `zod` at startup.
|
||||
- **`src/lib/DrizzleClient.ts`**: Database client instance.
|
||||
- **`src/commands/`**: Command files.
|
||||
- **`src/db/`**: Database schema and migrations.
|
||||
**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.
|
||||
|
||||
## Setup
|
||||
## ✨ Features
|
||||
|
||||
1. **Install Dependencies**:
|
||||
### Discord Bot
|
||||
* **Class System**: Users can join different classes.
|
||||
* **Economy**: Complete economy system with balance, transactions, and daily rewards.
|
||||
* **Inventory & Items**: Sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management.
|
||||
* **Leveling**: XP-based leveling system to track user activity and progress.
|
||||
* **Quests**: Quest system with requirements and rewards.
|
||||
* **Trading**: Secure trading system between users.
|
||||
* **Lootdrops**: Random loot drops in channels to engage users.
|
||||
* **Admin Tools**: Administrative commands for server management.
|
||||
|
||||
### 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
|
||||
|
||||
* **Runtime**: [Bun](https://bun.sh/)
|
||||
* **Bot Framework**: [Discord.js](https://discord.js.org/)
|
||||
* **Web Framework**: [React 19](https://react.dev/) + [Vite](https://vitejs.dev/) (served via Bun)
|
||||
* **Styling**: [Tailwind CSS v4](https://tailwindcss.com/) + [Radix UI](https://www.radix-ui.com/)
|
||||
* **Database**: [PostgreSQL](https://www.postgresql.org/)
|
||||
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
||||
* **Validation**: [Zod](https://zod.dev/)
|
||||
* **Containerization**: [Docker](https://www.docker.com/)
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
* [Bun](https://bun.sh/) (latest version)
|
||||
* [Docker](https://www.docker.com/) & Docker Compose
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd aurora
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
2. **Environment Variables**:
|
||||
Copy `.env.example` to `.env` (create one if it doesn't exist) and fill in the required values:
|
||||
3. **Environment Setup**
|
||||
Copy the example environment file and configure it:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
Edit `.env` with your Discord bot token, Client ID, and database credentials.
|
||||
|
||||
> **Note**: The `DATABASE_URL` in `.env.example` is pre-configured for Docker.
|
||||
|
||||
4. **Start the Database**
|
||||
Run the database service using Docker Compose:
|
||||
```bash
|
||||
docker compose up -d db
|
||||
```
|
||||
|
||||
5. **Run Migrations**
|
||||
```bash
|
||||
bun run migrate
|
||||
```
|
||||
OR
|
||||
```bash
|
||||
bun run db:push
|
||||
```
|
||||
|
||||
### Running the Bot & Dashboard
|
||||
|
||||
**Development Mode** (with hot reload):
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
* Bot: Online in Discord
|
||||
* Dashboard: http://localhost:3000
|
||||
|
||||
**Production Mode**:
|
||||
Build and run with Docker (recommended):
|
||||
```bash
|
||||
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
|
||||
DISCORD_BOT_TOKEN=your_token_here
|
||||
DISCORD_CLIENT_ID=your_client_id
|
||||
DISCORD_GUILD_ID=your_guild_id_optional
|
||||
DATABASE_URL=postgres://user:pass@localhost:5432/db_name
|
||||
```
|
||||
*Note: The app will fail to start if `DISCORD_BOT_TOKEN` or `DATABASE_URL` are missing or invalid.*
|
||||
|
||||
3. **Run Development**:
|
||||
```bash
|
||||
bun run dev
|
||||
VPS_USER=root
|
||||
VPS_HOST=123.45.67.89
|
||||
```
|
||||
|
||||
4. **Database Migrations**:
|
||||
2. Run the remote connection script:
|
||||
```bash
|
||||
bun run db:push # Apply schema changes
|
||||
bun run generate # Generate migrations
|
||||
```
|
||||
bun run remote
|
||||
```
|
||||
|
||||
This will establish secure tunnels for:
|
||||
* **Dashboard**: http://localhost:3000
|
||||
* **Drizzle Studio**: http://localhost:4983
|
||||
|
||||
## 📜 Scripts
|
||||
|
||||
* `bun run dev`: Start the bot and dashboard in watch mode.
|
||||
* `bun run remote`: Open SSH tunnel to production services.
|
||||
* `bun run generate`: Generate Drizzle migrations.
|
||||
* `bun run migrate`: Apply migrations (via Docker).
|
||||
* `bun run db:studio`: Open Drizzle Studio to inspect the database.
|
||||
* `bun test`: Run tests.
|
||||
|
||||
## 📂 Project Structure
|
||||
|
||||
```
|
||||
├── bot # Discord Bot logic & entry point
|
||||
├── web # React Web Dashboard (Frontend + Server)
|
||||
├── shared # Shared code (Database, Config, Types)
|
||||
├── drizzle # Drizzle migration files
|
||||
├── scripts # Utility scripts
|
||||
├── docker-compose.yml
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
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 |
54
bot/commands/admin/case.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
export const moderationCase = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("case")
|
||||
.setDescription("View details of a specific moderation case")
|
||||
.addStringOption(option =>
|
||||
option
|
||||
.setName("case_id")
|
||||
.setDescription("The case ID (e.g., CASE-0001)")
|
||||
.setRequired(true)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||
|
||||
// Validate case ID format
|
||||
if (!caseId.match(/^CASE-\d+$/)) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the case
|
||||
const moderationCase = await ModerationService.getCaseById(caseId);
|
||||
|
||||
if (!moderationCase) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Display the case
|
||||
await interaction.editReply({
|
||||
embeds: [getCaseEmbed(moderationCase)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Case command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching the case.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
54
bot/commands/admin/cases.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
export const cases = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("cases")
|
||||
.setDescription("View all moderation cases for a user")
|
||||
.addUserOption(option =>
|
||||
option
|
||||
.setName("user")
|
||||
.setDescription("The user to check cases for")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addBooleanOption(option =>
|
||||
option
|
||||
.setName("active_only")
|
||||
.setDescription("Show only active cases (warnings)")
|
||||
.setRequired(false)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
const activeOnly = interaction.options.getBoolean("active_only") || false;
|
||||
|
||||
// Get cases for the user
|
||||
const userCases = await ModerationService.getUserCases(targetUser.id, activeOnly);
|
||||
|
||||
const title = activeOnly
|
||||
? `⚠️ Active Cases for ${targetUser.username}`
|
||||
: `📋 All Cases for ${targetUser.username}`;
|
||||
|
||||
const description = userCases.length === 0
|
||||
? undefined
|
||||
: `Total cases: **${userCases.length}**`;
|
||||
|
||||
// Display the cases
|
||||
await interaction.editReply({
|
||||
embeds: [getCasesListEmbed(userCases, title, description)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Cases command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching cases.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
84
bot/commands/admin/clearwarning.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
export const clearwarning = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("clearwarning")
|
||||
.setDescription("Clear/resolve a warning")
|
||||
.addStringOption(option =>
|
||||
option
|
||||
.setName("case_id")
|
||||
.setDescription("The case ID to clear (e.g., CASE-0001)")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addStringOption(option =>
|
||||
option
|
||||
.setName("reason")
|
||||
.setDescription("Reason for clearing the warning")
|
||||
.setRequired(false)
|
||||
.setMaxLength(500)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||
const reason = interaction.options.getString("reason") || "Cleared by moderator";
|
||||
|
||||
// Validate case ID format
|
||||
if (!caseId.match(/^CASE-\d+$/)) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if case exists and is active
|
||||
const existingCase = await ModerationService.getCaseById(caseId);
|
||||
|
||||
if (!existingCase) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existingCase.active) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** is already resolved.`)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingCase.type !== 'warn') {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** is not a warning. Only warnings can be cleared.`)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the warning
|
||||
await ModerationService.clearCase({
|
||||
caseId,
|
||||
clearedBy: interaction.user.id,
|
||||
clearedByName: interaction.user.username,
|
||||
reason
|
||||
});
|
||||
|
||||
// Send success message
|
||||
await interaction.editReply({
|
||||
embeds: [getClearSuccessEmbed(caseId)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Clear warning command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while clearing the warning.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
68
bot/commands/admin/config.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ModalSubmitInteraction } from "discord.js";
|
||||
import { config, saveConfig } from "@shared/lib/config";
|
||||
import type { GameConfigType } from "@shared/lib/config";
|
||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
||||
|
||||
export const configCommand = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("config")
|
||||
.setDescription("Edit the bot configuration")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
console.log(`Config command executed by ${interaction.user.tag}`);
|
||||
const replacer = (key: string, value: any) => {
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const currentConfigJson = JSON.stringify(config, replacer, 4);
|
||||
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId("config-modal")
|
||||
.setTitle("Edit Configuration");
|
||||
|
||||
const jsonInput = new TextInputBuilder()
|
||||
.setCustomId("json-input")
|
||||
.setLabel("Configuration JSON")
|
||||
.setStyle(TextInputStyle.Paragraph)
|
||||
.setValue(currentConfigJson)
|
||||
.setRequired(true);
|
||||
|
||||
const actionRow = new ActionRowBuilder<TextInputBuilder>().addComponents(jsonInput);
|
||||
modal.addComponents(actionRow);
|
||||
|
||||
await interaction.showModal(modal);
|
||||
|
||||
try {
|
||||
const submitted = await interaction.awaitModalSubmit({
|
||||
time: 300000, // 5 minutes
|
||||
filter: (i) => i.customId === "config-modal" && i.user.id === interaction.user.id
|
||||
});
|
||||
|
||||
const jsonString = submitted.fields.getTextInputValue("json-input");
|
||||
|
||||
try {
|
||||
const newConfig = JSON.parse(jsonString);
|
||||
saveConfig(newConfig as GameConfigType);
|
||||
|
||||
await submitted.reply({
|
||||
embeds: [createSuccessEmbed("Configuration updated successfully.", "Config Saved")]
|
||||
});
|
||||
} catch (parseError) {
|
||||
await submitted.reply({
|
||||
embeds: [createErrorEmbed(`Invalid JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`, "Config Update Failed")],
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Timeout or other error handling if needed, usually just ignore timeouts for modals
|
||||
if (error instanceof Error && error.message.includes('time')) {
|
||||
// specific timeout handling if desired
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
94
bot/commands/admin/create_color.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js";
|
||||
import { config, saveConfig } from "@shared/lib/config";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { items } from "@db/schema";
|
||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
||||
|
||||
export const createColor = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("createcolor")
|
||||
.setDescription("Create a new Color Role and corresponding Item")
|
||||
.addStringOption(option =>
|
||||
option.setName("name")
|
||||
.setDescription("The name of the role and item")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addStringOption(option =>
|
||||
option.setName("color")
|
||||
.setDescription("The hex color code (e.g. #FF0000)")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addNumberOption(option =>
|
||||
option.setName("price")
|
||||
.setDescription("Price of the item (Default: 500)")
|
||||
.setRequired(false)
|
||||
)
|
||||
.addStringOption(option =>
|
||||
option.setName("image")
|
||||
.setDescription("Image URL for the item")
|
||||
.setRequired(false)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
|
||||
const name = interaction.options.getString("name", true);
|
||||
const colorInput = interaction.options.getString("color", true);
|
||||
const price = interaction.options.getNumber("price") || 500;
|
||||
const imageUrl = interaction.options.getString("image") || "https://cdn.discordapp.com/attachments/1450061247365124199/1453122950822760559/Main_Chip_1.png";
|
||||
|
||||
// 1. Validate Color
|
||||
const colorRegex = /^#([0-9A-F]{3}){1,2}$/i;
|
||||
if (!colorRegex.test(colorInput)) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Hex Color code. Format: #RRGGBB")] });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. Create Role
|
||||
const role = await interaction.guild?.roles.create({
|
||||
name: name,
|
||||
color: colorInput as any, // Discord.js types are a bit strict on ColorResolvable, but string generally works or needs parsing
|
||||
reason: `Created via /createcolor by ${interaction.user.tag}`
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
throw new Error("Failed to create role.");
|
||||
}
|
||||
|
||||
// 3. Update Config
|
||||
if (!config.colorRoles.includes(role.id)) {
|
||||
config.colorRoles.push(role.id);
|
||||
saveConfig(config);
|
||||
}
|
||||
|
||||
// 4. Create Item
|
||||
await DrizzleClient.insert(items).values({
|
||||
name: `Color Role - ${name}`,
|
||||
description: `Use this item to apply the ${name} color to your name.`,
|
||||
type: "CONSUMABLE",
|
||||
rarity: "Common",
|
||||
price: BigInt(price),
|
||||
iconUrl: "",
|
||||
imageUrl: imageUrl,
|
||||
usageData: {
|
||||
consume: false,
|
||||
effects: [{ type: "COLOR_ROLE", roleId: role.id }]
|
||||
} as any
|
||||
});
|
||||
|
||||
// 5. Success
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`**Role:** <@&${role.id}> (${colorInput})\n**Item:** Color Role - ${name}\n**Price:** ${price} 🪙`,
|
||||
"✅ Color Role & Item Created"
|
||||
)]
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error in createcolor:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(`Failed to create color role: ${error.message}`)] });
|
||||
}
|
||||
}
|
||||
});
|
||||
14
bot/commands/admin/create_item.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { renderWizard } from "@/modules/admin/item_wizard";
|
||||
|
||||
export const createItem = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("createitem")
|
||||
.setDescription("Create a new item using the interactive wizard")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
const payload = renderWizard(interaction.user.id);
|
||||
await interaction.reply({ ...payload, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
});
|
||||
94
bot/commands/admin/features.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createBaseEmbed } from "@lib/embeds";
|
||||
import { config, reloadConfig, toggleCommand } from "@shared/lib/config";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
|
||||
export const features = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("features")
|
||||
.setDescription("Manage bot features and commands")
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("list")
|
||||
.setDescription("List all commands and their status")
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("toggle")
|
||||
.setDescription("Enable or disable a command")
|
||||
.addStringOption(option =>
|
||||
option.setName("command")
|
||||
.setDescription("The name of the command")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addBooleanOption(option =>
|
||||
option.setName("enabled")
|
||||
.setDescription("Whether the command should be enabled")
|
||||
.setRequired(true)
|
||||
)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
if (subcommand === "list") {
|
||||
const activeCommands = AuroraClient.commands;
|
||||
const categories = new Map<string, string[]>();
|
||||
|
||||
// Group active commands
|
||||
activeCommands.forEach(cmd => {
|
||||
const cat = cmd.category || 'Uncategorized';
|
||||
if (!categories.has(cat)) categories.set(cat, []);
|
||||
categories.get(cat)!.push(cmd.data.name);
|
||||
});
|
||||
|
||||
// Config overrides
|
||||
const overrides = Object.entries(config.commands)
|
||||
.map(([name, enabled]) => `• **${name}**: ${enabled ? "✅ Enabled (Override)" : "❌ Disabled"}`);
|
||||
|
||||
const embed = createBaseEmbed("Command Features", undefined, "Blue");
|
||||
|
||||
// Add fields for each category
|
||||
const sortedCategories = [...categories.keys()].sort();
|
||||
for (const cat of sortedCategories) {
|
||||
const cmds = categories.get(cat)!.sort();
|
||||
const cmdList = cmds.map(name => {
|
||||
const isOverride = config.commands[name] !== undefined;
|
||||
return isOverride ? `**${name}** (See Overrides)` : `**${name}**`;
|
||||
}).join(", ");
|
||||
|
||||
embed.addFields({ name: `📂 ${cat.toUpperCase()}`, value: cmdList || "None" });
|
||||
}
|
||||
|
||||
if (overrides.length > 0) {
|
||||
embed.addFields({ name: "⚙️ Configuration Overrides", value: overrides.join("\n") });
|
||||
} else {
|
||||
embed.addFields({ name: "⚙️ Configuration Overrides", value: "No overrides set." });
|
||||
}
|
||||
|
||||
// Check permissions manually as a fallback (though defaultMemberPermissions handles it at the API level)
|
||||
if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) {
|
||||
await interaction.reply({ content: "❌ You need Administrator permissions to use this command.", flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
|
||||
} else if (subcommand === "toggle") {
|
||||
const commandName = interaction.options.getString("command", true);
|
||||
const enabled = interaction.options.getBoolean("enabled", true);
|
||||
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
toggleCommand(commandName, enabled);
|
||||
|
||||
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Reloading configuration...` });
|
||||
|
||||
// Reload config from disk (which was updated by toggleCommand)
|
||||
reloadConfig();
|
||||
|
||||
await AuroraClient.loadCommands(true);
|
||||
await AuroraClient.deployCommands();
|
||||
|
||||
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Commands reloaded!` });
|
||||
}
|
||||
}
|
||||
});
|
||||
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] });
|
||||
}
|
||||
});
|
||||
99
bot/commands/admin/listing.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import {
|
||||
SlashCommandBuilder,
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
type BaseGuildTextChannel,
|
||||
PermissionFlagsBits,
|
||||
MessageFlags
|
||||
} from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { items } from "@db/schema";
|
||||
import { ilike, isNotNull, and } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { getShopListingMessage } from "@/modules/economy/shop.view";
|
||||
|
||||
export const listing = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("listing")
|
||||
.setDescription("Post an item listing in the channel for users to buy")
|
||||
.addNumberOption(option =>
|
||||
option.setName("item")
|
||||
.setDescription("The item to list")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
)
|
||||
.addChannelOption(option =>
|
||||
option.setName("channel")
|
||||
.setDescription("The channel to post the listing in (defaults to current)")
|
||||
.setRequired(false)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const itemId = interaction.options.getNumber("item", true);
|
||||
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
|
||||
|
||||
if (!targetChannel || !targetChannel.isSendable()) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const item = await inventoryService.getItem(itemId);
|
||||
if (!item) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!item.price) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item "${item.name}" is not for sale (no price set).`)] });
|
||||
return;
|
||||
}
|
||||
|
||||
const listingMessage = getShopListingMessage({
|
||||
...item,
|
||||
formattedPrice: `${item.price} 🪙`,
|
||||
price: item.price
|
||||
});
|
||||
|
||||
try {
|
||||
await targetChannel.send(listingMessage);
|
||||
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
||||
} else {
|
||||
console.error("Error creating listing:", error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
autocomplete: async (interaction) => {
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
|
||||
const results = await DrizzleClient.select({
|
||||
id: items.id,
|
||||
name: items.name,
|
||||
price: items.price
|
||||
})
|
||||
.from(items)
|
||||
.where(
|
||||
and(
|
||||
ilike(items.name, `%${focusedValue}%`),
|
||||
isNotNull(items.price)
|
||||
)
|
||||
)
|
||||
.limit(20);
|
||||
|
||||
await interaction.respond(
|
||||
results.map(item => ({
|
||||
name: `${item.name} (Price: ${item.price})`,
|
||||
value: item.id
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
62
bot/commands/admin/note.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { CaseType } from "@shared/lib/constants";
|
||||
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
export const note = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("note")
|
||||
.setDescription("Add a staff-only note about a user")
|
||||
.addUserOption(option =>
|
||||
option
|
||||
.setName("user")
|
||||
.setDescription("The user to add a note for")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addStringOption(option =>
|
||||
option
|
||||
.setName("note")
|
||||
.setDescription("The note to add")
|
||||
.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 noteText = interaction.options.getString("note", true);
|
||||
|
||||
// Create the note case
|
||||
const moderationCase = await ModerationService.createCase({
|
||||
type: CaseType.NOTE,
|
||||
userId: targetUser.id,
|
||||
username: targetUser.username,
|
||||
moderatorId: interaction.user.id,
|
||||
moderatorName: interaction.user.username,
|
||||
reason: noteText,
|
||||
});
|
||||
|
||||
if (!moderationCase) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("Failed to create note.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Send success message
|
||||
await interaction.editReply({
|
||||
embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Note command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while adding the note.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
43
bot/commands/admin/notes.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
export const notes = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("notes")
|
||||
.setDescription("View all staff notes for a user")
|
||||
.addUserOption(option =>
|
||||
option
|
||||
.setName("user")
|
||||
.setDescription("The user to check notes for")
|
||||
.setRequired(true)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
|
||||
// Get all notes for the user
|
||||
const userNotes = await ModerationService.getUserNotes(targetUser.id);
|
||||
|
||||
// Display the notes
|
||||
await interaction.editReply({
|
||||
embeds: [getCasesListEmbed(
|
||||
userNotes,
|
||||
`📝 Staff Notes for ${targetUser.username}`,
|
||||
userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**`
|
||||
)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Notes command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching notes.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
179
bot/commands/admin/prune.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { PruneService } from "@shared/modules/moderation/prune.service";
|
||||
import {
|
||||
getConfirmationMessage,
|
||||
getProgressEmbed,
|
||||
getSuccessEmbed,
|
||||
getPruneErrorEmbed,
|
||||
getPruneWarningEmbed,
|
||||
getCancelledEmbed
|
||||
} from "@/modules/moderation/prune.view";
|
||||
|
||||
export const prune = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("prune")
|
||||
.setDescription("Delete messages in bulk (admin only)")
|
||||
.addIntegerOption(option =>
|
||||
option
|
||||
.setName("amount")
|
||||
.setDescription(`Number of messages to delete (1-${config.moderation?.prune?.maxAmount || 100})`)
|
||||
.setRequired(false)
|
||||
.setMinValue(1)
|
||||
.setMaxValue(config.moderation?.prune?.maxAmount || 100)
|
||||
)
|
||||
.addUserOption(option =>
|
||||
option
|
||||
.setName("user")
|
||||
.setDescription("Only delete messages from this user")
|
||||
.setRequired(false)
|
||||
)
|
||||
.addBooleanOption(option =>
|
||||
option
|
||||
.setName("all")
|
||||
.setDescription("Delete all messages in the channel")
|
||||
.setRequired(false)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const amount = interaction.options.getInteger("amount");
|
||||
const user = interaction.options.getUser("user");
|
||||
const all = interaction.options.getBoolean("all") || false;
|
||||
|
||||
// Validate inputs
|
||||
if (!amount && !all) {
|
||||
// Default to 10 messages
|
||||
} else if (amount && all) {
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneErrorEmbed("Cannot specify both `amount` and `all`. Please use one or the other.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const finalAmount = all ? 'all' : (amount || 10);
|
||||
const confirmThreshold = config.moderation.prune.confirmThreshold;
|
||||
|
||||
// Check if confirmation is needed
|
||||
const needsConfirmation = all || (typeof finalAmount === 'number' && finalAmount > confirmThreshold);
|
||||
|
||||
if (needsConfirmation) {
|
||||
// Estimate message count for confirmation
|
||||
let estimatedCount: number | undefined;
|
||||
if (all) {
|
||||
try {
|
||||
estimatedCount = await PruneService.estimateMessageCount(interaction.channel!);
|
||||
} catch {
|
||||
estimatedCount = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const { embeds, components } = getConfirmationMessage(finalAmount, estimatedCount);
|
||||
const response = await interaction.editReply({ embeds, components });
|
||||
|
||||
try {
|
||||
const confirmation = await response.awaitMessageComponent({
|
||||
filter: (i) => i.user.id === interaction.user.id,
|
||||
componentType: ComponentType.Button,
|
||||
time: 30000
|
||||
});
|
||||
|
||||
if (confirmation.customId === "cancel_prune") {
|
||||
await confirmation.update({
|
||||
embeds: [getCancelledEmbed()],
|
||||
components: []
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// User confirmed, proceed with deletion
|
||||
await confirmation.update({
|
||||
embeds: [getProgressEmbed({ current: 0, total: estimatedCount || finalAmount as number })],
|
||||
components: []
|
||||
});
|
||||
|
||||
// Execute deletion with progress callback for 'all' mode
|
||||
const result = await PruneService.deleteMessages(
|
||||
interaction.channel!,
|
||||
{
|
||||
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
|
||||
userId: user?.id,
|
||||
all
|
||||
},
|
||||
all ? async (progress) => {
|
||||
await interaction.editReply({
|
||||
embeds: [getProgressEmbed(progress)]
|
||||
});
|
||||
} : undefined
|
||||
);
|
||||
|
||||
// Show success
|
||||
await interaction.editReply({
|
||||
embeds: [getSuccessEmbed(result)],
|
||||
components: []
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("time")) {
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneWarningEmbed("Confirmation timed out. Please try again.")],
|
||||
components: []
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No confirmation needed, proceed directly
|
||||
const result = await PruneService.deleteMessages(
|
||||
interaction.channel!,
|
||||
{
|
||||
amount: finalAmount as number,
|
||||
userId: user?.id,
|
||||
all: false
|
||||
}
|
||||
);
|
||||
|
||||
// Check if no messages were found
|
||||
if (result.deletedCount === 0) {
|
||||
if (user) {
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneWarningEmbed(`No messages found from **${user.username}** in the last ${finalAmount} messages.`)]
|
||||
});
|
||||
} else {
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneWarningEmbed("No messages found to delete.")]
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [getSuccessEmbed(result)]
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Prune command error:", error);
|
||||
|
||||
let errorMessage = "An unexpected error occurred while trying to delete messages.";
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("permission")) {
|
||||
errorMessage = "I don't have permission to delete messages in this channel.";
|
||||
} else if (error.message.includes("channel type")) {
|
||||
errorMessage = "This command cannot be used in this type of channel.";
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneErrorEmbed(errorMessage)]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
33
bot/commands/admin/refresh.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
|
||||
export const refresh = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("refresh")
|
||||
.setDescription("Reloads all commands and config without restarting")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
await AuroraClient.loadCommands(true);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
// Deploy commands
|
||||
await AuroraClient.deployCommands();
|
||||
|
||||
const embed = createSuccessEmbed(
|
||||
`Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`,
|
||||
"System Refreshed"
|
||||
);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An error occurred while refreshing commands. Check console for details.", "Refresh Failed")] });
|
||||
}
|
||||
}
|
||||
});
|
||||
37
bot/commands/admin/terminal.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
|
||||
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
||||
import { createBaseEmbed, createErrorEmbed } from "@/lib/embeds";
|
||||
|
||||
export const terminal = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("terminal")
|
||||
.setDescription("Manage the Aurora Terminal")
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("init")
|
||||
.setDescription("Initialize the terminal in the current channel")
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
if (subcommand === "init") {
|
||||
const channel = interaction.channel;
|
||||
if (!channel || channel.type !== ChannelType.GuildText) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed("Terminal can only be initialized in text channels.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({ ephemeral: true, content: "Initializing terminal..." });
|
||||
|
||||
try {
|
||||
await terminalService.init(channel as TextChannel);
|
||||
await interaction.editReply({ content: "✅ Terminal initialized!" });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await interaction.editReply({ content: "❌ Failed to initialize terminal." });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
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.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
39
bot/commands/admin/warnings.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
export const warnings = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("warnings")
|
||||
.setDescription("View active warnings for a user")
|
||||
.addUserOption(option =>
|
||||
option
|
||||
.setName("user")
|
||||
.setDescription("The user to check warnings for")
|
||||
.setRequired(true)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
|
||||
// Get active warnings for the user
|
||||
const activeWarnings = await ModerationService.getUserWarnings(targetUser.id);
|
||||
|
||||
// Display the warnings
|
||||
await interaction.editReply({
|
||||
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Warnings command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching warnings.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, TextChannel, NewsChannel, VoiceChannel } from "discord.js";
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
||||
|
||||
export const webhook = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -13,7 +14,7 @@ export const webhook = createCommand({
|
||||
.setRequired(true)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const payloadString = interaction.options.getString("payload", true);
|
||||
let payload;
|
||||
@@ -36,37 +37,17 @@ export const webhook = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
let webhook;
|
||||
try {
|
||||
webhook = await channel.createWebhook({
|
||||
name: `${interaction.client.user.username} - Proxy`,
|
||||
avatar: interaction.client.user.displayAvatarURL(),
|
||||
reason: `Proxy message requested by ${interaction.user.tag}`
|
||||
});
|
||||
|
||||
|
||||
// Support snake_case keys for raw API compatibility
|
||||
if (payload.avatar_url && !payload.avatarURL) {
|
||||
payload.avatarURL = payload.avatar_url;
|
||||
delete payload.avatar_url;
|
||||
}
|
||||
|
||||
await webhook.send(payload);
|
||||
|
||||
await webhook.delete("Proxy message sent");
|
||||
await sendWebhookMessage(
|
||||
channel,
|
||||
payload,
|
||||
interaction.client.user,
|
||||
`Proxy message requested by ${interaction.user.tag}`
|
||||
);
|
||||
|
||||
await interaction.editReply({ content: "Message sent successfully!" });
|
||||
} catch (error) {
|
||||
console.error("Webhook error:", error);
|
||||
// Attempt cleanup if webhook was created but sending failed
|
||||
if (webhook) {
|
||||
try {
|
||||
await webhook.delete("Cleanup after failure");
|
||||
} catch (cleanupError) {
|
||||
console.error("Failed to delete webhook during cleanup:", cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("Failed to send message via webhook. Ensure the bot has 'Manage Webhooks' permission and the payload is valid.", "Delivery Failed")]
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { createBaseEmbed } from "@lib/embeds";
|
||||
|
||||
export const balance = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -15,12 +16,17 @@ export const balance = createCommand({
|
||||
await interaction.deferReply();
|
||||
|
||||
const targetUser = interaction.options.getUser("user") || interaction.user;
|
||||
|
||||
if (targetUser.bot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setAuthor({ name: targetUser.username, iconURL: targetUser.displayAvatarURL() })
|
||||
.setDescription(`**Balance**: ${user.balance || 0n} AU`)
|
||||
.setColor("Yellow");
|
||||
if (!user) throw new Error("Failed to retrieve user data.");
|
||||
|
||||
const embed = createBaseEmbed(undefined, `**Balance**: ${user.balance || 0n} AU`, "Yellow")
|
||||
.setAuthor({ name: targetUser.username, iconURL: targetUser.displayAvatarURL() });
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
35
bot/commands/economy/daily.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
|
||||
export const daily = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("daily")
|
||||
.setDescription("Claim your daily reward"),
|
||||
execute: async (interaction) => {
|
||||
try {
|
||||
const result = await economyService.claimDaily(interaction.user.id);
|
||||
|
||||
const embed = createSuccessEmbed(`You claimed ** ${result.amount}** Astral Units!${result.isWeekly ? `\n🎉 **Weekly Bonus!** +${result.weeklyBonus} extra!` : ''}`, "💰 Daily Reward Claimed!")
|
||||
.addFields(
|
||||
{ name: "Streak", value: `🔥 ${result.streak} days`, inline: true },
|
||||
{ name: "Weekly Progress", value: `${"🟩".repeat(result.streak % 7 || 7)}${"⬜".repeat(7 - (result.streak % 7 || 7))} (${result.streak % 7 || 7}/7)`, inline: true },
|
||||
{ name: "Next Reward", value: `<t:${Math.floor(result.nextReadyAt.getTime() / 1000)}:R> `, inline: true }
|
||||
)
|
||||
.setColor("Gold");
|
||||
|
||||
await interaction.reply({ embeds: [embed] });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
||||
} else {
|
||||
console.error("Error claiming daily:", error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
205
bot/commands/economy/exam.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { userTimers, users } from "@db/schema";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { TimerType } from "@shared/lib/constants";
|
||||
|
||||
const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
|
||||
const EXAM_TIMER_KEY = 'default';
|
||||
|
||||
interface ExamMetadata {
|
||||
examDay: number;
|
||||
lastXp: string;
|
||||
}
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
export const exam = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("exam")
|
||||
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
if (!user) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to retrieve user data.")] });
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
const currentDay = now.getDay();
|
||||
|
||||
try {
|
||||
// 1. Fetch existing timer/exam data
|
||||
const timer = await DrizzleClient.query.userTimers.findFirst({
|
||||
where: and(
|
||||
eq(userTimers.userId, user.id),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
)
|
||||
});
|
||||
|
||||
// 2. First Run Logic
|
||||
if (!timer) {
|
||||
// Set exam day to today
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + 7);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||
|
||||
const metadata: ExamMetadata = {
|
||||
examDay: currentDay,
|
||||
lastXp: (user.xp ?? 0n).toString()
|
||||
};
|
||||
|
||||
await DrizzleClient.insert(userTimers).values({
|
||||
userId: user.id,
|
||||
type: EXAM_TIMER_TYPE,
|
||||
key: EXAM_TIMER_KEY,
|
||||
expiresAt: nextExamDate,
|
||||
metadata: metadata
|
||||
});
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}** (Server Time).\n` +
|
||||
`Come back on <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>) to take your first exam!`,
|
||||
"Exam Registration Successful"
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = timer.metadata as unknown as ExamMetadata;
|
||||
const examDay = metadata.examDay;
|
||||
|
||||
// 3. Cooldown Check
|
||||
const expiresAt = new Date(timer.expiresAt);
|
||||
expiresAt.setHours(0, 0, 0, 0);
|
||||
|
||||
if (now < expiresAt) {
|
||||
// Calculate time remaining
|
||||
const timestamp = Math.floor(expiresAt.getTime() / 1000);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
||||
`Next exam available: <t:${timestamp}:D> (<t:${timestamp}:R>)`
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Day Check
|
||||
if (currentDay !== examDay) {
|
||||
// Calculate next correct exam day to correct the schedule
|
||||
let daysUntil = (examDay - currentDay + 7) % 7;
|
||||
if (daysUntil === 0) daysUntil = 7;
|
||||
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + daysUntil);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||
|
||||
const newMetadata: ExamMetadata = {
|
||||
examDay: examDay,
|
||||
lastXp: (user.xp ?? 0n).toString()
|
||||
};
|
||||
|
||||
await DrizzleClient.update(userTimers)
|
||||
.set({
|
||||
expiresAt: nextExamDate,
|
||||
metadata: newMetadata
|
||||
})
|
||||
.where(and(
|
||||
eq(userTimers.userId, user.id),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
));
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You missed your exam day! Your exam day is **${DAYS[examDay]}** (Server Time).\n` +
|
||||
`You verify your attendance but score a **0**.\n` +
|
||||
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
|
||||
"Exam Failed"
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Reward Calculation
|
||||
const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case
|
||||
const currentXp = user.xp ?? 0n;
|
||||
const diff = currentXp - lastXp;
|
||||
|
||||
// Calculate Reward
|
||||
const multMin = config.economy.exam.multMin;
|
||||
const multMax = config.economy.exam.multMax;
|
||||
const multiplier = Math.random() * (multMax - multMin) + multMin;
|
||||
|
||||
// Allow negative reward? existing description implies "difference", usually gain.
|
||||
// If diff is negative (lost XP?), reward might be 0.
|
||||
let reward = 0n;
|
||||
if (diff > 0n) {
|
||||
reward = BigInt(Math.floor(Number(diff) * multiplier));
|
||||
}
|
||||
|
||||
// 6. Update State
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + 7);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||
|
||||
const newMetadata: ExamMetadata = {
|
||||
examDay: examDay,
|
||||
lastXp: currentXp.toString()
|
||||
};
|
||||
|
||||
await DrizzleClient.transaction(async (tx) => {
|
||||
// Update Timer
|
||||
await tx.update(userTimers)
|
||||
.set({
|
||||
expiresAt: nextExamDate,
|
||||
metadata: newMetadata
|
||||
})
|
||||
.where(and(
|
||||
eq(userTimers.userId, user.id),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
));
|
||||
|
||||
// Add Currency
|
||||
if (reward > 0n) {
|
||||
await tx.update(users)
|
||||
.set({
|
||||
balance: sql`${users.balance} + ${reward}`
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
}
|
||||
});
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`**XP Gained:** ${diff.toString()}\n` +
|
||||
`**Multiplier:** x${multiplier.toFixed(2)}\n` +
|
||||
`**Reward:** ${reward.toString()} Currency\n\n` +
|
||||
`See you next week: <t:${nextExamTimestamp}:D>`,
|
||||
"Exam Passed!"
|
||||
)]
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
||||
} else {
|
||||
console.error("Error in exam command:", error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
69
bot/commands/economy/pay.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
|
||||
export const pay = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("pay")
|
||||
.setDescription("Transfer Astral Units to another user")
|
||||
.addUserOption(option =>
|
||||
option.setName("user")
|
||||
.setDescription("The user to pay")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addIntegerOption(option =>
|
||||
option.setName("amount")
|
||||
.setDescription("Amount to transfer")
|
||||
.setMinValue(1)
|
||||
.setRequired(true)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
const targetUser = await userService.getOrCreateUser(interaction.options.getUser("user", true).id, interaction.options.getUser("user", true).username);
|
||||
const discordUser = interaction.options.getUser("user", true);
|
||||
|
||||
if (discordUser.bot) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed("You cannot send money to bots.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
const amount = BigInt(interaction.options.getInteger("amount", true));
|
||||
const senderId = interaction.user.id;
|
||||
if (!targetUser) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed("User not found.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
const receiverId = targetUser.id;
|
||||
|
||||
if (amount < config.economy.transfers.minAmount) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(`Amount must be at least ${config.economy.transfers.minAmount}.`)], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
if (senderId === receiverId.toString()) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed("You cannot pay yourself.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await interaction.deferReply();
|
||||
await economyService.transfer(senderId, receiverId.toString(), amount);
|
||||
|
||||
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
|
||||
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error sending payment:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, EmbedBuilder, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, ThreadAutoArchiveDuration } from "discord.js";
|
||||
import { TradeService } from "@/modules/trade/trade.service";
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags } from "discord.js";
|
||||
import { tradeService } from "@shared/modules/trade/trade.service";
|
||||
import { getTradeDashboard } from "@/modules/trade/trade.view";
|
||||
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
|
||||
|
||||
export const trade = createCommand({
|
||||
@@ -16,19 +17,19 @@ export const trade = createCommand({
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
|
||||
if (targetUser.id === interaction.user.id) {
|
||||
await interaction.reply({ embeds: [createWarningEmbed("You cannot trade with yourself.")], ephemeral: true });
|
||||
await interaction.reply({ embeds: [createWarningEmbed("You cannot trade with yourself.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetUser.bot) {
|
||||
await interaction.reply({ embeds: [createWarningEmbed("You cannot trade with bots.")], ephemeral: true });
|
||||
await interaction.reply({ embeds: [createWarningEmbed("You cannot trade with bots.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create Thread
|
||||
const channel = interaction.channel;
|
||||
if (!channel || channel.type === ChannelType.DM) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed("Cannot start trade in DMs.")], ephemeral: true });
|
||||
await interaction.reply({ embeds: [createErrorEmbed("Cannot start trade in DMs.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -53,37 +54,20 @@ export const trade = createCommand({
|
||||
} catch (err) {
|
||||
console.error("Failed to delete setup message", err);
|
||||
}
|
||||
await interaction.followUp({ embeds: [createErrorEmbed("Failed to create trade thread. Check permissions.")], ephemeral: true });
|
||||
await interaction.followUp({ embeds: [createErrorEmbed("Failed to create trade thread. Check permissions.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup Session
|
||||
TradeService.createSession(thread.id,
|
||||
const session = tradeService.createSession(thread.id,
|
||||
{ id: interaction.user.id, username: interaction.user.username },
|
||||
{ id: targetUser.id, username: targetUser.username }
|
||||
);
|
||||
|
||||
// Send Dashboard to Thread
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("🤝 Trading Session")
|
||||
.setDescription(`Trade started between ${interaction.user} and ${targetUser}.\nUse the controls below to build your offer.`)
|
||||
.setColor(0xFFD700)
|
||||
.addFields(
|
||||
{ name: interaction.user.username, value: "*Empty Offer*", inline: true },
|
||||
{ name: targetUser.username, value: "*Empty Offer*", inline: true }
|
||||
)
|
||||
.setFooter({ text: "Both parties must click Lock to confirm trade." });
|
||||
const dashboard = getTradeDashboard(session);
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder().setCustomId('trade_add_item').setLabel('Add Item').setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId('trade_add_money').setLabel('Add Money').setStyle(ButtonStyle.Success),
|
||||
new ButtonBuilder().setCustomId('trade_remove_item').setLabel('Remove Item').setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId('trade_lock').setLabel('Lock / Unlock').setStyle(ButtonStyle.Primary),
|
||||
new ButtonBuilder().setCustomId('trade_cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger),
|
||||
);
|
||||
|
||||
await thread.send({ content: `${interaction.user} ${targetUser} Welcome to your trading session!`, embeds: [embed], components: [row] });
|
||||
await thread.send({ content: `${interaction.user} ${targetUser} Welcome to your trading session!`, ...dashboard });
|
||||
|
||||
// Update original reply
|
||||
await interaction.editReply({ content: `✅ Trade opened: <#${thread.id}>` });
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
29
bot/commands/feedback/feedback.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { getFeedbackTypeMenu } from "@/modules/feedback/feedback.view";
|
||||
|
||||
export const feedback = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("feedback")
|
||||
.setDescription("Submit feedback, feature requests, or bug reports"),
|
||||
execute: async (interaction) => {
|
||||
// Check if feedback channel is configured
|
||||
if (!config.feedbackChannelId) {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("Feedback system is not configured. Please contact an administrator.")],
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Show feedback type selection menu
|
||||
const menu = getFeedbackTypeMenu();
|
||||
await interaction.reply({
|
||||
content: "## 🌟 Share Your Thoughts\n\nThank you for helping improve Aurora! Please select the type of feedback you'd like to submit:",
|
||||
...menu,
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
|
||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { createWarningEmbed } from "@lib/embeds";
|
||||
import { getInventoryEmbed } from "@/modules/inventory/inventory.view";
|
||||
|
||||
export const inventory = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -17,23 +18,26 @@ export const inventory = createCommand({
|
||||
await interaction.deferReply();
|
||||
|
||||
const targetUser = interaction.options.getUser("user") || interaction.user;
|
||||
|
||||
if (targetUser.bot) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("Bots do not have inventories.", "Inventory Check")] });
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("Inventory is empty.", `${user.username}'s Inventory`)] });
|
||||
return;
|
||||
}
|
||||
|
||||
const description = items.map(entry => {
|
||||
return `**${entry.item.name}** x${entry.quantity}`;
|
||||
}).join("\n");
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`${user.username}'s Inventory`)
|
||||
.setDescription(description)
|
||||
.setColor("Blue")
|
||||
.setTimestamp();
|
||||
const embed = getInventoryEmbed(items, user.username);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
79
bot/commands/inventory/use.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
||||
import type { ItemUsageData } from "@shared/lib/types";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { config } from "@shared/lib/config";
|
||||
|
||||
export const use = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("use")
|
||||
.setDescription("Use an item from your inventory")
|
||||
.addNumberOption(option =>
|
||||
option.setName("item")
|
||||
.setDescription("The item to use")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
|
||||
const itemId = interaction.options.getNumber("item", true);
|
||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
if (!user) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await inventoryService.useItem(user.id.toString(), itemId);
|
||||
|
||||
const usageData = result.usageData;
|
||||
if (usageData) {
|
||||
for (const effect of usageData.effects) {
|
||||
if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') {
|
||||
try {
|
||||
const member = await interaction.guild?.members.fetch(user.id.toString());
|
||||
if (member) {
|
||||
if (effect.type === 'TEMP_ROLE') {
|
||||
await member.roles.add(effect.roleId);
|
||||
} else if (effect.type === 'COLOR_ROLE') {
|
||||
// Remove existing color roles
|
||||
const rolesToRemove = config.colorRoles.filter(r => member.roles.cache.has(r));
|
||||
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
|
||||
await member.roles.add(effect.roleId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to assign role in /use command:", e);
|
||||
result.results.push("⚠️ Failed to assign role (Check bot permissions)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const embed = getItemUseResultEmbed(result.results, result.item);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error using item:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred while using the item.")] });
|
||||
}
|
||||
}
|
||||
},
|
||||
autocomplete: async (interaction) => {
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
const userId = interaction.user.id;
|
||||
|
||||
const results = await inventoryService.getAutocompleteItems(userId, focusedValue);
|
||||
|
||||
await interaction.respond(results);
|
||||
}
|
||||
});
|
||||
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,7 +1,8 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
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 { createWarningEmbed } from "@/lib/embeds";
|
||||
|
||||
export const profile = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -16,6 +17,12 @@ export const profile = createCommand({
|
||||
await interaction.deferReply();
|
||||
|
||||
const targetUser = interaction.options.getUser("user") || interaction.user;
|
||||
|
||||
if (targetUser.bot) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("Bots do not have profiles.", "Profile Check")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
||||
|
||||
const cardBuffer = await generateStudentIdCard({
|
||||
35
bot/events/guildMemberAdd.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Events } from "discord.js";
|
||||
import type { Event } from "@shared/lib/types";
|
||||
import { config } from "@shared/lib/config";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
|
||||
// Visitor role
|
||||
const event: Event<Events.GuildMemberAdd> = {
|
||||
name: Events.GuildMemberAdd,
|
||||
execute: async (member) => {
|
||||
console.log(`👤 New member joined: ${member.user.tag} (${member.id})`);
|
||||
try {
|
||||
const user = await userService.getUserById(member.id);
|
||||
|
||||
if (user && user.class) {
|
||||
console.log(`🔄 Returning student detected: ${member.user.tag}`);
|
||||
await member.roles.remove(config.visitorRole);
|
||||
await member.roles.add(config.studentRole);
|
||||
|
||||
if (user.class.roleId) {
|
||||
await member.roles.add(user.class.roleId);
|
||||
console.log(`Restored class role ${user.class.name} to ${member.user.tag}`);
|
||||
}
|
||||
console.log(`Restored student role to ${member.user.tag}`);
|
||||
} else {
|
||||
await member.roles.add(config.visitorRole);
|
||||
console.log(`Assigned visitor role to ${member.user.tag}`);
|
||||
}
|
||||
console.log(`User Roles: ${member.roles.cache.map(role => role.name).join(", ")}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to handle role assignment for ${member.user.tag}:`, error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default event;
|
||||
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 { userService } from "@/modules/user/user.service";
|
||||
import { levelingService } from "@/modules/leveling/leveling.service";
|
||||
import type { Event } from "@lib/types";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { levelingService } from "@shared/modules/leveling/leveling.service";
|
||||
import type { Event } from "@shared/lib/types";
|
||||
|
||||
const event: Event<Events.MessageCreate> = {
|
||||
name: Events.MessageCreate,
|
||||
@@ -13,6 +13,9 @@ const event: Event<Events.MessageCreate> = {
|
||||
if (!user) return;
|
||||
|
||||
levelingService.processChatXp(message.author.id);
|
||||
|
||||
// Activity Tracking for Lootdrops
|
||||
import("@shared/modules/economy/lootdrop.service").then(m => m.lootdropService.processMessage(message));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Events } from "discord.js";
|
||||
import { schedulerService } from "@/modules/system/scheduler";
|
||||
import type { Event } from "@lib/types";
|
||||
import type { Event } from "@shared/lib/types";
|
||||
|
||||
const event: Event<Events.ClientReady> = {
|
||||
name: Events.ClientReady,
|
||||
@@ -8,6 +8,10 @@ const event: Event<Events.ClientReady> = {
|
||||
execute: async (c) => {
|
||||
console.log(`Ready! Logged in as ${c.user.tag}`);
|
||||
schedulerService.start();
|
||||
|
||||
// Handle post-update tasks
|
||||
const { UpdateService } = await import("@shared/modules/admin/update.service");
|
||||
await UpdateService.handlePostRestart(c);
|
||||
},
|
||||
};
|
||||
|
||||
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 { levelingService } from '@/modules/leveling/leveling.service';
|
||||
import { levelingService } from '@shared/modules/leveling/leveling.service';
|
||||
import path from 'path';
|
||||
|
||||
// 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, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
|
||||
|
||||
@@ -18,8 +18,8 @@ interface StudentCardData {
|
||||
}
|
||||
|
||||
export async function generateStudentIdCard(data: StudentCardData): Promise<Buffer> {
|
||||
const templatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'studentID', 'template.png');
|
||||
const classTemplatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'studentID', `Constellation-${data.className}.png`);
|
||||
const templatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'studentID', 'template.png');
|
||||
const classTemplatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'studentID', `Constellation-${data.className}.png`);
|
||||
|
||||
const template = await loadImage(templatePath);
|
||||
const classTemplate = await loadImage(classTemplatePath);
|
||||
@@ -97,9 +97,12 @@ export async function generateStudentIdCard(data: StudentCardData): Promise<Buff
|
||||
ctx.restore();
|
||||
|
||||
// Draw XP Bar
|
||||
const xpForNextLevel = levelingService.getXpForLevel(data.level);
|
||||
const xpForThisLevel = levelingService.getXpForNextLevel(data.level); // The total size of the current level bucket
|
||||
const xpAtStartOfLevel = levelingService.getXpToReachLevel(data.level); // The accumulated XP when this level started
|
||||
const currentLevelProgress = Number(data.xp) - xpAtStartOfLevel; // How much XP into this level
|
||||
|
||||
const xpBarMaxWidth = 382;
|
||||
const xpBarWidth = xpBarMaxWidth * Number(data.xp) / Number(xpForNextLevel);
|
||||
const xpBarWidth = Math.max(0, Math.min(xpBarMaxWidth, xpBarMaxWidth * currentLevelProgress / xpForThisLevel));
|
||||
const xpBarHeight = 3;
|
||||
ctx.save();
|
||||
ctx.fillStyle = '#B3AD93';
|
||||
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();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EmbedBuilder, Colors } from "discord.js";
|
||||
import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js";
|
||||
|
||||
/**
|
||||
* Creates a standardized error embed.
|
||||
@@ -55,3 +55,21 @@ export function createInfoEmbed(message: string, title: string = "Info"): EmbedB
|
||||
.setColor(Colors.Blue)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a standardized base embed with common configuration.
|
||||
* @param title Optional title for the embed.
|
||||
* @param description Optional description for the embed.
|
||||
* @param color Optional color for the embed.
|
||||
* @returns An EmbedBuilder instance with base configuration.
|
||||
*/
|
||||
export function createBaseEmbed(title?: string, description?: string, color?: ColorResolvable): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTimestamp();
|
||||
|
||||
if (title) embed.setTitle(title);
|
||||
if (description) embed.setDescription(description);
|
||||
if (color) embed.setColor(color);
|
||||
|
||||
return embed;
|
||||
}
|
||||
18
bot/lib/errors.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export class ApplicationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
}
|
||||
}
|
||||
|
||||
export class UserError extends ApplicationError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class SystemError extends ApplicationError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
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";
|
||||
66
bot/lib/interaction.routes.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
|
||||
|
||||
// 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 {
|
||||
predicate: (interaction: ComponentInteraction) => boolean;
|
||||
handler: () => Promise<InteractionModule>;
|
||||
method: string;
|
||||
}
|
||||
|
||||
export const interactionRoutes: InteractionRoute[] = [
|
||||
// --- TRADE MODULE ---
|
||||
{
|
||||
predicate: (i) => i.customId.startsWith("trade_") || i.customId === "amount",
|
||||
handler: () => import("@/modules/trade/trade.interaction"),
|
||||
method: 'handleTradeInteraction'
|
||||
},
|
||||
|
||||
// --- ECONOMY MODULE ---
|
||||
{
|
||||
predicate: (i) => i.isButton() && i.customId.startsWith("shop_buy_"),
|
||||
handler: () => import("@/modules/economy/shop.interaction"),
|
||||
method: 'handleShopInteraction'
|
||||
},
|
||||
{
|
||||
predicate: (i) => i.isButton() && i.customId.startsWith("lootdrop_"),
|
||||
handler: () => import("@/modules/economy/lootdrop.interaction"),
|
||||
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_"),
|
||||
handler: () => import("@/modules/admin/item_wizard"),
|
||||
method: 'handleItemWizardInteraction'
|
||||
},
|
||||
|
||||
// --- USER MODULE ---
|
||||
{
|
||||
predicate: (i) => i.isButton() && i.customId === "enrollment",
|
||||
handler: () => import("@/modules/user/enrollment.interaction"),
|
||||
method: 'handleEnrollmentInteraction'
|
||||
},
|
||||
|
||||
// --- FEEDBACK MODULE ---
|
||||
{
|
||||
predicate: (i) => i.customId.startsWith("feedback_"),
|
||||
handler: () => import("@/modules/feedback/feedback.interaction"),
|
||||
method: 'handleFeedbackInteraction'
|
||||
}
|
||||
];
|
||||
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));
|
||||
}
|
||||
};
|
||||
56
bot/lib/webhookUtils.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { type TextBasedChannel, User } from 'discord.js';
|
||||
|
||||
/**
|
||||
* Sends a message to a channel using a temporary webhook (imitating the bot or custom persona).
|
||||
*
|
||||
* @param channel The channel to send the message to (must support webhooks).
|
||||
* @param payload The message payload (string content or JSON object for embeds/options).
|
||||
* @param clientUser The client user (bot) to fallback for avatar/name if not specified in payload.
|
||||
* @param reason The reason for creating the webhook (for audit logs).
|
||||
*/
|
||||
export async function sendWebhookMessage(
|
||||
channel: TextBasedChannel,
|
||||
payload: any,
|
||||
clientUser: User,
|
||||
reason: string
|
||||
): Promise<void> {
|
||||
|
||||
if (!('createWebhook' in channel)) {
|
||||
throw new Error("Channel does not support webhooks.");
|
||||
}
|
||||
|
||||
// Normalize payload if it's just a string, wrap it in content
|
||||
if (typeof payload === 'string') {
|
||||
payload = { content: payload };
|
||||
}
|
||||
|
||||
let webhook;
|
||||
try {
|
||||
webhook = await channel.createWebhook({
|
||||
name: payload.username || `${clientUser.username}`, // Use payload name or bot name
|
||||
avatar: payload.avatar_url || payload.avatarURL || clientUser.displayAvatarURL(),
|
||||
reason: reason
|
||||
});
|
||||
|
||||
// Support snake_case keys for raw API compatibility if passed from config
|
||||
if (payload.avatar_url && !payload.avatarURL) {
|
||||
payload.avatarURL = payload.avatar_url;
|
||||
delete payload.avatar_url;
|
||||
}
|
||||
|
||||
await webhook.send(payload);
|
||||
|
||||
await webhook.delete(reason);
|
||||
|
||||
} catch (error) {
|
||||
// Attempt cleanup if webhook was created but sending failed
|
||||
if (webhook) {
|
||||
try {
|
||||
await webhook.delete("Cleanup after failure");
|
||||
} catch (cleanupError) {
|
||||
console.error("Failed to delete webhook during cleanup:", cleanupError);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
260
bot/modules/admin/item_wizard.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { describe, test, expect, spyOn, beforeEach, mock } from "bun:test";
|
||||
import { handleItemWizardInteraction, renderWizard } from "./item_wizard";
|
||||
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
|
||||
|
||||
// Mock Setup
|
||||
const valuesMock = mock((_args: any) => Promise.resolve());
|
||||
const insertMock = mock(() => ({ values: valuesMock }));
|
||||
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: {
|
||||
insert: insertMock
|
||||
}
|
||||
}));
|
||||
|
||||
mock.module("@db/schema", () => ({
|
||||
items: "items_schema"
|
||||
}));
|
||||
|
||||
describe("ItemWizard", () => {
|
||||
const userId = "test-user-123";
|
||||
|
||||
beforeEach(() => {
|
||||
valuesMock.mockClear();
|
||||
insertMock.mockClear();
|
||||
// Since draftSession is internal, we can't easily clear it.
|
||||
// We will use unique user IDs or rely on overwrite behavior.
|
||||
});
|
||||
|
||||
// Helper to create base interaction
|
||||
const createBaseInteraction = (id: string, customId: string) => ({
|
||||
user: { id },
|
||||
customId,
|
||||
deferUpdate: mock(() => Promise.resolve()),
|
||||
editReply: mock(() => Promise.resolve()),
|
||||
update: mock(() => Promise.resolve()),
|
||||
showModal: mock(() => Promise.resolve()),
|
||||
followUp: mock(() => Promise.resolve()),
|
||||
reply: mock(() => Promise.resolve()),
|
||||
});
|
||||
|
||||
test("renderWizard should return initial state for new user", () => {
|
||||
const result = renderWizard(`new-${Date.now()}`);
|
||||
expect(result.embeds).toHaveLength(1);
|
||||
expect(result.embeds[0]?.data.title).toContain("New Item");
|
||||
expect(result.components).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("handleItemWizardInteraction should handle details modal submit", async () => {
|
||||
const uid = `user-details-${Date.now()}`;
|
||||
renderWizard(uid); // Init session
|
||||
|
||||
const interaction = {
|
||||
...createBaseInteraction(uid, "createitem_modal_details"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => true,
|
||||
isMessageComponent: () => false,
|
||||
fields: {
|
||||
getTextInputValue: (key: string) => {
|
||||
if (key === "name") return "Updated Name";
|
||||
if (key === "desc") return "Updated Desc";
|
||||
if (key === "rarity") return "Legendary";
|
||||
return "";
|
||||
}
|
||||
},
|
||||
} as unknown as ModalSubmitInteraction;
|
||||
|
||||
await handleItemWizardInteraction(interaction);
|
||||
|
||||
expect(interaction.deferUpdate).toHaveBeenCalled();
|
||||
const result = renderWizard(uid);
|
||||
expect(result.embeds[0]?.data.title).toContain("Updated Name");
|
||||
});
|
||||
|
||||
test("handleItemWizardInteraction should handle economy modal submit", async () => {
|
||||
const uid = `user-economy-${Date.now()}`;
|
||||
renderWizard(uid);
|
||||
|
||||
const interaction = {
|
||||
...createBaseInteraction(uid, "createitem_modal_economy"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => true,
|
||||
isMessageComponent: () => false,
|
||||
fields: {
|
||||
getTextInputValue: (key: string) => (key === "price" ? "500" : "")
|
||||
},
|
||||
} as unknown as ModalSubmitInteraction;
|
||||
|
||||
await handleItemWizardInteraction(interaction);
|
||||
|
||||
const result = renderWizard(uid);
|
||||
const economyField = result.embeds[0]?.data.fields?.find(f => f.name === "Economy");
|
||||
expect(economyField?.value).toContain("500 🪙");
|
||||
});
|
||||
|
||||
test("handleItemWizardInteraction should handle visuals modal submit", async () => {
|
||||
const uid = `user-visuals-${Date.now()}`;
|
||||
renderWizard(uid);
|
||||
|
||||
const interaction = {
|
||||
...createBaseInteraction(uid, "createitem_modal_visuals"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => true,
|
||||
isMessageComponent: () => false,
|
||||
fields: {
|
||||
getTextInputValue: (key: string) => {
|
||||
if (key === "icon") return "http://icon.com";
|
||||
if (key === "image") return "http://image.com";
|
||||
return "";
|
||||
}
|
||||
},
|
||||
} as unknown as ModalSubmitInteraction;
|
||||
|
||||
await handleItemWizardInteraction(interaction);
|
||||
|
||||
const result = renderWizard(uid);
|
||||
expect(result.embeds[0]?.data.thumbnail?.url).toBe("http://icon.com");
|
||||
expect(result.embeds[0]?.data.image?.url).toBe("http://image.com");
|
||||
});
|
||||
|
||||
test("handleItemWizardInteraction should flow through adding an effect", async () => {
|
||||
const uid = `user-effect-${Date.now()}`;
|
||||
renderWizard(uid);
|
||||
|
||||
// 1. Start Add Effect
|
||||
const startInteraction = {
|
||||
...createBaseInteraction(uid, "createitem_addeffect_start"),
|
||||
isButton: () => true,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => false,
|
||||
isMessageComponent: () => true,
|
||||
} as unknown as ButtonInteraction;
|
||||
await handleItemWizardInteraction(startInteraction);
|
||||
expect(startInteraction.update).toHaveBeenCalled(); // Should show select menu
|
||||
|
||||
// 2. Select Effect Type
|
||||
const selectInteraction = {
|
||||
...createBaseInteraction(uid, "createitem_select_effect_type"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => true,
|
||||
isModalSubmit: () => false,
|
||||
isMessageComponent: () => true,
|
||||
values: ["ADD_XP"]
|
||||
} as unknown as StringSelectMenuInteraction;
|
||||
await handleItemWizardInteraction(selectInteraction);
|
||||
expect(selectInteraction.showModal).toHaveBeenCalled(); // Should show config modal
|
||||
|
||||
// 3. Submit Effect Config Modal
|
||||
const modalInteraction = {
|
||||
...createBaseInteraction(uid, "createitem_modal_effect"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => true,
|
||||
isMessageComponent: () => false,
|
||||
fields: {
|
||||
getTextInputValue: (key: string) => (key === "amount" ? "1000" : "")
|
||||
},
|
||||
} as unknown as ModalSubmitInteraction;
|
||||
await handleItemWizardInteraction(modalInteraction);
|
||||
|
||||
// Verify Effect Added
|
||||
const result = renderWizard(uid);
|
||||
const effectsField = result.embeds[0]?.data.fields?.find(f => f.name === "Usage Effects");
|
||||
expect(effectsField?.value).toContain("ADD_XP");
|
||||
expect(effectsField?.value).toContain("1000");
|
||||
});
|
||||
|
||||
test("handleItemWizardInteraction should save item to database", async () => {
|
||||
const uid = `user-save-${Date.now()}`;
|
||||
renderWizard(uid);
|
||||
|
||||
// Set name first so we can check it
|
||||
const nameInteraction = {
|
||||
...createBaseInteraction(uid, "createitem_modal_details"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => true,
|
||||
isMessageComponent: () => false,
|
||||
fields: {
|
||||
getTextInputValue: (key: string) => {
|
||||
if (key === "name") return "Saved Item";
|
||||
if (key === "desc") return "Desc";
|
||||
if (key === "rarity") return "Common";
|
||||
return "";
|
||||
}
|
||||
},
|
||||
} as unknown as ModalSubmitInteraction;
|
||||
await handleItemWizardInteraction(nameInteraction);
|
||||
|
||||
// Save
|
||||
const saveInteraction = {
|
||||
...createBaseInteraction(uid, "createitem_save"),
|
||||
isButton: () => true,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => false,
|
||||
isMessageComponent: () => true,
|
||||
} as unknown as ButtonInteraction;
|
||||
|
||||
await handleItemWizardInteraction(saveInteraction);
|
||||
|
||||
expect(valuesMock).toHaveBeenCalled();
|
||||
const calls = valuesMock.mock.calls as any[];
|
||||
if (calls.length > 0) {
|
||||
const callArgs = calls[0][0];
|
||||
expect(callArgs).toMatchObject({
|
||||
name: "Saved Item",
|
||||
description: "Desc",
|
||||
rarity: "Common",
|
||||
// Add other fields as needed
|
||||
});
|
||||
}
|
||||
|
||||
expect(saveInteraction.editReply).toHaveBeenCalledWith(expect.objectContaining({
|
||||
content: expect.stringContaining("successfully")
|
||||
}));
|
||||
});
|
||||
|
||||
test("handleItemWizardInteraction should cancel and clear session", async () => {
|
||||
const uid = `user-cancel-${Date.now()}`;
|
||||
renderWizard(uid);
|
||||
|
||||
const interaction = {
|
||||
...createBaseInteraction(uid, "createitem_cancel"),
|
||||
isButton: () => true, // Technically any component
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => false,
|
||||
isMessageComponent: () => true,
|
||||
} as unknown as ButtonInteraction;
|
||||
|
||||
await handleItemWizardInteraction(interaction);
|
||||
|
||||
expect(interaction.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
content: expect.stringContaining("cancelled")
|
||||
}));
|
||||
|
||||
// Verify session is gone by checking if renderWizard returns default New Item
|
||||
// Let's modify it first
|
||||
const modInteraction = {
|
||||
...createBaseInteraction(uid, "createitem_modal_details"),
|
||||
isButton: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
isModalSubmit: () => true,
|
||||
isMessageComponent: () => false,
|
||||
fields: {
|
||||
getTextInputValue: (key: string) => (key === "name" ? "Modified" : "x")
|
||||
},
|
||||
} as unknown as ModalSubmitInteraction;
|
||||
await handleItemWizardInteraction(modInteraction);
|
||||
|
||||
// Now Cancel
|
||||
await handleItemWizardInteraction(interaction);
|
||||
|
||||
// New render should be "New Item" not "Modified"
|
||||
const result = renderWizard(uid);
|
||||
expect(result.embeds[0]?.data.title).toContain("New Item");
|
||||
expect(result.embeds[0]?.data.title).not.toContain("Modified");
|
||||
});
|
||||
});
|
||||
248
bot/modules/admin/item_wizard.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { type Interaction } from "discord.js";
|
||||
import { items } from "@db/schema";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import type { ItemUsageData, ItemEffect } from "@shared/lib/types";
|
||||
import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
|
||||
import type { DraftItem } from "./item_wizard.types";
|
||||
import { ItemType, EffectType } from "@shared/lib/constants";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
|
||||
// --- State ---
|
||||
const draftSession = new Map<string, DraftItem>();
|
||||
|
||||
|
||||
|
||||
// --- Render ---
|
||||
export const renderWizard = (userId: string, isDraft = true) => {
|
||||
let draft = draftSession.get(userId);
|
||||
|
||||
// Initialize if new
|
||||
if (!draft) {
|
||||
draft = {
|
||||
name: "New Item",
|
||||
description: "No description",
|
||||
rarity: "Common",
|
||||
type: ItemType.MATERIAL,
|
||||
price: null,
|
||||
iconUrl: "",
|
||||
imageUrl: "",
|
||||
usageData: { consume: true, effects: [] } // Default Consume to true for now
|
||||
};
|
||||
draftSession.set(userId, draft);
|
||||
}
|
||||
|
||||
const { embeds, components } = getItemWizardEmbed(draft);
|
||||
return { embeds, components };
|
||||
};
|
||||
|
||||
// --- Handler ---
|
||||
export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
// Only handle createitem interactions
|
||||
if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return;
|
||||
if (!interaction.customId.startsWith("createitem_")) return;
|
||||
|
||||
const userId = interaction.user.id;
|
||||
let draft = draftSession.get(userId);
|
||||
|
||||
// Special case for Cancel - doesn't need draft checks usually, but we want to clear it
|
||||
if (interaction.customId === "createitem_cancel") {
|
||||
draftSession.delete(userId);
|
||||
if (interaction.isMessageComponent()) {
|
||||
await interaction.update({ content: "❌ Item creation cancelled.", embeds: [], components: [] });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize draft if missing for other actions (edge case: bot restart)
|
||||
if (!draft) {
|
||||
if (interaction.isMessageComponent()) {
|
||||
// Create one implicitly to prevent crashes, or warn user
|
||||
if (interaction.customId === "createitem_start") {
|
||||
// Allow start
|
||||
} else {
|
||||
await interaction.reply({ content: "⚠️ Session expired. Please run `/createitem` again.", ephemeral: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-get draft (guaranteed now if we handled the start/restart)
|
||||
// Actually renderWizard initializes it, so if we call that we are safe.
|
||||
// But for Modals we need it.
|
||||
|
||||
if (!draft) {
|
||||
// Just init it
|
||||
renderWizard(userId);
|
||||
draft = draftSession.get(userId)!;
|
||||
}
|
||||
|
||||
|
||||
// --- Routing ---
|
||||
|
||||
// 1. Details Modal
|
||||
if (interaction.customId === "createitem_details") {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = getDetailsModal(draft);
|
||||
await interaction.showModal(modal);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Economy Modal
|
||||
if (interaction.customId === "createitem_economy") {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = getEconomyModal(draft);
|
||||
await interaction.showModal(modal);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Visuals Modal
|
||||
if (interaction.customId === "createitem_visuals") {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = getVisualsModal(draft);
|
||||
await interaction.showModal(modal);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Type Toggle (Start Select Menu)
|
||||
if (interaction.customId === "createitem_type_toggle") {
|
||||
if (!interaction.isButton()) return;
|
||||
const { components } = getItemTypeSelection();
|
||||
await interaction.update({ components }); // Temporary view
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.customId === "createitem_select_type") {
|
||||
if (!interaction.isStringSelectMenu()) return;
|
||||
const selected = interaction.values[0];
|
||||
if (selected) {
|
||||
draft.type = selected;
|
||||
}
|
||||
// Re-render
|
||||
const payload = renderWizard(userId);
|
||||
await interaction.update(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Add Effect Flow
|
||||
if (interaction.customId === "createitem_addeffect_start") {
|
||||
if (!interaction.isButton()) return;
|
||||
const { components } = getEffectTypeSelection();
|
||||
await interaction.update({ components });
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.customId === "createitem_select_effect_type") {
|
||||
if (!interaction.isStringSelectMenu()) return;
|
||||
const effectType = interaction.values[0];
|
||||
if (!effectType) return;
|
||||
draft.pendingEffectType = effectType;
|
||||
|
||||
// Immediately show modal for data collection
|
||||
// Note: You can't showModal from an update? You CAN showModal from a component interaction (SelectMenu).
|
||||
// But we shouldn't update the message AND show modal. We must pick one.
|
||||
// We will show modal. The message remains in "Select Effect" state until modal submit re-renders it.
|
||||
|
||||
const modal = getEffectConfigModal(effectType);
|
||||
await interaction.showModal(modal);
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle Consume
|
||||
if (interaction.customId === "createitem_toggle_consume") {
|
||||
if (!interaction.isButton()) return;
|
||||
draft.usageData.consume = !draft.usageData.consume;
|
||||
const payload = renderWizard(userId);
|
||||
await interaction.update(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. Handle Modal Submits
|
||||
if (interaction.isModalSubmit()) {
|
||||
if (interaction.customId === "createitem_modal_details") {
|
||||
draft.name = interaction.fields.getTextInputValue("name");
|
||||
draft.description = interaction.fields.getTextInputValue("desc");
|
||||
draft.rarity = interaction.fields.getTextInputValue("rarity");
|
||||
}
|
||||
else if (interaction.customId === "createitem_modal_economy") {
|
||||
const price = parseInt(interaction.fields.getTextInputValue("price"));
|
||||
draft.price = isNaN(price) || price === 0 ? null : price;
|
||||
}
|
||||
else if (interaction.customId === "createitem_modal_visuals") {
|
||||
draft.iconUrl = interaction.fields.getTextInputValue("icon");
|
||||
draft.imageUrl = interaction.fields.getTextInputValue("image");
|
||||
}
|
||||
else if (interaction.customId === "createitem_modal_effect") {
|
||||
const type = draft.pendingEffectType;
|
||||
if (type) {
|
||||
let effect: ItemEffect | null = null;
|
||||
|
||||
if (type === EffectType.ADD_XP || type === EffectType.ADD_BALANCE) {
|
||||
const amount = parseInt(interaction.fields.getTextInputValue("amount"));
|
||||
if (!isNaN(amount)) effect = { type: type as any, amount };
|
||||
}
|
||||
else if (type === EffectType.REPLY_MESSAGE) {
|
||||
effect = { type: EffectType.REPLY_MESSAGE, message: interaction.fields.getTextInputValue("message") };
|
||||
}
|
||||
else if (type === EffectType.XP_BOOST) {
|
||||
const multiplier = parseFloat(interaction.fields.getTextInputValue("multiplier"));
|
||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
||||
if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: EffectType.XP_BOOST, multiplier, durationSeconds: duration };
|
||||
}
|
||||
else if (type === EffectType.TEMP_ROLE) {
|
||||
const roleId = interaction.fields.getTextInputValue("role_id");
|
||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
||||
if (roleId && !isNaN(duration)) effect = { type: EffectType.TEMP_ROLE, roleId: roleId, durationSeconds: duration };
|
||||
}
|
||||
else if (type === EffectType.COLOR_ROLE) {
|
||||
const roleId = interaction.fields.getTextInputValue("role_id");
|
||||
if (roleId) effect = { type: EffectType.COLOR_ROLE, roleId: roleId };
|
||||
}
|
||||
|
||||
if (effect) {
|
||||
draft.usageData.effects.push(effect);
|
||||
}
|
||||
draft.pendingEffectType = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-render
|
||||
const payload = renderWizard(userId);
|
||||
await interaction.deferUpdate();
|
||||
await interaction.editReply(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
// 7. Save
|
||||
if (interaction.customId === "createitem_save") {
|
||||
if (!interaction.isButton()) return;
|
||||
|
||||
await interaction.deferUpdate(); // Prepare to save
|
||||
|
||||
try {
|
||||
await DrizzleClient.insert(items).values({
|
||||
name: draft.name,
|
||||
description: draft.description,
|
||||
type: draft.type,
|
||||
rarity: draft.rarity,
|
||||
price: draft.price ? BigInt(draft.price) : null,
|
||||
iconUrl: draft.iconUrl,
|
||||
imageUrl: draft.imageUrl,
|
||||
usageData: draft.usageData
|
||||
});
|
||||
|
||||
draftSession.delete(userId);
|
||||
await interaction.editReply({ content: `✅ **${draft.name}** has been created successfully!`, embeds: [], components: [] });
|
||||
} catch (error: any) {
|
||||
console.error("Failed to create item:", error);
|
||||
// Restore state
|
||||
await interaction.followUp({ content: `❌ Failed to save item: ${error.message}`, ephemeral: true });
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export const clearDraftSessions = () => {
|
||||
draftSession.clear();
|
||||
console.log("[ItemWizard] All draft item creation sessions cleared.");
|
||||
};
|
||||
14
bot/modules/admin/item_wizard.types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ItemUsageData } from "@shared/lib/types";
|
||||
|
||||
export interface DraftItem {
|
||||
name: string;
|
||||
description: string;
|
||||
rarity: string;
|
||||
type: string;
|
||||
price: number | null;
|
||||
iconUrl: string;
|
||||
imageUrl: string;
|
||||
usageData: ItemUsageData;
|
||||
// Temporary state for effect adding flow
|
||||
pendingEffectType?: string;
|
||||
}
|
||||
135
bot/modules/admin/item_wizard.view.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
ModalBuilder,
|
||||
StringSelectMenuBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle,
|
||||
type MessageActionRowComponentBuilder
|
||||
} from "discord.js";
|
||||
import { createBaseEmbed } from "@lib/embeds";
|
||||
import type { DraftItem } from "./item_wizard.types";
|
||||
import { ItemType } from "@shared/lib/constants";
|
||||
|
||||
const getItemTypeOptions = () => [
|
||||
{ label: "Material", value: ItemType.MATERIAL, description: "Used for crafting or trading" },
|
||||
{ label: "Consumable", value: ItemType.CONSUMABLE, description: "Can be used to gain effects" },
|
||||
{ label: "Equipment", value: ItemType.EQUIPMENT, description: "Can be equipped (Not yet implemented)" },
|
||||
{ label: "Quest Item", value: ItemType.QUEST, description: "Required for quests" },
|
||||
];
|
||||
|
||||
const getEffectTypeOptions = () => [
|
||||
{ label: "Add XP", value: "ADD_XP", description: "Gives XP to the user" },
|
||||
{ label: "Add Balance", value: "ADD_BALANCE", description: "Gives currency to the user" },
|
||||
{ label: "Reply Message", value: "REPLY_MESSAGE", description: "Bot replies with a message" },
|
||||
{ label: "XP Boost", value: "XP_BOOST", description: "Temporarily boosts XP gain" },
|
||||
{ label: "Temp Role", value: "TEMP_ROLE", description: "Gives a temporary role" },
|
||||
{ label: "Color Role", value: "COLOR_ROLE", description: "Equips a permanent color role (swaps)" },
|
||||
];
|
||||
|
||||
export const getItemWizardEmbed = (draft: DraftItem) => {
|
||||
const embed = createBaseEmbed(`🛠️ Item Creator: ${draft.name}`, undefined, "Blue")
|
||||
.addFields(
|
||||
{ name: "General", value: `**Type:** ${draft.type}\n**Rarity:** ${draft.rarity}\n**Desc:** ${draft.description}`, inline: true },
|
||||
{ name: "Economy", value: `**Price:** ${draft.price ? `${draft.price} 🪙` : "Not for sale"}`, inline: true },
|
||||
{ name: "Visuals", value: `**Icon:** ${draft.iconUrl ? "✅ Set" : "❌"}\n**Image:** ${draft.imageUrl ? "✅ Set" : "❌"}`, inline: true },
|
||||
{ name: "Usage", value: `**Consume:** ${draft.usageData.consume ? "✅ Yes" : "❌ No"}`, inline: true },
|
||||
);
|
||||
|
||||
// Effects Display
|
||||
if (draft.usageData.effects.length > 0) {
|
||||
const effecto = draft.usageData.effects.map((e, i) => `${i + 1}. **${e.type}**: ${JSON.stringify(e)}`).join("\n");
|
||||
embed.addFields({ name: "Usage Effects", value: effecto.substring(0, 1024) });
|
||||
} else {
|
||||
embed.addFields({ name: "Usage Effects", value: "None" });
|
||||
}
|
||||
|
||||
if (draft.imageUrl) embed.setImage(draft.imageUrl);
|
||||
if (draft.iconUrl) embed.setThumbnail(draft.iconUrl);
|
||||
|
||||
// Components
|
||||
const row1 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder().setCustomId("createitem_details").setLabel("Edit Details").setStyle(ButtonStyle.Secondary).setEmoji("📝"),
|
||||
new ButtonBuilder().setCustomId("createitem_economy").setLabel("Edit Economy").setStyle(ButtonStyle.Secondary).setEmoji("💰"),
|
||||
new ButtonBuilder().setCustomId("createitem_visuals").setLabel("Edit Visuals").setStyle(ButtonStyle.Secondary).setEmoji("🖼️"),
|
||||
new ButtonBuilder().setCustomId("createitem_type_toggle").setLabel("Change Type").setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
|
||||
);
|
||||
|
||||
const row2 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder().setCustomId("createitem_addeffect_start").setLabel("Add Effect").setStyle(ButtonStyle.Primary).setEmoji("✨"),
|
||||
new ButtonBuilder().setCustomId("createitem_toggle_consume").setLabel(`Consume: ${draft.usageData.consume ? "ON" : "OFF"}`).setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
|
||||
new ButtonBuilder().setCustomId("createitem_save").setLabel("Save Item").setStyle(ButtonStyle.Success).setEmoji("💾"),
|
||||
new ButtonBuilder().setCustomId("createitem_cancel").setLabel("Cancel").setStyle(ButtonStyle.Danger).setEmoji("✖️")
|
||||
);
|
||||
|
||||
return { embeds: [embed], components: [row1, row2] };
|
||||
};
|
||||
|
||||
export const getItemTypeSelection = () => {
|
||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||
new StringSelectMenuBuilder().setCustomId("createitem_select_type").setPlaceholder("Select Item Type").addOptions(getItemTypeOptions())
|
||||
);
|
||||
return { components: [row] };
|
||||
};
|
||||
|
||||
export const getEffectTypeSelection = () => {
|
||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||
new StringSelectMenuBuilder().setCustomId("createitem_select_effect_type").setPlaceholder("Select Effect Type").addOptions(getEffectTypeOptions())
|
||||
);
|
||||
return { components: [row] };
|
||||
};
|
||||
|
||||
export const getDetailsModal = (current: DraftItem) => {
|
||||
const modal = new ModalBuilder().setCustomId("createitem_modal_details").setTitle("Edit Details");
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("name").setLabel("Name").setValue(current.name).setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("desc").setLabel("Description").setValue(current.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("Common, Rare, Legendary...").setRequired(true))
|
||||
);
|
||||
return modal;
|
||||
};
|
||||
|
||||
export const getEconomyModal = (current: DraftItem) => {
|
||||
const modal = new ModalBuilder().setCustomId("createitem_modal_economy").setTitle("Edit Economy");
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("price").setLabel("Price (0 for not for sale)").setValue(current.price?.toString() || "0").setStyle(TextInputStyle.Short).setRequired(true))
|
||||
);
|
||||
return modal;
|
||||
};
|
||||
|
||||
export const getVisualsModal = (current: DraftItem) => {
|
||||
const modal = new ModalBuilder().setCustomId("createitem_modal_visuals").setTitle("Edit Visuals");
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("icon").setLabel("Icon URL (Emoji or Link)").setValue(current.iconUrl).setStyle(TextInputStyle.Short).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("image").setLabel("Image URL").setValue(current.imageUrl).setStyle(TextInputStyle.Short).setRequired(false))
|
||||
);
|
||||
return modal;
|
||||
};
|
||||
|
||||
export const getEffectConfigModal = (effectType: string) => {
|
||||
let modal = new ModalBuilder().setCustomId("createitem_modal_effect").setTitle(`Config ${effectType}`);
|
||||
|
||||
if (effectType === "ADD_XP" || effectType === "ADD_BALANCE") {
|
||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("amount").setLabel("Amount").setStyle(TextInputStyle.Short).setRequired(true).setPlaceholder("100")));
|
||||
} else if (effectType === "REPLY_MESSAGE") {
|
||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("message").setLabel("Message").setStyle(TextInputStyle.Paragraph).setRequired(true)));
|
||||
} else if (effectType === "XP_BOOST") {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("multiplier").setLabel("Multiplier (e.g. 1.5)").setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||
);
|
||||
} else if (effectType === "TEMP_ROLE") {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||
);
|
||||
} else if (effectType === "COLOR_ROLE") {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true))
|
||||
);
|
||||
}
|
||||
return modal;
|
||||
};
|
||||
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} 🪙.` });
|
||||
}
|
||||
20
bot/modules/economy/shop.view.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
|
||||
import { createBaseEmbed } from "@/lib/embeds";
|
||||
|
||||
export function getShopListingMessage(item: { id: number; name: string; description: string | null; formattedPrice: string; iconUrl: string | null; imageUrl: string | null; price: number | bigint }) {
|
||||
const embed = createBaseEmbed(`Shop: ${item.name}`, item.description || "No description available.", "Green")
|
||||
.addFields({ name: "Price", value: item.formattedPrice, inline: true })
|
||||
.setThumbnail(item.iconUrl || null)
|
||||
.setImage(item.imageUrl || null)
|
||||
.setFooter({ text: "Click the button below to purchase instantly." });
|
||||
|
||||
const buyButton = new ButtonBuilder()
|
||||
.setCustomId(`shop_buy_${item.id}`)
|
||||
.setLabel(`Buy for ${item.price} 🪙`)
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setEmoji("🛒");
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton);
|
||||
|
||||
return { embeds: [embed], components: [row] };
|
||||
}
|
||||
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
|
||||
});
|
||||
}
|
||||
};
|
||||
23
bot/modules/feedback/feedback.types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export type FeedbackType = "FEATURE_REQUEST" | "BUG_REPORT" | "GENERAL";
|
||||
|
||||
export interface FeedbackData {
|
||||
type: FeedbackType;
|
||||
title: string;
|
||||
description: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export const FEEDBACK_TYPE_LABELS: Record<FeedbackType, string> = {
|
||||
FEATURE_REQUEST: "💡 Feature Request",
|
||||
BUG_REPORT: "🐛 Bug Report",
|
||||
GENERAL: "💬 General Feedback"
|
||||
};
|
||||
|
||||
export const FEEDBACK_CUSTOM_IDS = {
|
||||
MODAL: "feedback_modal",
|
||||
TYPE_FIELD: "feedback_type",
|
||||
TITLE_FIELD: "feedback_title",
|
||||
DESCRIPTION_FIELD: "feedback_description"
|
||||
} as const;
|
||||
123
bot/modules/feedback/feedback.view.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
ModalBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle,
|
||||
ActionRowBuilder,
|
||||
StringSelectMenuBuilder,
|
||||
ActionRowBuilder as MessageActionRowBuilder,
|
||||
ContainerBuilder,
|
||||
TextDisplayBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle
|
||||
} from "discord.js";
|
||||
import { FEEDBACK_TYPE_LABELS, FEEDBACK_CUSTOM_IDS, type FeedbackData, type FeedbackType } from "./feedback.types";
|
||||
|
||||
export function getFeedbackTypeMenu() {
|
||||
const select = new StringSelectMenuBuilder()
|
||||
.setCustomId("feedback_select_type")
|
||||
.setPlaceholder("Choose feedback type")
|
||||
.addOptions([
|
||||
{
|
||||
label: "💡 Feature Request",
|
||||
description: "Suggest a new feature or improvement",
|
||||
value: "FEATURE_REQUEST"
|
||||
},
|
||||
{
|
||||
label: "🐛 Bug Report",
|
||||
description: "Report a bug or issue",
|
||||
value: "BUG_REPORT"
|
||||
},
|
||||
{
|
||||
label: "💬 General Feedback",
|
||||
description: "Share your thoughts or suggestions",
|
||||
value: "GENERAL"
|
||||
}
|
||||
]);
|
||||
|
||||
const row = new MessageActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
|
||||
return { components: [row] };
|
||||
}
|
||||
|
||||
export function getFeedbackModal(feedbackType: FeedbackType) {
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId(`${FEEDBACK_CUSTOM_IDS.MODAL}_${feedbackType}`)
|
||||
.setTitle(FEEDBACK_TYPE_LABELS[feedbackType]);
|
||||
|
||||
// Title Input
|
||||
const titleInput = new TextInputBuilder()
|
||||
.setCustomId(FEEDBACK_CUSTOM_IDS.TITLE_FIELD)
|
||||
.setLabel("Title")
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setPlaceholder("Brief summary of your feedback")
|
||||
.setRequired(true)
|
||||
.setMaxLength(100);
|
||||
|
||||
const titleRow = new ActionRowBuilder<TextInputBuilder>().addComponents(titleInput);
|
||||
|
||||
// Description Input
|
||||
const descriptionInput = new TextInputBuilder()
|
||||
.setCustomId(FEEDBACK_CUSTOM_IDS.DESCRIPTION_FIELD)
|
||||
.setLabel("Description")
|
||||
.setStyle(TextInputStyle.Paragraph)
|
||||
.setPlaceholder("Provide detailed information about your feedback")
|
||||
.setRequired(true)
|
||||
.setMaxLength(1000);
|
||||
|
||||
const descriptionRow = new ActionRowBuilder<TextInputBuilder>().addComponents(descriptionInput);
|
||||
|
||||
modal.addComponents(titleRow, descriptionRow);
|
||||
|
||||
return modal;
|
||||
}
|
||||
|
||||
export function buildFeedbackMessage(feedback: FeedbackData) {
|
||||
// Define colors/themes for each feedback type
|
||||
const themes = {
|
||||
FEATURE_REQUEST: {
|
||||
icon: "💡",
|
||||
color: "Blue",
|
||||
title: "FEATURE REQUEST",
|
||||
description: "A new starlight suggestion has been received"
|
||||
},
|
||||
BUG_REPORT: {
|
||||
icon: "🐛",
|
||||
color: "Red",
|
||||
title: "BUG REPORT",
|
||||
description: "A cosmic anomaly has been detected"
|
||||
},
|
||||
GENERAL: {
|
||||
icon: "💬",
|
||||
color: "Gray",
|
||||
title: "GENERAL FEEDBACK",
|
||||
description: "A message from the cosmos"
|
||||
}
|
||||
};
|
||||
|
||||
const theme = themes[feedback.type];
|
||||
|
||||
if (!theme) {
|
||||
console.error(`Unknown feedback type: ${feedback.type}`);
|
||||
throw new Error(`Invalid feedback type: ${feedback.type}`);
|
||||
}
|
||||
|
||||
const timestamp = Math.floor(feedback.timestamp.getTime() / 1000);
|
||||
|
||||
// Header Container
|
||||
const headerContainer = new ContainerBuilder()
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`# ${theme.icon} ${theme.title}`),
|
||||
new TextDisplayBuilder().setContent(`*${theme.description}*`)
|
||||
);
|
||||
|
||||
// Content Container
|
||||
const contentContainer = new ContainerBuilder()
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`## ${feedback.title}`),
|
||||
new TextDisplayBuilder().setContent(`> ${feedback.description.split('\n').join('\n> ')}`),
|
||||
new TextDisplayBuilder().setContent(
|
||||
`**Submitted by:** <@${feedback.userId}>\n**Time:** <t:${timestamp}:F> (<t:${timestamp}:R>)`
|
||||
)
|
||||
);
|
||||
|
||||
return [headerContainer, contentContainer];
|
||||
}
|
||||
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.";
|
||||
};
|
||||
20
bot/modules/inventory/effects/registry.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
handleAddXp,
|
||||
handleAddBalance,
|
||||
handleReplyMessage,
|
||||
handleXpBoost,
|
||||
handleTempRole,
|
||||
handleColorRole,
|
||||
handleLootbox
|
||||
} from "./handlers";
|
||||
import type { EffectHandler } from "./types";
|
||||
|
||||
export const effectHandlers: Record<string, EffectHandler> = {
|
||||
'ADD_XP': handleAddXp,
|
||||
'ADD_BALANCE': handleAddBalance,
|
||||
'REPLY_MESSAGE': handleReplyMessage,
|
||||
'XP_BOOST': handleXpBoost,
|
||||
'TEMP_ROLE': handleTempRole,
|
||||
'COLOR_ROLE': handleColorRole,
|
||||
'LOOTBOX': handleLootbox
|
||||
};
|
||||
4
bot/modules/inventory/effects/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
import type { Transaction } from "@shared/lib/types";
|
||||
|
||||
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
|
||||
}
|
||||
46
bot/modules/moderation/moderation.types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { CaseType } from "@shared/lib/constants";
|
||||
|
||||
export { CaseType };
|
||||
|
||||
export interface CreateCaseOptions {
|
||||
type: CaseType;
|
||||
userId: string;
|
||||
username: string;
|
||||
moderatorId: string;
|
||||
moderatorName: string;
|
||||
reason: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ClearCaseOptions {
|
||||
caseId: string;
|
||||
clearedBy: string;
|
||||
clearedByName: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ModerationCase {
|
||||
id: bigint;
|
||||
caseId: string;
|
||||
type: string;
|
||||
userId: bigint;
|
||||
username: string;
|
||||
moderatorId: bigint;
|
||||
moderatorName: string;
|
||||
reason: string;
|
||||
metadata: unknown;
|
||||
active: boolean;
|
||||
createdAt: Date;
|
||||
resolvedAt: Date | null;
|
||||
resolvedBy: bigint | null;
|
||||
resolvedReason: string | null;
|
||||
}
|
||||
|
||||
export interface SearchCasesFilter {
|
||||
userId?: string;
|
||||
moderatorId?: string;
|
||||
type?: CaseType;
|
||||
active?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
241
bot/modules/moderation/moderation.view.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { EmbedBuilder, Colors, time, TimestampStyles } from "discord.js";
|
||||
import type { ModerationCase } from "./moderation.types";
|
||||
|
||||
/**
|
||||
* Get color based on case type
|
||||
*/
|
||||
function getCaseColor(type: string): number {
|
||||
switch (type) {
|
||||
case 'warn': return Colors.Yellow;
|
||||
case 'timeout': return Colors.Orange;
|
||||
case 'kick': return Colors.Red;
|
||||
case 'ban': return Colors.DarkRed;
|
||||
case 'note': return Colors.Blue;
|
||||
case 'prune': return Colors.Grey;
|
||||
default: return Colors.Grey;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get emoji based on case type
|
||||
*/
|
||||
function getCaseEmoji(type: string): string {
|
||||
switch (type) {
|
||||
case 'warn': return '⚠️';
|
||||
case 'timeout': return '🔇';
|
||||
case 'kick': return '👢';
|
||||
case 'ban': return '🔨';
|
||||
case 'note': return '📝';
|
||||
case 'prune': return '🧹';
|
||||
default: return '📋';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a single case
|
||||
*/
|
||||
export function getCaseEmbed(moderationCase: ModerationCase): EmbedBuilder {
|
||||
const emoji = getCaseEmoji(moderationCase.type);
|
||||
const color = getCaseColor(moderationCase.type);
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`${emoji} Case ${moderationCase.caseId}`)
|
||||
.setColor(color)
|
||||
.addFields(
|
||||
{ name: 'Type', value: moderationCase.type.toUpperCase(), inline: true },
|
||||
{ name: 'Status', value: moderationCase.active ? '🟢 Active' : '⚫ Resolved', inline: true },
|
||||
{ name: '\u200B', value: '\u200B', inline: true },
|
||||
{ name: 'User', value: `${moderationCase.username} (${moderationCase.userId})`, inline: false },
|
||||
{ name: 'Moderator', value: moderationCase.moderatorName, inline: true },
|
||||
{ name: 'Date', value: time(moderationCase.createdAt, TimestampStyles.ShortDateTime), inline: true }
|
||||
)
|
||||
.addFields({ name: 'Reason', value: moderationCase.reason })
|
||||
.setTimestamp(moderationCase.createdAt);
|
||||
|
||||
// Add resolution info if resolved
|
||||
if (!moderationCase.active && moderationCase.resolvedAt) {
|
||||
embed.addFields(
|
||||
{ name: '\u200B', value: '**Resolution**' },
|
||||
{ name: 'Resolved At', value: time(moderationCase.resolvedAt, TimestampStyles.ShortDateTime), inline: true }
|
||||
);
|
||||
|
||||
if (moderationCase.resolvedReason) {
|
||||
embed.addFields({ name: 'Resolution Reason', value: moderationCase.resolvedReason });
|
||||
}
|
||||
}
|
||||
|
||||
// Add metadata if present
|
||||
if (moderationCase.metadata && Object.keys(moderationCase.metadata).length > 0) {
|
||||
const metadataStr = JSON.stringify(moderationCase.metadata, null, 2);
|
||||
if (metadataStr.length < 1024) {
|
||||
embed.addFields({ name: 'Additional Info', value: `\`\`\`json\n${metadataStr}\n\`\`\`` });
|
||||
}
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a list of cases
|
||||
*/
|
||||
export function getCasesListEmbed(
|
||||
cases: ModerationCase[],
|
||||
title: string,
|
||||
description?: string
|
||||
): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(title)
|
||||
.setColor(Colors.Blue)
|
||||
.setTimestamp();
|
||||
|
||||
if (description) {
|
||||
embed.setDescription(description);
|
||||
}
|
||||
|
||||
if (cases.length === 0) {
|
||||
embed.setDescription('No cases found.');
|
||||
return embed;
|
||||
}
|
||||
|
||||
// Group by type for better display
|
||||
const casesByType: Record<string, ModerationCase[]> = {};
|
||||
for (const c of cases) {
|
||||
if (!casesByType[c.type]) {
|
||||
casesByType[c.type] = [];
|
||||
}
|
||||
casesByType[c.type]!.push(c);
|
||||
}
|
||||
|
||||
// Add fields for each type
|
||||
for (const [type, typeCases] of Object.entries(casesByType)) {
|
||||
const emoji = getCaseEmoji(type);
|
||||
const caseList = typeCases.slice(0, 5).map(c => {
|
||||
const status = c.active ? '🟢' : '⚫';
|
||||
const date = time(c.createdAt, TimestampStyles.ShortDate);
|
||||
return `${status} **${c.caseId}** - ${c.reason.substring(0, 50)}${c.reason.length > 50 ? '...' : ''} (${date})`;
|
||||
}).join('\n');
|
||||
|
||||
embed.addFields({
|
||||
name: `${emoji} ${type.toUpperCase()} (${typeCases.length})`,
|
||||
value: caseList || 'None',
|
||||
inline: false
|
||||
});
|
||||
|
||||
if (typeCases.length > 5) {
|
||||
embed.addFields({
|
||||
name: '\u200B',
|
||||
value: `_...and ${typeCases.length - 5} more_`,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display user's active warnings
|
||||
*/
|
||||
export function getWarningsEmbed(warnings: ModerationCase[], username: string): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`⚠️ Active Warnings for ${username}`)
|
||||
.setColor(Colors.Yellow)
|
||||
.setTimestamp();
|
||||
|
||||
if (warnings.length === 0) {
|
||||
embed.setDescription('No active warnings.');
|
||||
return embed;
|
||||
}
|
||||
|
||||
embed.setDescription(`**Total Active Warnings:** ${warnings.length}`);
|
||||
|
||||
for (const warning of warnings.slice(0, 10)) {
|
||||
const date = time(warning.createdAt, TimestampStyles.ShortDateTime);
|
||||
embed.addFields({
|
||||
name: `${warning.caseId} - ${date}`,
|
||||
value: `**Moderator:** ${warning.moderatorName}\n**Reason:** ${warning.reason}`,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
if (warnings.length > 10) {
|
||||
embed.addFields({
|
||||
name: '\u200B',
|
||||
value: `_...and ${warnings.length - 10} more warnings. Use \`/cases\` to view all._`,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Success message after warning a user
|
||||
*/
|
||||
export function getWarnSuccessEmbed(caseId: string, username: string, reason: string): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle('✅ Warning Issued')
|
||||
.setDescription(`**${username}** has been warned.`)
|
||||
.addFields(
|
||||
{ name: 'Case ID', value: caseId, inline: true },
|
||||
{ name: 'Reason', value: reason, inline: false }
|
||||
)
|
||||
.setColor(Colors.Green)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Success message after adding a note
|
||||
*/
|
||||
export function getNoteSuccessEmbed(caseId: string, username: string): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle('✅ Note Added')
|
||||
.setDescription(`Staff note added for **${username}**.`)
|
||||
.addFields({ name: 'Case ID', value: caseId, inline: true })
|
||||
.setColor(Colors.Green)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Success message after clearing a warning
|
||||
*/
|
||||
export function getClearSuccessEmbed(caseId: string): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle('✅ Warning Cleared')
|
||||
.setDescription(`Case **${caseId}** has been resolved.`)
|
||||
.setColor(Colors.Green)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Error embed for moderation operations
|
||||
*/
|
||||
export function getModerationErrorEmbed(message: string): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle('❌ Error')
|
||||
.setDescription(message)
|
||||
.setColor(Colors.Red)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Warning embed to send to user via DM
|
||||
*/
|
||||
export function getUserWarningEmbed(
|
||||
serverName: string,
|
||||
reason: string,
|
||||
caseId: string,
|
||||
warningCount: number
|
||||
): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle('⚠️ You have received a warning')
|
||||
.setDescription(`You have been warned in **${serverName}**.`)
|
||||
.addFields(
|
||||
{ name: 'Reason', value: reason, inline: false },
|
||||
{ name: 'Case ID', value: caseId, inline: true },
|
||||
{ name: 'Total Warnings', value: warningCount.toString(), inline: true }
|
||||
)
|
||||
.setColor(Colors.Yellow)
|
||||
.setTimestamp()
|
||||
.setFooter({ text: 'Please review the server rules to avoid further action.' });
|
||||
}
|
||||
18
bot/modules/moderation/prune.types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface PruneOptions {
|
||||
amount?: number;
|
||||
userId?: string;
|
||||
all?: boolean;
|
||||
}
|
||||
|
||||
export interface PruneResult {
|
||||
deletedCount: number;
|
||||
requestedCount: number;
|
||||
filtered: boolean;
|
||||
username?: string;
|
||||
skippedOld?: number;
|
||||
}
|
||||
|
||||
export interface PruneProgress {
|
||||
current: number;
|
||||
total: number;
|
||||
}
|
||||
115
bot/modules/moderation/prune.view.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, Colors } from "discord.js";
|
||||
import type { PruneResult, PruneProgress } from "./prune.types";
|
||||
|
||||
/**
|
||||
* Creates a confirmation message for prune operations
|
||||
*/
|
||||
export function getConfirmationMessage(
|
||||
amount: number | 'all',
|
||||
estimatedCount?: number
|
||||
): { embeds: EmbedBuilder[], components: ActionRowBuilder<ButtonBuilder>[] } {
|
||||
const isAll = amount === 'all';
|
||||
const messageCount = isAll ? estimatedCount : amount;
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("⚠️ Confirm Deletion")
|
||||
.setDescription(
|
||||
isAll
|
||||
? `You are about to delete **ALL messages** in this channel.\n\n` +
|
||||
`Estimated messages: **~${estimatedCount || 'Unknown'}**\n` +
|
||||
`This action **cannot be undone**.`
|
||||
: `You are about to delete **${amount} messages**.\n\n` +
|
||||
`This action **cannot be undone**.`
|
||||
)
|
||||
.setColor(Colors.Orange)
|
||||
.setTimestamp();
|
||||
|
||||
const confirmButton = new ButtonBuilder()
|
||||
.setCustomId("confirm_prune")
|
||||
.setLabel("Confirm")
|
||||
.setStyle(ButtonStyle.Danger);
|
||||
|
||||
const cancelButton = new ButtonBuilder()
|
||||
.setCustomId("cancel_prune")
|
||||
.setLabel("Cancel")
|
||||
.setStyle(ButtonStyle.Secondary);
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>()
|
||||
.addComponents(confirmButton, cancelButton);
|
||||
|
||||
return { embeds: [embed], components: [row] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a progress embed for ongoing deletions
|
||||
*/
|
||||
export function getProgressEmbed(progress: PruneProgress): EmbedBuilder {
|
||||
const percentage = Math.round((progress.current / progress.total) * 100);
|
||||
|
||||
return new EmbedBuilder()
|
||||
.setTitle("🔄 Deleting Messages")
|
||||
.setDescription(
|
||||
`Progress: **${progress.current}/${progress.total}** (${percentage}%)\n\n` +
|
||||
`Please wait...`
|
||||
)
|
||||
.setColor(Colors.Blue)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a success embed after deletion
|
||||
*/
|
||||
export function getSuccessEmbed(result: PruneResult): EmbedBuilder {
|
||||
let description = `Successfully deleted **${result.deletedCount} messages**.`;
|
||||
|
||||
if (result.filtered && result.username) {
|
||||
description = `Successfully deleted **${result.deletedCount} messages** from **${result.username}**.`;
|
||||
}
|
||||
|
||||
if (result.skippedOld && result.skippedOld > 0) {
|
||||
description += `\n\n⚠️ **${result.skippedOld} messages** were older than 14 days and could not be deleted.`;
|
||||
}
|
||||
|
||||
if (result.deletedCount < result.requestedCount && !result.skippedOld) {
|
||||
description += `\n\nℹ️ Only **${result.deletedCount}** messages were available to delete.`;
|
||||
}
|
||||
|
||||
return new EmbedBuilder()
|
||||
.setTitle("✅ Messages Deleted")
|
||||
.setDescription(description)
|
||||
.setColor(Colors.Green)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an error embed
|
||||
*/
|
||||
export function getPruneErrorEmbed(message: string): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle("❌ Prune Failed")
|
||||
.setDescription(message)
|
||||
.setColor(Colors.Red)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a warning embed
|
||||
*/
|
||||
export function getPruneWarningEmbed(message: string): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle("⚠️ Warning")
|
||||
.setDescription(message)
|
||||
.setColor(Colors.Yellow)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a cancelled embed
|
||||
*/
|
||||
export function getCancelledEmbed(): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle("🚫 Deletion Cancelled")
|
||||
.setDescription("Message deletion has been cancelled.")
|
||||
.setColor(Colors.Grey)
|
||||
.setTimestamp();
|
||||
}
|
||||
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();
|
||||
}
|
||||
};
|
||||
@@ -1,24 +1,19 @@
|
||||
import {
|
||||
type Interaction,
|
||||
ButtonInteraction,
|
||||
ModalSubmitInteraction,
|
||||
StringSelectMenuInteraction,
|
||||
type Interaction,
|
||||
EmbedBuilder,
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
StringSelectMenuBuilder,
|
||||
ModalBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle,
|
||||
ThreadChannel,
|
||||
TextChannel,
|
||||
EmbedBuilder
|
||||
} from "discord.js";
|
||||
import { TradeService } from "./trade.service";
|
||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||
import { tradeService } from "@shared/modules/trade/trade.service";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@lib/errors";
|
||||
import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view";
|
||||
|
||||
|
||||
const EMBED_COLOR = 0xFFD700; // Gold
|
||||
|
||||
export async function handleTradeInteraction(interaction: Interaction) {
|
||||
if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return;
|
||||
@@ -28,43 +23,34 @@ export async function handleTradeInteraction(interaction: Interaction) {
|
||||
|
||||
if (!threadId) return;
|
||||
|
||||
try {
|
||||
if (customId === 'trade_cancel') {
|
||||
await handleCancel(interaction, threadId);
|
||||
} else if (customId === 'trade_lock') {
|
||||
await handleLock(interaction, threadId);
|
||||
} else if (customId === 'trade_confirm') {
|
||||
// Confirm logic is handled implicitly by both locking or explicitly if needed.
|
||||
// For now, locking both triggers execution, so no separate confirm handler is actively used
|
||||
// unless we re-introduce a specific button. keeping basic handler stub if needed.
|
||||
} else if (customId === 'trade_add_money') {
|
||||
await handleAddMoneyClick(interaction);
|
||||
} else if (customId === 'trade_money_modal') {
|
||||
await handleMoneySubmit(interaction as ModalSubmitInteraction, threadId);
|
||||
} else if (customId === 'trade_add_item') {
|
||||
await handleAddItemClick(interaction as ButtonInteraction, threadId);
|
||||
} else if (customId === 'trade_select_item') {
|
||||
await handleItemSelect(interaction as StringSelectMenuInteraction, threadId);
|
||||
} else if (customId === 'trade_remove_item') {
|
||||
await handleRemoveItemClick(interaction as ButtonInteraction, threadId);
|
||||
} else if (customId === 'trade_remove_item_select') {
|
||||
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 });
|
||||
}
|
||||
if (customId === 'trade_cancel') {
|
||||
await handleCancel(interaction, threadId);
|
||||
} else if (customId === 'trade_lock') {
|
||||
await handleLock(interaction, threadId);
|
||||
} else if (customId === 'trade_confirm') {
|
||||
// Confirm logic is handled implicitly by both locking or explicitly if needed.
|
||||
// For now, locking both triggers execution, so no separate confirm handler is actively used
|
||||
// unless we re-introduce a specific button. keeping basic handler stub if needed.
|
||||
} else if (customId === 'trade_add_money') {
|
||||
await handleAddMoneyClick(interaction);
|
||||
} else if (customId === 'trade_money_modal') {
|
||||
await handleMoneySubmit(interaction as ModalSubmitInteraction, threadId);
|
||||
} else if (customId === 'trade_add_item') {
|
||||
await handleAddItemClick(interaction as ButtonInteraction, threadId);
|
||||
} else if (customId === 'trade_select_item') {
|
||||
await handleItemSelect(interaction as StringSelectMenuInteraction, threadId);
|
||||
} else if (customId === 'trade_remove_item') {
|
||||
await handleRemoveItemClick(interaction as ButtonInteraction, threadId);
|
||||
} else if (customId === 'trade_remove_item_select') {
|
||||
await handleRemoveItemSelect(interaction as StringSelectMenuInteraction, threadId);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
|
||||
const session = TradeService.getSession(threadId);
|
||||
const session = tradeService.getSession(threadId);
|
||||
const user = interaction.user;
|
||||
|
||||
TradeService.endSession(threadId);
|
||||
tradeService.endSession(threadId);
|
||||
|
||||
await interaction.deferUpdate();
|
||||
|
||||
@@ -76,11 +62,11 @@ async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInt
|
||||
|
||||
async function handleLock(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
|
||||
await interaction.deferUpdate();
|
||||
const isLocked = TradeService.toggleLock(threadId, interaction.user.id);
|
||||
const isLocked = tradeService.toggleLock(threadId, interaction.user.id);
|
||||
await updateTradeDashboard(interaction, threadId);
|
||||
|
||||
// Check if trade executed (both locked)
|
||||
const session = TradeService.getSession(threadId);
|
||||
const session = tradeService.getSession(threadId);
|
||||
if (session && session.state === 'COMPLETED') {
|
||||
// Trade executed during updateTradeDashboard
|
||||
return;
|
||||
@@ -91,20 +77,7 @@ async function handleLock(interaction: ButtonInteraction | StringSelectMenuInter
|
||||
|
||||
async function handleAddMoneyClick(interaction: Interaction) {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId('trade_money_modal')
|
||||
.setTitle('Add Money');
|
||||
|
||||
const input = new TextInputBuilder()
|
||||
.setCustomId('amount')
|
||||
.setLabel("Amount to trade")
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setPlaceholder("100")
|
||||
.setRequired(true);
|
||||
|
||||
const row = new ActionRowBuilder<TextInputBuilder>().addComponents(input);
|
||||
modal.addComponents(row);
|
||||
|
||||
const modal = getTradeMoneyModal();
|
||||
await interaction.showModal(modal);
|
||||
}
|
||||
|
||||
@@ -112,9 +85,9 @@ async function handleMoneySubmit(interaction: ModalSubmitInteraction, threadId:
|
||||
const amountStr = interaction.fields.getTextInputValue('amount');
|
||||
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 updateTradeDashboard(interaction, threadId);
|
||||
}
|
||||
@@ -128,20 +101,14 @@ async function handleAddItemClick(interaction: ButtonInteraction, threadId: stri
|
||||
}
|
||||
|
||||
// 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})`,
|
||||
value: entry.item.id.toString(),
|
||||
description: `Rarity: ${entry.item.rarity}`
|
||||
description: `Rarity: ${entry.item.rarity} `
|
||||
}));
|
||||
|
||||
const select = new StringSelectMenuBuilder()
|
||||
.setCustomId('trade_select_item')
|
||||
.setPlaceholder('Select an item to add')
|
||||
.addOptions(options);
|
||||
|
||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
|
||||
|
||||
await interaction.reply({ content: "Select an item to add:", components: [row], ephemeral: true });
|
||||
const { components } = getItemSelectMenu(options, 'trade_select_item', 'Select an item to add');
|
||||
await interaction.reply({ content: "Select an item to add:", components, ephemeral: true });
|
||||
}
|
||||
|
||||
async function handleItemSelect(interaction: StringSelectMenuInteraction, threadId: string) {
|
||||
@@ -151,16 +118,16 @@ async function handleItemSelect(interaction: StringSelectMenuInteraction, thread
|
||||
|
||||
// Assuming implementation implies adding 1 item for now
|
||||
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 updateTradeDashboard(interaction, threadId);
|
||||
}
|
||||
|
||||
async function handleRemoveItemClick(interaction: ButtonInteraction, threadId: string) {
|
||||
const session = TradeService.getSession(threadId);
|
||||
const session = tradeService.getSession(threadId);
|
||||
if (!session) return;
|
||||
|
||||
const participant = session.userA.id === interaction.user.id ? session.userA : session.userB;
|
||||
@@ -175,21 +142,15 @@ async function handleRemoveItemClick(interaction: ButtonInteraction, threadId: s
|
||||
value: i.id.toString(),
|
||||
}));
|
||||
|
||||
const select = new StringSelectMenuBuilder()
|
||||
.setCustomId('trade_remove_item_select')
|
||||
.setPlaceholder('Select an item to remove')
|
||||
.addOptions(options);
|
||||
|
||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
|
||||
|
||||
await interaction.reply({ content: "Select an item to remove:", components: [row], ephemeral: true });
|
||||
const { components } = getItemSelectMenu(options, 'trade_remove_item_select', 'Select an item to remove');
|
||||
await interaction.reply({ content: "Select an item to remove:", components, ephemeral: true });
|
||||
}
|
||||
|
||||
async function handleRemoveItemSelect(interaction: StringSelectMenuInteraction, threadId: string) {
|
||||
const value = interaction.values[0];
|
||||
if (!value) return;
|
||||
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 updateTradeDashboard(interaction, threadId);
|
||||
@@ -199,23 +160,15 @@ async function handleRemoveItemSelect(interaction: StringSelectMenuInteraction,
|
||||
// --- DASHBOARD UPDATER ---
|
||||
|
||||
export async function updateTradeDashboard(interaction: Interaction, threadId: string) {
|
||||
const session = TradeService.getSession(threadId);
|
||||
const session = tradeService.getSession(threadId);
|
||||
if (!session) return;
|
||||
|
||||
// Check Auto-Execute (If both locked)
|
||||
if (session.userA.locked && session.userB.locked) {
|
||||
// Execute Trade
|
||||
try {
|
||||
await TradeService.executeTrade(threadId);
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("✅ Trade Completed")
|
||||
.setColor("Green")
|
||||
.addFields(
|
||||
{ name: session.userA.username, value: formatOffer(session.userA), inline: true },
|
||||
{ name: session.userB.username, value: formatOffer(session.userB), inline: true }
|
||||
)
|
||||
.setTimestamp();
|
||||
|
||||
await tradeService.executeTrade(threadId);
|
||||
const embed = getTradeCompletedEmbed(session);
|
||||
await updateDashboardMessage(interaction, { embeds: [embed], components: [] });
|
||||
|
||||
// Notify and Schedule Cleanup
|
||||
@@ -223,7 +176,7 @@ export async function updateTradeDashboard(interaction: Interaction, threadId: s
|
||||
const successEmbed = createSuccessEmbed("Trade executed successfully. Items and funds have been transferred.", "Trade Complete");
|
||||
await scheduleThreadCleanup(
|
||||
interaction.channel,
|
||||
`🎉 Trade successful! <@${session.userA.id}> <@${session.userB.id}>\nThis thread will be deleted in 10 seconds.`,
|
||||
`🎉 Trade successful! < @${session.userA.id}> <@${session.userB.id}>\nThis thread will be deleted in 10 seconds.`,
|
||||
10000,
|
||||
successEmbed
|
||||
);
|
||||
@@ -246,33 +199,8 @@ export async function updateTradeDashboard(interaction: Interaction, threadId: s
|
||||
}
|
||||
|
||||
// Build Status Embed
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("🤝 Trading Session")
|
||||
.setColor(EMBED_COLOR)
|
||||
.addFields(
|
||||
{
|
||||
name: `${session.userA.username} ${session.userA.locked ? '✅ (Ready)' : '✏️ (Editing)'}`,
|
||||
value: formatOffer(session.userA),
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: `${session.userB.username} ${session.userB.locked ? '✅ (Ready)' : '✏️ (Editing)'}`,
|
||||
value: formatOffer(session.userB),
|
||||
inline: true
|
||||
}
|
||||
)
|
||||
.setFooter({ text: "Both parties must click Lock to confirm trade." });
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder().setCustomId('trade_add_item').setLabel('Add Item').setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId('trade_add_money').setLabel('Add Money').setStyle(ButtonStyle.Success),
|
||||
new ButtonBuilder().setCustomId('trade_remove_item').setLabel('Remove Item').setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId('trade_lock').setLabel('Lock / Unlock').setStyle(ButtonStyle.Primary),
|
||||
new ButtonBuilder().setCustomId('trade_cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger),
|
||||
);
|
||||
|
||||
await updateDashboardMessage(interaction, { embeds: [embed], components: [row] });
|
||||
const { embeds, components } = getTradeDashboard(session);
|
||||
await updateDashboardMessage(interaction, { embeds, components });
|
||||
}
|
||||
|
||||
async function updateDashboardMessage(interaction: Interaction, payload: any) {
|
||||
@@ -300,17 +228,7 @@ async function updateDashboardMessage(interaction: Interaction, payload: any) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatOffer(participant: any) {
|
||||
let text = "";
|
||||
if (participant.offer.money > 0n) {
|
||||
text += `💰 ${participant.offer.money} 🪙\n`;
|
||||
}
|
||||
if (participant.offer.items.length > 0) {
|
||||
text += participant.offer.items.map((i: any) => `- ${i.name} (x${i.quantity})`).join("\n");
|
||||
}
|
||||
if (text === "") text = "*Empty Offer*";
|
||||
return text;
|
||||
}
|
||||
|
||||
|
||||
async function scheduleThreadCleanup(channel: ThreadChannel | TextChannel, message: string, delayMs: number = 10000, embed?: EmbedBuilder) {
|
||||
try {
|
||||
@@ -322,7 +240,7 @@ async function scheduleThreadCleanup(channel: ThreadChannel | TextChannel, messa
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
if (channel.isThread()) {
|
||||
console.log(`Deleting thread: ${channel.id}`);
|
||||
console.log(`Deleting thread: ${channel.id} `);
|
||||
await channel.delete("Trade Session Ended");
|
||||
}
|
||||
} catch (e) {
|
||||