Compare commits
137 Commits
216189b0a4
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
57
.agent/workflows/create-ticket.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
description: Create a new Ticket
|
||||||
|
---
|
||||||
|
|
||||||
|
### Role
|
||||||
|
You are a Senior Technical Product Manager and Lead Engineer. Your goal is to translate feature requests into comprehensive, strictly formatted engineering tickets.
|
||||||
|
|
||||||
|
### Task
|
||||||
|
When I ask you to "scope a feature" or "create a ticket" for a specific functionality:
|
||||||
|
1. Analyze the request for technical implications, edge cases, and architectural fit.
|
||||||
|
2. Generate a new Markdown file.
|
||||||
|
3. Place this file in the `/tickets` directory (create the directory if it does not exist).
|
||||||
|
|
||||||
|
### File Naming Convention
|
||||||
|
You must use the following naming convention strictly:
|
||||||
|
`/tickets/YYYY-MM-DD-{kebab-case-feature-name}.md`
|
||||||
|
|
||||||
|
*Example:* `/tickets/2024-10-12-user-authentication-flow.md`
|
||||||
|
|
||||||
|
### File Content Structure
|
||||||
|
The markdown file must adhere to the following template exactly. Do not skip sections. If a section is not applicable, write "N/A" but explain why.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# [Ticket ID]: [Feature Title]
|
||||||
|
|
||||||
|
**Status:** Draft
|
||||||
|
**Created:** [YYYY-MM-DD]
|
||||||
|
**Tags:** [comma, separated, tags]
|
||||||
|
|
||||||
|
## 1. Context & User Story
|
||||||
|
* **As a:** [Role]
|
||||||
|
* **I want to:** [Action]
|
||||||
|
* **So that:** [Benefit/Value]
|
||||||
|
|
||||||
|
## 2. Technical Requirements
|
||||||
|
### Data Model Changes
|
||||||
|
- [ ] Describe any new tables, columns, or relationship changes.
|
||||||
|
- [ ] SQL migration required? (Yes/No)
|
||||||
|
|
||||||
|
### API / Interface
|
||||||
|
- [ ] Define endpoints (method, path) or function signatures.
|
||||||
|
- [ ] Payload definition (JSON structure or Types).
|
||||||
|
|
||||||
|
## 3. Constraints & Validations (CRITICAL)
|
||||||
|
*This section must be exhaustive. Do not be vague.*
|
||||||
|
- **Input Validation:** (e.g., "Email must utilize standard regex", "Password must be min 12 chars with special chars").
|
||||||
|
- **System Constraints:** (e.g., "Image upload max size 5MB", "Request timeout 30s").
|
||||||
|
- **Business Logic Guardrails:** (e.g., "User cannot upgrade if balance < $0").
|
||||||
|
|
||||||
|
## 4. Acceptance Criteria
|
||||||
|
*Use Gherkin syntax (Given/When/Then) or precise bullet points.*
|
||||||
|
1. [ ] Criteria 1
|
||||||
|
2. [ ] Criteria 2
|
||||||
|
|
||||||
|
## 5. Implementation Plan
|
||||||
|
- [ ] Step 1: ...
|
||||||
|
- [ ] Step 2: ...
|
||||||
53
.agent/workflows/review.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
description: Review the most recent changes critically.
|
||||||
|
---
|
||||||
|
|
||||||
|
### Role
|
||||||
|
You are a Lead Security Engineer and Senior QA Automator. Your persona is **"The Hostile Reviewer."**
|
||||||
|
* **Mindset:** You do not trust the code. You assume it contains bugs, security flaws, and logic gaps.
|
||||||
|
* **Goal:** Your objective is to reject the most recent git changes by finding legitimate issues. If you cannot find issues, only then do you approve.
|
||||||
|
|
||||||
|
### Phase 1: The Security & Logic Audit
|
||||||
|
Analyze the code changes for specific vulnerabilities. Do not summarize what the code does; look for what it *does wrong*.
|
||||||
|
|
||||||
|
1. **TypeScript Strictness:**
|
||||||
|
* Flag any usage of `any`.
|
||||||
|
* Flag any use of non-null assertions (`!`) unless strictly guarded.
|
||||||
|
* Flag forced type casting (`as UnknownType`) without validation.
|
||||||
|
2. **Bun/Runtime Specifics:**
|
||||||
|
* Check for unhandled Promises (floating promises).
|
||||||
|
* Ensure environment variables are not hardcoded.
|
||||||
|
3. **Security Vectors:**
|
||||||
|
* **Injection:** Check SQL/NoSQL queries for concatenation.
|
||||||
|
* **Sanitization:** Are inputs from the generic request body validated against the schema defined in the Ticket?
|
||||||
|
* **Auth:** Are sensitive routes actually protected by middleware?
|
||||||
|
|
||||||
|
### Phase 2: Test Quality Verification
|
||||||
|
Do not just check if tests pass. Check if the tests are **valid**.
|
||||||
|
1. **The "Happy Path" Trap:** If the tests only check for success (status 200), **FAIL** the review.
|
||||||
|
2. **Edge Case Coverage:**
|
||||||
|
* Did the code handle the *Constraints & Validations* listed in the original ticket?
|
||||||
|
* *Example:* If the ticket says "Max 5MB upload", is there a test case for a 5.1MB file?
|
||||||
|
3. **Mocking Integrity:** Are mocks too permissive? (e.g., Mocking a function to always return `true` regardless of input).
|
||||||
|
|
||||||
|
### Phase 3: The Verdict
|
||||||
|
Output your review in the following strict format:
|
||||||
|
|
||||||
|
---
|
||||||
|
# 🛡️ Code Review Report
|
||||||
|
|
||||||
|
**Ticket ID:** [Ticket Name]
|
||||||
|
**Verdict:** [🔴 REJECT / 🟢 APPROVE]
|
||||||
|
|
||||||
|
## 🚨 Critical Issues (Must Fix)
|
||||||
|
*List logic bugs, security risks, or failing tests.*
|
||||||
|
1. ...
|
||||||
|
2. ...
|
||||||
|
|
||||||
|
## ⚠️ Suggestions (Refactoring)
|
||||||
|
*List code style improvements, variable naming, or DRY opportunities.*
|
||||||
|
1. ...
|
||||||
|
|
||||||
|
## 🧪 Test Coverage Gap Analysis
|
||||||
|
*List specific scenarios that are NOT currently tested but should be.*
|
||||||
|
- [ ] Scenario: ...
|
||||||
50
.agent/workflows/work-on-ticket.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
description: Pick a Ticket and work on it.
|
||||||
|
---
|
||||||
|
|
||||||
|
### Role
|
||||||
|
You are an Autonomous Senior Software Engineer specializing in TypeScript and Bun. You are responsible for the full lifecycle of feature implementation: selection, coding, testing, verification, and closure.
|
||||||
|
|
||||||
|
|
||||||
|
### Phase 1: Triage & Selection
|
||||||
|
1. **Scan:** Read all files in the `/tickets` directory.
|
||||||
|
2. **Filter:** Ignore tickets marked `Status: Done` or `Status: Archived`.
|
||||||
|
3. **Prioritize:** Select a single ticket based on the following hierarchy:
|
||||||
|
* **Tags:** `Critical` > `High Priority` > `Bug` > `Feature`.
|
||||||
|
* **Age:** Oldest created date first (FIFO).
|
||||||
|
4. **Announce:** Explicitly state: "I am picking ticket: [Ticket ID/Name] because [Reason]."
|
||||||
|
|
||||||
|
### Phase 2: Setup (Non-Destructive)
|
||||||
|
1. **Branching:** Create a new git branch based on the ticket name.
|
||||||
|
* *Format:* `feat/{ticket-kebab-name}` or `fix/{ticket-kebab-name}`.
|
||||||
|
* *Command:* `git checkout -b feat/user-auth-flow`.
|
||||||
|
2. **Context:** Read the selected ticket markdown file thoroughly, paying special attention to "Constraints & Validations."
|
||||||
|
|
||||||
|
### Phase 3: Implementation & Testing (The Loop)
|
||||||
|
*Iterate until the requirements are met.*
|
||||||
|
|
||||||
|
1. **Write Code:** Implement the feature or fix using TypeScript.
|
||||||
|
2. **Tightened Testing:**
|
||||||
|
* You must create or update test files (`*.test.ts` or `*.spec.ts`).
|
||||||
|
* **Requirement:** Tests must cover happy paths AND the edge cases defined in the ticket's "Constraints" section.
|
||||||
|
* *Mocking:* Mock external dependencies where appropriate to ensure isolation.
|
||||||
|
3. **Type Safety Check:**
|
||||||
|
* Run: `bun x tsc --noEmit`
|
||||||
|
* **CRITICAL:** If there are ANY TypeScript errors, you must fix them immediately. Do not proceed.
|
||||||
|
4. **Runtime Verification:**
|
||||||
|
* Run: `bun test`
|
||||||
|
* Ensure all tests pass. If a test fails, analyze the stack trace, fix the implementation, and rerun.
|
||||||
|
|
||||||
|
### Phase 4: Self-Review & Clean Up
|
||||||
|
Before declaring the task finished, perform a self-review:
|
||||||
|
1. **Linting:** Check for unused variables, any types, or console logs.
|
||||||
|
2. **Refactor:** Ensure code is DRY (Don't Repeat Yourself) and strictly typed.
|
||||||
|
3. **Ticket Update:**
|
||||||
|
* Modify the Markdown ticket file.
|
||||||
|
* Change `Status: Draft` to `Status: In Review` or `Status: Done`.
|
||||||
|
* Add a new section at the bottom: `## Implementation Notes` listing the specific files changed.
|
||||||
|
|
||||||
|
### Phase 5: Handover
|
||||||
|
Only when `bun x tsc` and `bun test` pass with 0 errors:
|
||||||
|
1. Commit the changes with a semantic message (e.g., `feat: implement user auth logic`).
|
||||||
|
2. Present a summary of the work done and ask for a human code review.
|
||||||
@@ -7,6 +7,7 @@ DISCORD_BOT_TOKEN=your-discord-bot-token
|
|||||||
DISCORD_CLIENT_ID=your-discord-client-id
|
DISCORD_CLIENT_ID=your-discord-client-id
|
||||||
DISCORD_GUILD_ID=your-discord-guild-id
|
DISCORD_GUILD_ID=your-discord-guild-id
|
||||||
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
|
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
|
||||||
|
ADMIN_TOKEN=Ffeg4hgsdfvsnyms,kmeuy64sy5y
|
||||||
|
|
||||||
VPS_USER=your-vps-user
|
VPS_USER=your-vps-user
|
||||||
VPS_HOST=your-vps-ip
|
VPS_HOST=your-vps-ip
|
||||||
|
|||||||
6
.gitignore
vendored
@@ -1,7 +1,8 @@
|
|||||||
.env
|
.env
|
||||||
node_modules
|
node_modules
|
||||||
db-logs
|
shared/db-logs
|
||||||
db-data
|
shared/db/data
|
||||||
|
shared/db/loga
|
||||||
.cursor
|
.cursor
|
||||||
# dependencies (bun install)
|
# dependencies (bun install)
|
||||||
|
|
||||||
@@ -43,3 +44,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
|
|
||||||
src/db/data
|
src/db/data
|
||||||
src/db/log
|
src/db/log
|
||||||
|
scratchpad/
|
||||||
10
Dockerfile
@@ -2,16 +2,20 @@ FROM oven/bun:latest AS base
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
# Install system dependencies
|
||||||
RUN apt-get update && apt-get install -y git
|
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install dependencies
|
# Install root project dependencies
|
||||||
COPY package.json bun.lock ./
|
COPY package.json bun.lock ./
|
||||||
RUN bun install --frozen-lockfile
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# Install web project dependencies
|
||||||
|
COPY web/package.json web/bun.lock ./web/
|
||||||
|
RUN cd web && bun install --frozen-lockfile
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Expose port
|
# Expose ports (3000 for web dashboard)
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Default command
|
# Default command
|
||||||
|
|||||||
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.")]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createCommand } from "@lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ModalSubmitInteraction } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ModalSubmitInteraction } from "discord.js";
|
||||||
import { config, saveConfig } from "@lib/config";
|
import { config, saveConfig } from "@shared/lib/config";
|
||||||
import type { GameConfigType } from "@lib/config";
|
import type { GameConfigType } from "@shared/lib/config";
|
||||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
export const configCommand = createCommand({
|
export const configCommand = createCommand({
|
||||||
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}`)] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { renderWizard } from "@/modules/admin/item_wizard";
|
import { renderWizard } from "@/modules/admin/item_wizard";
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { configManager } from "@/lib/configManager";
|
import { createBaseEmbed } from "@lib/embeds";
|
||||||
import { config, reloadConfig } from "@/lib/config";
|
import { config, reloadConfig, toggleCommand } from "@shared/lib/config";
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
|
|
||||||
export const features = createCommand({
|
export const features = createCommand({
|
||||||
@@ -45,9 +45,7 @@ export const features = createCommand({
|
|||||||
const overrides = Object.entries(config.commands)
|
const overrides = Object.entries(config.commands)
|
||||||
.map(([name, enabled]) => `• **${name}**: ${enabled ? "✅ Enabled (Override)" : "❌ Disabled"}`);
|
.map(([name, enabled]) => `• **${name}**: ${enabled ? "✅ Enabled (Override)" : "❌ Disabled"}`);
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
const embed = createBaseEmbed("Command Features", undefined, "Blue");
|
||||||
.setTitle("Command Features")
|
|
||||||
.setColor("Blue");
|
|
||||||
|
|
||||||
// Add fields for each category
|
// Add fields for each category
|
||||||
const sortedCategories = [...categories.keys()].sort();
|
const sortedCategories = [...categories.keys()].sort();
|
||||||
@@ -80,11 +78,11 @@ export const features = createCommand({
|
|||||||
|
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
configManager.toggleCommand(commandName, enabled);
|
toggleCommand(commandName, enabled);
|
||||||
|
|
||||||
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Reloading configuration...` });
|
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Reloading configuration...` });
|
||||||
|
|
||||||
// Reload config from disk (which was updated by configManager)
|
// Reload config from disk (which was updated by toggleCommand)
|
||||||
reloadConfig();
|
reloadConfig();
|
||||||
|
|
||||||
await AuroraClient.loadCommands(true);
|
await AuroraClient.loadCommands(true);
|
||||||
84
bot/commands/admin/health.test.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||||
|
import { health } from "./health";
|
||||||
|
import { ChatInputCommandInteraction, Colors } from "discord.js";
|
||||||
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
|
|
||||||
|
// Mock DrizzleClient
|
||||||
|
const executeMock = mock(() => Promise.resolve());
|
||||||
|
mock.module("@shared/db/DrizzleClient", () => ({
|
||||||
|
DrizzleClient: {
|
||||||
|
execute: executeMock
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock BotClient (already has lastCommandTimestamp if imported, but we might want to control it)
|
||||||
|
AuroraClient.lastCommandTimestamp = 1641481200000; // Fixed timestamp for testing
|
||||||
|
|
||||||
|
describe("Health Command", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
executeMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should execute successfully and return health embed", async () => {
|
||||||
|
const interaction = {
|
||||||
|
deferReply: mock(() => Promise.resolve()),
|
||||||
|
editReply: mock(() => Promise.resolve()),
|
||||||
|
client: {
|
||||||
|
ws: {
|
||||||
|
ping: 42
|
||||||
|
}
|
||||||
|
},
|
||||||
|
user: { id: "123", username: "testuser" },
|
||||||
|
commandName: "health"
|
||||||
|
} as unknown as ChatInputCommandInteraction;
|
||||||
|
|
||||||
|
await health.execute(interaction);
|
||||||
|
|
||||||
|
expect(interaction.deferReply).toHaveBeenCalled();
|
||||||
|
expect(executeMock).toHaveBeenCalled();
|
||||||
|
expect(interaction.editReply).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const editReplyCall = (interaction.editReply as any).mock.calls[0][0];
|
||||||
|
const embed = editReplyCall.embeds[0];
|
||||||
|
|
||||||
|
expect(embed.data.title).toBe("System Health Status");
|
||||||
|
expect(embed.data.color).toBe(Colors.Aqua);
|
||||||
|
|
||||||
|
// Check fields
|
||||||
|
const fields = embed.data.fields;
|
||||||
|
expect(fields).toBeDefined();
|
||||||
|
|
||||||
|
// Connectivity field
|
||||||
|
const connectivityField = fields.find((f: any) => f.name === "📡 Connectivity");
|
||||||
|
expect(connectivityField.value).toContain("42ms");
|
||||||
|
expect(connectivityField.value).toContain("Connected");
|
||||||
|
|
||||||
|
// Activity field
|
||||||
|
const activityField = fields.find((f: any) => f.name === "⌨️ Activity");
|
||||||
|
expect(activityField.value).toContain("R>"); // Relative Discord timestamp
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle database disconnection", async () => {
|
||||||
|
executeMock.mockImplementationOnce(() => Promise.reject(new Error("DB Down")));
|
||||||
|
|
||||||
|
const interaction = {
|
||||||
|
deferReply: mock(() => Promise.resolve()),
|
||||||
|
editReply: mock(() => Promise.resolve()),
|
||||||
|
client: {
|
||||||
|
ws: {
|
||||||
|
ping: 42
|
||||||
|
}
|
||||||
|
},
|
||||||
|
user: { id: "123", username: "testuser" },
|
||||||
|
commandName: "health"
|
||||||
|
} as unknown as ChatInputCommandInteraction;
|
||||||
|
|
||||||
|
await health.execute(interaction);
|
||||||
|
|
||||||
|
const editReplyCall = (interaction.editReply as any).mock.calls[0][0];
|
||||||
|
const embed = editReplyCall.embeds[0];
|
||||||
|
const connectivityField = embed.data.fields.find((f: any) => f.name === "📡 Connectivity");
|
||||||
|
|
||||||
|
expect(connectivityField.value).toContain("Disconnected");
|
||||||
|
});
|
||||||
|
});
|
||||||
60
bot/commands/admin/health.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { createCommand } from "@shared/lib/utils";
|
||||||
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
|
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, Colors } from "discord.js";
|
||||||
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { createBaseEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
|
export const health = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("health")
|
||||||
|
.setDescription("Check the bot's health status")
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
// 1. Check Discord API latency
|
||||||
|
const wsPing = interaction.client.ws.ping;
|
||||||
|
|
||||||
|
// 2. Verify database connection
|
||||||
|
let dbStatus = "Connected";
|
||||||
|
let dbPing = -1;
|
||||||
|
try {
|
||||||
|
const start = Date.now();
|
||||||
|
await DrizzleClient.execute(sql`SELECT 1`);
|
||||||
|
dbPing = Date.now() - start;
|
||||||
|
} catch (error) {
|
||||||
|
dbStatus = "Disconnected";
|
||||||
|
console.error("Health check DB error:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Uptime
|
||||||
|
const uptime = process.uptime();
|
||||||
|
const days = Math.floor(uptime / 86400);
|
||||||
|
const hours = Math.floor((uptime % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((uptime % 3600) / 60);
|
||||||
|
const seconds = Math.floor(uptime % 60);
|
||||||
|
const uptimeString = `${days}d ${hours}h ${minutes}m ${seconds}s`;
|
||||||
|
|
||||||
|
// 4. Memory usage
|
||||||
|
const memory = process.memoryUsage();
|
||||||
|
const heapUsed = (memory.heapUsed / 1024 / 1024).toFixed(2);
|
||||||
|
const heapTotal = (memory.heapTotal / 1024 / 1024).toFixed(2);
|
||||||
|
const rss = (memory.rss / 1024 / 1024).toFixed(2);
|
||||||
|
|
||||||
|
// 5. Last successful command
|
||||||
|
const lastCommand = AuroraClient.lastCommandTimestamp
|
||||||
|
? `<t:${Math.floor(AuroraClient.lastCommandTimestamp / 1000)}:R>`
|
||||||
|
: "None since startup";
|
||||||
|
|
||||||
|
const embed = createBaseEmbed("System Health Status", undefined, Colors.Aqua)
|
||||||
|
.addFields(
|
||||||
|
{ name: "📡 Connectivity", value: `**Discord WS:** ${wsPing}ms\n**Database:** ${dbStatus} ${dbPing >= 0 ? `(${dbPing}ms)` : ""}`, inline: true },
|
||||||
|
{ name: "⏱️ Uptime", value: uptimeString, inline: true },
|
||||||
|
{ name: "🧠 Memory Usage", value: `**RSS:** ${rss} MB\n**Heap:** ${heapUsed} / ${heapTotal} MB`, inline: false },
|
||||||
|
{ name: "⌨️ Activity", value: `**Last Command:** ${lastCommand}`, inline: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import {
|
import {
|
||||||
SlashCommandBuilder,
|
SlashCommandBuilder,
|
||||||
EmbedBuilder,
|
|
||||||
ActionRowBuilder,
|
ActionRowBuilder,
|
||||||
ButtonBuilder,
|
ButtonBuilder,
|
||||||
ButtonStyle,
|
ButtonStyle,
|
||||||
@@ -9,11 +8,13 @@ import {
|
|||||||
PermissionFlagsBits,
|
PermissionFlagsBits,
|
||||||
MessageFlags
|
MessageFlags
|
||||||
} from "discord.js";
|
} from "discord.js";
|
||||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
|
import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds";
|
||||||
import { items } from "@/db/schema";
|
import { UserError } from "@/lib/errors";
|
||||||
|
import { items } from "@db/schema";
|
||||||
import { ilike, isNotNull, and } from "drizzle-orm";
|
import { ilike, isNotNull, and } from "drizzle-orm";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
|
import { getShopListingMessage } from "@/modules/economy/shop.view";
|
||||||
|
|
||||||
export const listing = createCommand({
|
export const listing = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -49,33 +50,26 @@ export const listing = createCommand({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!item.price) {
|
if (!item.price) {
|
||||||
await interaction.editReply({ content: "", embeds: [createWarningEmbed(`Item "${item.name}" is not for sale (no price set).`)] });
|
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item "${item.name}" is not for sale (no price set).`)] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
const listingMessage = getShopListingMessage({
|
||||||
.setTitle(`Shop: ${item.name}`)
|
...item,
|
||||||
.setDescription(item.description || "No description available.")
|
formattedPrice: `${item.price} 🪙`,
|
||||||
.addFields({ name: "Price", value: `${item.price} 🪙`, inline: true })
|
price: item.price
|
||||||
.setColor("Green")
|
});
|
||||||
.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 actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await targetChannel.send({ embeds: [embed], components: [actionRow] });
|
await targetChannel.send(listingMessage);
|
||||||
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
|
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error("Failed to send listing message:", error);
|
if (error instanceof UserError) {
|
||||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Failed to post the listing.")] });
|
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) => {
|
autocomplete: async (interaction) => {
|
||||||
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)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createCommand } from "@lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { createErrorEmbed, createSuccessEmbed, createWarningEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
export const refresh = createCommand({
|
export const refresh = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
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,5 +1,5 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, TextChannel, NewsChannel, VoiceChannel, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { createErrorEmbed } from "@/lib/embeds";
|
import { createErrorEmbed } from "@/lib/embeds";
|
||||||
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { createWarningEmbed } from "@/lib/embeds";
|
import { createBaseEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
export const balance = createCommand({
|
export const balance = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -18,15 +18,15 @@ export const balance = createCommand({
|
|||||||
const targetUser = interaction.options.getUser("user") || interaction.user;
|
const targetUser = interaction.options.getUser("user") || interaction.user;
|
||||||
|
|
||||||
if (targetUser.bot) {
|
if (targetUser.bot) {
|
||||||
return; // Wait, I need to send the reply inside the if.
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
if (!user) throw new Error("Failed to retrieve user data.");
|
||||||
.setAuthor({ name: targetUser.username, iconURL: targetUser.displayAvatarURL() })
|
|
||||||
.setDescription(`**Balance**: ${user.balance || 0n} AU`)
|
const embed = createBaseEmbed(undefined, `**Balance**: ${user.balance || 0n} AU`, "Yellow")
|
||||||
.setColor("Yellow");
|
.setAuthor({ name: targetUser.username, iconURL: targetUser.displayAvatarURL() });
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
import { userTimers, users } from "@/db/schema";
|
import { UserError } from "@/lib/errors";
|
||||||
|
import { userTimers, users } from "@db/schema";
|
||||||
import { eq, and, sql } from "drizzle-orm";
|
import { eq, and, sql } from "drizzle-orm";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
import { config } from "@lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
|
import { TimerType } from "@shared/lib/constants";
|
||||||
|
|
||||||
const EXAM_TIMER_TYPE = 'EXAM_SYSTEM';
|
const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
|
||||||
const EXAM_TIMER_KEY = 'default';
|
const EXAM_TIMER_KEY = 'default';
|
||||||
|
|
||||||
interface ExamMetadata {
|
interface ExamMetadata {
|
||||||
@@ -24,6 +26,10 @@ export const exam = createCommand({
|
|||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply();
|
await interaction.deferReply();
|
||||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||||
|
if (!user) {
|
||||||
|
await interaction.editReply({ embeds: [createErrorEmbed("Failed to retrieve user data.")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const currentDay = now.getDay();
|
const currentDay = now.getDay();
|
||||||
|
|
||||||
@@ -40,26 +46,28 @@ export const exam = createCommand({
|
|||||||
// 2. First Run Logic
|
// 2. First Run Logic
|
||||||
if (!timer) {
|
if (!timer) {
|
||||||
// Set exam day to today
|
// Set exam day to today
|
||||||
const nextWeek = new Date(now);
|
const nextExamDate = new Date(now);
|
||||||
nextWeek.setDate(now.getDate() + 7);
|
nextExamDate.setDate(now.getDate() + 7);
|
||||||
|
nextExamDate.setHours(0, 0, 0, 0);
|
||||||
|
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||||
|
|
||||||
const metadata: ExamMetadata = {
|
const metadata: ExamMetadata = {
|
||||||
examDay: currentDay,
|
examDay: currentDay,
|
||||||
lastXp: user.xp.toString()
|
lastXp: (user.xp ?? 0n).toString()
|
||||||
};
|
};
|
||||||
|
|
||||||
await DrizzleClient.insert(userTimers).values({
|
await DrizzleClient.insert(userTimers).values({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
type: EXAM_TIMER_TYPE,
|
type: EXAM_TIMER_TYPE,
|
||||||
key: EXAM_TIMER_KEY,
|
key: EXAM_TIMER_KEY,
|
||||||
expiresAt: nextWeek,
|
expiresAt: nextExamDate,
|
||||||
metadata: metadata
|
metadata: metadata
|
||||||
});
|
});
|
||||||
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [createSuccessEmbed(
|
embeds: [createSuccessEmbed(
|
||||||
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}**.\n` +
|
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}** (Server Time).\n` +
|
||||||
`Come back next week on **${DAYS[currentDay]}** to take your first exam and earn rewards based on your XP gain!`,
|
`Come back on <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>) to take your first exam!`,
|
||||||
"Exam Registration Successful"
|
"Exam Registration Successful"
|
||||||
)]
|
)]
|
||||||
});
|
});
|
||||||
@@ -70,16 +78,17 @@ export const exam = createCommand({
|
|||||||
const examDay = metadata.examDay;
|
const examDay = metadata.examDay;
|
||||||
|
|
||||||
// 3. Cooldown Check
|
// 3. Cooldown Check
|
||||||
if (now < new Date(timer.expiresAt)) {
|
|
||||||
// Calculate time remaining
|
|
||||||
const expiresAt = new Date(timer.expiresAt);
|
const expiresAt = new Date(timer.expiresAt);
|
||||||
// Simple formatting
|
expiresAt.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
if (now < expiresAt) {
|
||||||
|
// Calculate time remaining
|
||||||
const timestamp = Math.floor(expiresAt.getTime() / 1000);
|
const timestamp = Math.floor(expiresAt.getTime() / 1000);
|
||||||
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [createErrorEmbed(
|
embeds: [createErrorEmbed(
|
||||||
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
||||||
`Next exam available: <t:${timestamp}:R> (${DAYS[examDay]})`
|
`Next exam available: <t:${timestamp}:D> (<t:${timestamp}:R>)`
|
||||||
)]
|
)]
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -87,20 +96,23 @@ export const exam = createCommand({
|
|||||||
|
|
||||||
// 4. Day Check
|
// 4. Day Check
|
||||||
if (currentDay !== examDay) {
|
if (currentDay !== examDay) {
|
||||||
// "If not executed on same weekday... we do not reward"
|
// Calculate next correct exam day to correct the schedule
|
||||||
// Consume the attempt (reset timer) but give 0 reward.
|
let daysUntil = (examDay - currentDay + 7) % 7;
|
||||||
|
if (daysUntil === 0) daysUntil = 7;
|
||||||
|
|
||||||
const nextWeek = new Date(now);
|
const nextExamDate = new Date(now);
|
||||||
nextWeek.setDate(now.getDate() + 7);
|
nextExamDate.setDate(now.getDate() + daysUntil);
|
||||||
|
nextExamDate.setHours(0, 0, 0, 0);
|
||||||
|
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||||
|
|
||||||
const newMetadata: ExamMetadata = {
|
const newMetadata: ExamMetadata = {
|
||||||
examDay: examDay,
|
examDay: examDay,
|
||||||
lastXp: user.xp.toString() // Reset tracking
|
lastXp: (user.xp ?? 0n).toString()
|
||||||
};
|
};
|
||||||
|
|
||||||
await DrizzleClient.update(userTimers)
|
await DrizzleClient.update(userTimers)
|
||||||
.set({
|
.set({
|
||||||
expiresAt: nextWeek,
|
expiresAt: nextExamDate,
|
||||||
metadata: newMetadata
|
metadata: newMetadata
|
||||||
})
|
})
|
||||||
.where(and(
|
.where(and(
|
||||||
@@ -111,8 +123,9 @@ export const exam = createCommand({
|
|||||||
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [createErrorEmbed(
|
embeds: [createErrorEmbed(
|
||||||
`You missed your exam day! Your exam is on **${DAYS[examDay]}**, but today is ${DAYS[currentDay]}.\n` +
|
`You missed your exam day! Your exam day is **${DAYS[examDay]}** (Server Time).\n` +
|
||||||
`You verify your attendance but score a **0**. Come back next **${DAYS[examDay]}**!`,
|
`You verify your attendance but score a **0**.\n` +
|
||||||
|
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
|
||||||
"Exam Failed"
|
"Exam Failed"
|
||||||
)]
|
)]
|
||||||
});
|
});
|
||||||
@@ -121,7 +134,7 @@ export const exam = createCommand({
|
|||||||
|
|
||||||
// 5. Reward Calculation
|
// 5. Reward Calculation
|
||||||
const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case
|
const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case
|
||||||
const currentXp = user.xp;
|
const currentXp = user.xp ?? 0n;
|
||||||
const diff = currentXp - lastXp;
|
const diff = currentXp - lastXp;
|
||||||
|
|
||||||
// Calculate Reward
|
// Calculate Reward
|
||||||
@@ -137,8 +150,10 @@ export const exam = createCommand({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 6. Update State
|
// 6. Update State
|
||||||
const nextWeek = new Date(now);
|
const nextExamDate = new Date(now);
|
||||||
nextWeek.setDate(now.getDate() + 7);
|
nextExamDate.setDate(now.getDate() + 7);
|
||||||
|
nextExamDate.setHours(0, 0, 0, 0);
|
||||||
|
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||||
|
|
||||||
const newMetadata: ExamMetadata = {
|
const newMetadata: ExamMetadata = {
|
||||||
examDay: examDay,
|
examDay: examDay,
|
||||||
@@ -149,7 +164,7 @@ export const exam = createCommand({
|
|||||||
// Update Timer
|
// Update Timer
|
||||||
await tx.update(userTimers)
|
await tx.update(userTimers)
|
||||||
.set({
|
.set({
|
||||||
expiresAt: nextWeek,
|
expiresAt: nextExamDate,
|
||||||
metadata: newMetadata
|
metadata: newMetadata
|
||||||
})
|
})
|
||||||
.where(and(
|
.where(and(
|
||||||
@@ -173,14 +188,18 @@ export const exam = createCommand({
|
|||||||
`**XP Gained:** ${diff.toString()}\n` +
|
`**XP Gained:** ${diff.toString()}\n` +
|
||||||
`**Multiplier:** x${multiplier.toFixed(2)}\n` +
|
`**Multiplier:** x${multiplier.toFixed(2)}\n` +
|
||||||
`**Reward:** ${reward.toString()} Currency\n\n` +
|
`**Reward:** ${reward.toString()} Currency\n\n` +
|
||||||
`See you next week on **${DAYS[examDay]}**!`,
|
`See you next week: <t:${nextExamTimestamp}:D>`,
|
||||||
"Exam Passed!"
|
"Exam Passed!"
|
||||||
)]
|
)]
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Exam command error:", error);
|
if (error instanceof UserError) {
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("An error occurred while processing your exam.")] });
|
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 { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, EmbedBuilder, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, ThreadAutoArchiveDuration, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags } from "discord.js";
|
||||||
import { TradeService } from "@/modules/trade/trade.service";
|
import { tradeService } from "@shared/modules/trade/trade.service";
|
||||||
|
import { getTradeDashboard } from "@/modules/trade/trade.view";
|
||||||
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
export const trade = createCommand({
|
export const trade = createCommand({
|
||||||
@@ -58,32 +59,15 @@ export const trade = createCommand({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Setup Session
|
// Setup Session
|
||||||
TradeService.createSession(thread.id,
|
const session = tradeService.createSession(thread.id,
|
||||||
{ id: interaction.user.id, username: interaction.user.username },
|
{ id: interaction.user.id, username: interaction.user.username },
|
||||||
{ id: targetUser.id, username: targetUser.username }
|
{ id: targetUser.id, username: targetUser.username }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Send Dashboard to Thread
|
// Send Dashboard to Thread
|
||||||
const embed = new EmbedBuilder()
|
const dashboard = getTradeDashboard(session);
|
||||||
.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 row = new ActionRowBuilder<ButtonBuilder>()
|
await thread.send({ content: `${interaction.user} ${targetUser} Welcome to your trading session!`, ...dashboard });
|
||||||
.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] });
|
|
||||||
|
|
||||||
// Update original reply
|
// Update original reply
|
||||||
await interaction.editReply({ content: `✅ Trade opened: <#${thread.id}>` });
|
await interaction.editReply({ content: `✅ Trade opened: <#${thread.id}>` });
|
||||||
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 { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { createWarningEmbed } from "@lib/embeds";
|
import { createWarningEmbed } from "@lib/embeds";
|
||||||
|
import { getInventoryEmbed } from "@/modules/inventory/inventory.view";
|
||||||
|
|
||||||
export const inventory = createCommand({
|
export const inventory = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -24,22 +25,19 @@ export const inventory = createCommand({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
||||||
const items = await inventoryService.getInventory(user.id);
|
if (!user) {
|
||||||
|
await interaction.editReply({ embeds: [createWarningEmbed("Failed to load user data.", "Error")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await inventoryService.getInventory(user.id.toString());
|
||||||
|
|
||||||
if (!items || items.length === 0) {
|
if (!items || items.length === 0) {
|
||||||
await interaction.editReply({ embeds: [createWarningEmbed("Inventory is empty.", `${user.username}'s Inventory`)] });
|
await interaction.editReply({ embeds: [createWarningEmbed("Inventory is empty.", `${user.username}'s Inventory`)] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const description = items.map(entry => {
|
const embed = getInventoryEmbed(items, user.username);
|
||||||
return `**${entry.item.name}** x${entry.quantity}`;
|
|
||||||
}).join("\n");
|
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setTitle(`${user.username}'s Inventory`)
|
|
||||||
.setDescription(description)
|
|
||||||
.setColor("Blue")
|
|
||||||
.setTimestamp();
|
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
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,6 +1,6 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, AttachmentBuilder } from "discord.js";
|
import { SlashCommandBuilder, AttachmentBuilder } from "discord.js";
|
||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { generateStudentIdCard } from "@/graphics/studentID";
|
import { generateStudentIdCard } from "@/graphics/studentID";
|
||||||
import { createWarningEmbed } from "@/lib/embeds";
|
import { createWarningEmbed } from "@/lib/embeds";
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Events } from "discord.js";
|
import { Events } from "discord.js";
|
||||||
import type { Event } from "@lib/types";
|
import type { Event } from "@shared/lib/types";
|
||||||
import { config } from "@lib/config";
|
import { config } from "@shared/lib/config";
|
||||||
import { userService } from "@modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
|
|
||||||
// Visitor role
|
// Visitor role
|
||||||
const event: Event<Events.GuildMemberAdd> = {
|
const event: Event<Events.GuildMemberAdd> = {
|
||||||
22
bot/events/interactionCreate.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Events } from "discord.js";
|
||||||
|
import { ComponentInteractionHandler, AutocompleteHandler, CommandHandler } from "@/lib/handlers";
|
||||||
|
import type { Event } from "@shared/lib/types";
|
||||||
|
|
||||||
|
const event: Event<Events.InteractionCreate> = {
|
||||||
|
name: Events.InteractionCreate,
|
||||||
|
execute: async (interaction) => {
|
||||||
|
if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
|
||||||
|
return ComponentInteractionHandler.handle(interaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.isAutocomplete()) {
|
||||||
|
return AutocompleteHandler.handle(interaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.isChatInputCommand()) {
|
||||||
|
return CommandHandler.handle(interaction);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default event;
|
||||||
22
bot/events/messageCreate.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Events } from "discord.js";
|
||||||
|
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,
|
||||||
|
execute: async (message) => {
|
||||||
|
if (message.author.bot) return;
|
||||||
|
if (!message.guild) return;
|
||||||
|
|
||||||
|
const user = await userService.getUserById(message.author.id);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
levelingService.processChatXp(message.author.id);
|
||||||
|
|
||||||
|
// Activity Tracking for Lootdrops
|
||||||
|
import("@shared/modules/economy/lootdrop.service").then(m => m.lootdropService.processMessage(message));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default event;
|
||||||
18
bot/events/ready.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Events } from "discord.js";
|
||||||
|
import { schedulerService } from "@/modules/system/scheduler";
|
||||||
|
import type { Event } from "@shared/lib/types";
|
||||||
|
|
||||||
|
const event: Event<Events.ClientReady> = {
|
||||||
|
name: Events.ClientReady,
|
||||||
|
once: true,
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default event;
|
||||||
135
bot/graphics/lootdrop.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// Register Fonts (same as studentID.ts)
|
||||||
|
const fontDir = path.join(process.cwd(), 'bot', 'assets', 'fonts');
|
||||||
|
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold');
|
||||||
|
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
|
||||||
|
|
||||||
|
export async function generateLootdropCard(amount: number, currency: string): Promise<Buffer> {
|
||||||
|
const templatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'lootdrop', 'template.png');
|
||||||
|
const template = await loadImage(templatePath);
|
||||||
|
|
||||||
|
const canvas = createCanvas(template.width, template.height);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Draw Template
|
||||||
|
ctx.drawImage(template, 0, 0);
|
||||||
|
|
||||||
|
// Draw Lootdrop Text (Title-ish)
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '48px IBMPlexSansCondensed-SemiBold';
|
||||||
|
ctx.fillStyle = '#FFFFFF';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.shadowBlur = 10;
|
||||||
|
ctx.shadowColor = 'rgba(255, 255, 255, 0.5)';
|
||||||
|
// Center of lower half (512-1024) is roughly 768
|
||||||
|
ctx.fillText('A STAR IS FALLING', canvas.width / 2, 660);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Draw Reward Amount
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '72px IBMPlexMono-Bold';
|
||||||
|
ctx.fillStyle = '#DAC7A1';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
//ctx.shadowBlur = 15;
|
||||||
|
//ctx.shadowColor = 'rgba(255, 215, 0, 0.8)';
|
||||||
|
ctx.fillText(`${amount} ${currency}`, canvas.width / 2, 760); // Below title
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Crop the image by 64px on all sides
|
||||||
|
const croppedWidth = template.width - 128;
|
||||||
|
const croppedHeight = template.height - 128;
|
||||||
|
const croppedCanvas = createCanvas(croppedWidth, croppedHeight);
|
||||||
|
const croppedCtx = croppedCanvas.getContext('2d');
|
||||||
|
|
||||||
|
// Draw the original canvas onto the cropped canvas, shifted by -64
|
||||||
|
croppedCtx.drawImage(canvas, -64, -64);
|
||||||
|
|
||||||
|
return croppedCanvas.toBuffer('image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateClaimedLootdropCard(amount: number, currency: string, username: string, avatarUrl: string): Promise<Buffer> {
|
||||||
|
const templatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'lootdrop', 'template.png');
|
||||||
|
const template = await loadImage(templatePath);
|
||||||
|
|
||||||
|
const canvas = createCanvas(template.width, template.height);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Draw Template
|
||||||
|
ctx.drawImage(template, 0, 0);
|
||||||
|
|
||||||
|
// Add a colored overlay to signify "claimed"
|
||||||
|
ctx.fillStyle = 'rgba(10, 10, 20, 0.85)';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// Draw Claimed Text (Title-ish)
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '48px IBMPlexSansCondensed-SemiBold';
|
||||||
|
ctx.fillStyle = '#FFFFFF';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.shadowBlur = 10;
|
||||||
|
ctx.shadowColor = 'rgba(255, 255, 255, 0.5)';
|
||||||
|
ctx.fillText('STAR CLAIMED', canvas.width / 2, 660);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Draw "by username" with Avatar
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '36px IBMPlexSansCondensed-SemiBold';
|
||||||
|
ctx.fillStyle = '#AAAAAA';
|
||||||
|
|
||||||
|
// Calculate layout for centering Group (Avatar + Text)
|
||||||
|
const text = `by ${username}`;
|
||||||
|
const textMetrics = ctx.measureText(text);
|
||||||
|
const textWidth = textMetrics.width;
|
||||||
|
const avatarSize = 50;
|
||||||
|
const gap = 15;
|
||||||
|
const totalWidth = avatarSize + gap + textWidth;
|
||||||
|
|
||||||
|
const startX = (canvas.width - totalWidth) / 2;
|
||||||
|
const baselineY = 830;
|
||||||
|
|
||||||
|
// Draw Avatar
|
||||||
|
try {
|
||||||
|
const avatar = await loadImage(avatarUrl);
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
// Center avatar vertically relative to text roughly (baseline - ~half cap height)
|
||||||
|
// 36px text ~ 27px cap height. Center roughly at baselineY - 14
|
||||||
|
const avatarCenterY = baselineY - 14;
|
||||||
|
ctx.arc(startX + avatarSize / 2, avatarCenterY, avatarSize / 2, 0, Math.PI * 2);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.clip();
|
||||||
|
ctx.drawImage(avatar, startX, avatarCenterY - avatarSize / 2, avatarSize, avatarSize);
|
||||||
|
ctx.restore();
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback if avatar fails to load, just don't draw it (or maybe shift text?)
|
||||||
|
// For now, let's just proceed, the text will be off-center if avatar is missing but that's acceptable edge case
|
||||||
|
console.error("Failed to load avatar", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw Text
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(text, startX + avatarSize + gap, baselineY);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '72px IBMPlexMono-Bold'; // Match Amount size
|
||||||
|
ctx.fillStyle = '#E6D2B5'; // Lighter gold/beige for better contrast
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.shadowBlur = 10;
|
||||||
|
ctx.shadowColor = 'rgba(0, 0, 0, 0.8)'; // Dark shadow for contrast
|
||||||
|
ctx.fillText(`${amount} ${currency}`, canvas.width / 2, 760); // Same position as Unclaimed Amount
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Crop the image by 64px on all sides
|
||||||
|
const croppedWidth = template.width - 128;
|
||||||
|
const croppedHeight = template.height - 128;
|
||||||
|
const croppedCanvas = createCanvas(croppedWidth, croppedHeight);
|
||||||
|
const croppedCtx = croppedCanvas.getContext('2d');
|
||||||
|
|
||||||
|
// Draw the original canvas onto the cropped canvas, shifted by -64
|
||||||
|
croppedCtx.drawImage(canvas, -64, -64);
|
||||||
|
|
||||||
|
return croppedCanvas.toBuffer('image/png');
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas';
|
import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas';
|
||||||
import { levelingService } from '@/modules/leveling/leveling.service';
|
import { levelingService } from '@shared/modules/leveling/leveling.service';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
// Register Fonts
|
// Register Fonts
|
||||||
const fontDir = path.join(process.cwd(), 'src', 'assets', 'fonts');
|
const fontDir = path.join(process.cwd(), 'bot', 'assets', 'fonts');
|
||||||
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold');
|
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold');
|
||||||
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
|
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
|
||||||
|
|
||||||
@@ -18,8 +18,8 @@ interface StudentCardData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateStudentIdCard(data: StudentCardData): Promise<Buffer> {
|
export async function generateStudentIdCard(data: StudentCardData): Promise<Buffer> {
|
||||||
const templatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'studentID', 'template.png');
|
const templatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'studentID', 'template.png');
|
||||||
const classTemplatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'studentID', `Constellation-${data.className}.png`);
|
const classTemplatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'studentID', `Constellation-${data.className}.png`);
|
||||||
|
|
||||||
const template = await loadImage(templatePath);
|
const template = await loadImage(templatePath);
|
||||||
const classTemplate = await loadImage(classTemplatePath);
|
const classTemplate = await loadImage(classTemplatePath);
|
||||||
@@ -97,9 +97,12 @@ export async function generateStudentIdCard(data: StudentCardData): Promise<Buff
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
// Draw XP Bar
|
// 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 xpBarMaxWidth = 382;
|
||||||
const xpBarWidth = xpBarMaxWidth * Number(data.xp) / Number(xpForNextLevel);
|
const xpBarWidth = Math.max(0, Math.min(xpBarMaxWidth, xpBarMaxWidth * currentLevelProgress / xpForThisLevel));
|
||||||
const xpBarHeight = 3;
|
const xpBarHeight = 3;
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.fillStyle = '#B3AD93';
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
176
bot/lib/BotClient.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
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>;
|
||||||
|
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.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();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
48
bot/lib/clientStats.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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,
|
||||||
|
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.
|
* Creates a standardized error embed.
|
||||||
@@ -55,3 +55,21 @@ export function createInfoEmbed(message: string, title: string = "Info"): EmbedB
|
|||||||
.setColor(Colors.Blue)
|
.setColor(Colors.Blue)
|
||||||
.setTimestamp();
|
.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;
|
||||||
|
}
|
||||||
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";
|
||||||
61
bot/lib/interaction.routes.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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'
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- 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'
|
||||||
|
}
|
||||||
|
];
|
||||||
111
bot/lib/loaders/CommandLoader.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type TextBasedChannel, User, Client } from 'discord.js';
|
import { type TextBasedChannel, User } from 'discord.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends a message to a channel using a temporary webhook (imitating the bot or custom persona).
|
* Sends a message to a channel using a temporary webhook (imitating the bot or custom persona).
|
||||||
@@ -1,19 +1,18 @@
|
|||||||
import { describe, test, expect, spyOn, beforeEach, mock } from "bun:test";
|
import { describe, test, expect, spyOn, beforeEach, mock } from "bun:test";
|
||||||
import { handleItemWizardInteraction, renderWizard } from "./item_wizard";
|
import { handleItemWizardInteraction, renderWizard } from "./item_wizard";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
|
||||||
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
|
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
|
||||||
|
|
||||||
// Mock Setup
|
// Mock Setup
|
||||||
const valuesMock = mock((_args: any) => Promise.resolve());
|
const valuesMock = mock((_args: any) => Promise.resolve());
|
||||||
const insertMock = mock(() => ({ values: valuesMock }));
|
const insertMock = mock(() => ({ values: valuesMock }));
|
||||||
|
|
||||||
mock.module("@/lib/DrizzleClient", () => ({
|
mock.module("@shared/db/DrizzleClient", () => ({
|
||||||
DrizzleClient: {
|
DrizzleClient: {
|
||||||
insert: insertMock
|
insert: insertMock
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mock.module("@/db/schema", () => ({
|
mock.module("@db/schema", () => ({
|
||||||
items: "items_schema"
|
items: "items_schema"
|
||||||
}));
|
}));
|
||||||
|
|
||||||
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 {
|
import {
|
||||||
|
type Interaction,
|
||||||
ButtonInteraction,
|
ButtonInteraction,
|
||||||
ModalSubmitInteraction,
|
ModalSubmitInteraction,
|
||||||
StringSelectMenuInteraction,
|
StringSelectMenuInteraction,
|
||||||
type Interaction,
|
|
||||||
EmbedBuilder,
|
|
||||||
ActionRowBuilder,
|
|
||||||
ButtonBuilder,
|
|
||||||
ButtonStyle,
|
|
||||||
StringSelectMenuBuilder,
|
|
||||||
ModalBuilder,
|
|
||||||
TextInputBuilder,
|
|
||||||
TextInputStyle,
|
|
||||||
ThreadChannel,
|
ThreadChannel,
|
||||||
TextChannel,
|
TextChannel,
|
||||||
|
EmbedBuilder
|
||||||
} from "discord.js";
|
} from "discord.js";
|
||||||
import { TradeService } from "./trade.service";
|
import { tradeService } from "@shared/modules/trade/trade.service";
|
||||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
|
||||||
|
import { UserError } from "@lib/errors";
|
||||||
|
import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view";
|
||||||
|
|
||||||
|
|
||||||
const EMBED_COLOR = 0xFFD700; // Gold
|
|
||||||
|
|
||||||
export async function handleTradeInteraction(interaction: Interaction) {
|
export async function handleTradeInteraction(interaction: Interaction) {
|
||||||
if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return;
|
if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return;
|
||||||
@@ -28,7 +23,6 @@ export async function handleTradeInteraction(interaction: Interaction) {
|
|||||||
|
|
||||||
if (!threadId) return;
|
if (!threadId) return;
|
||||||
|
|
||||||
try {
|
|
||||||
if (customId === 'trade_cancel') {
|
if (customId === 'trade_cancel') {
|
||||||
await handleCancel(interaction, threadId);
|
await handleCancel(interaction, threadId);
|
||||||
} else if (customId === 'trade_lock') {
|
} else if (customId === 'trade_lock') {
|
||||||
@@ -50,21 +44,13 @@ export async function handleTradeInteraction(interaction: Interaction) {
|
|||||||
} else if (customId === 'trade_remove_item_select') {
|
} else if (customId === 'trade_remove_item_select') {
|
||||||
await handleRemoveItemSelect(interaction as StringSelectMenuInteraction, threadId);
|
await handleRemoveItemSelect(interaction as StringSelectMenuInteraction, threadId);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
|
||||||
const errorEmbed = createErrorEmbed(error.message);
|
|
||||||
if (interaction.replied || interaction.deferred) {
|
|
||||||
await interaction.followUp({ embeds: [errorEmbed], ephemeral: true });
|
|
||||||
} else {
|
|
||||||
await interaction.reply({ embeds: [errorEmbed], ephemeral: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
|
async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
|
||||||
const session = TradeService.getSession(threadId);
|
const session = tradeService.getSession(threadId);
|
||||||
const user = interaction.user;
|
const user = interaction.user;
|
||||||
|
|
||||||
TradeService.endSession(threadId);
|
tradeService.endSession(threadId);
|
||||||
|
|
||||||
await interaction.deferUpdate();
|
await interaction.deferUpdate();
|
||||||
|
|
||||||
@@ -76,11 +62,11 @@ async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInt
|
|||||||
|
|
||||||
async function handleLock(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
|
async function handleLock(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
|
||||||
await interaction.deferUpdate();
|
await interaction.deferUpdate();
|
||||||
const isLocked = TradeService.toggleLock(threadId, interaction.user.id);
|
const isLocked = tradeService.toggleLock(threadId, interaction.user.id);
|
||||||
await updateTradeDashboard(interaction, threadId);
|
await updateTradeDashboard(interaction, threadId);
|
||||||
|
|
||||||
// Check if trade executed (both locked)
|
// Check if trade executed (both locked)
|
||||||
const session = TradeService.getSession(threadId);
|
const session = tradeService.getSession(threadId);
|
||||||
if (session && session.state === 'COMPLETED') {
|
if (session && session.state === 'COMPLETED') {
|
||||||
// Trade executed during updateTradeDashboard
|
// Trade executed during updateTradeDashboard
|
||||||
return;
|
return;
|
||||||
@@ -91,20 +77,7 @@ async function handleLock(interaction: ButtonInteraction | StringSelectMenuInter
|
|||||||
|
|
||||||
async function handleAddMoneyClick(interaction: Interaction) {
|
async function handleAddMoneyClick(interaction: Interaction) {
|
||||||
if (!interaction.isButton()) return;
|
if (!interaction.isButton()) return;
|
||||||
const modal = new ModalBuilder()
|
const modal = getTradeMoneyModal();
|
||||||
.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);
|
|
||||||
|
|
||||||
await interaction.showModal(modal);
|
await interaction.showModal(modal);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,9 +85,9 @@ async function handleMoneySubmit(interaction: ModalSubmitInteraction, threadId:
|
|||||||
const amountStr = interaction.fields.getTextInputValue('amount');
|
const amountStr = interaction.fields.getTextInputValue('amount');
|
||||||
const amount = BigInt(amountStr);
|
const amount = BigInt(amountStr);
|
||||||
|
|
||||||
if (amount < 0n) throw new Error("Amount must be positive");
|
if (amount < 0n) throw new UserError("Amount must be positive");
|
||||||
|
|
||||||
TradeService.updateMoney(threadId, interaction.user.id, amount);
|
tradeService.updateMoney(threadId, interaction.user.id, amount);
|
||||||
await interaction.deferUpdate(); // Acknowledge modal
|
await interaction.deferUpdate(); // Acknowledge modal
|
||||||
await updateTradeDashboard(interaction, threadId);
|
await updateTradeDashboard(interaction, threadId);
|
||||||
}
|
}
|
||||||
@@ -128,20 +101,14 @@ async function handleAddItemClick(interaction: ButtonInteraction, threadId: stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Slice top 25 for select menu
|
// Slice top 25 for select menu
|
||||||
const options = inventory.slice(0, 25).map(entry => ({
|
const options = inventory.slice(0, 25).map((entry: any) => ({
|
||||||
label: `${entry.item.name} (${entry.quantity})`,
|
label: `${entry.item.name} (${entry.quantity})`,
|
||||||
value: entry.item.id.toString(),
|
value: entry.item.id.toString(),
|
||||||
description: `Rarity: ${entry.item.rarity} `
|
description: `Rarity: ${entry.item.rarity} `
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const select = new StringSelectMenuBuilder()
|
const { components } = getItemSelectMenu(options, 'trade_select_item', 'Select an item to add');
|
||||||
.setCustomId('trade_select_item')
|
await interaction.reply({ content: "Select an item to add:", components, ephemeral: true });
|
||||||
.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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleItemSelect(interaction: StringSelectMenuInteraction, threadId: string) {
|
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
|
// Assuming implementation implies adding 1 item for now
|
||||||
const item = await inventoryService.getItem(itemId);
|
const item = await inventoryService.getItem(itemId);
|
||||||
if (!item) throw new Error("Item not found");
|
if (!item) throw new UserError("Item not found");
|
||||||
|
|
||||||
TradeService.addItem(threadId, interaction.user.id, { id: item.id, name: item.name }, 1n);
|
tradeService.addItem(threadId, interaction.user.id, { id: item.id, name: item.name }, 1n);
|
||||||
|
|
||||||
await interaction.update({ content: `Added ${item.name} x1`, components: [] });
|
await interaction.update({ content: `Added ${item.name} x1`, components: [] });
|
||||||
await updateTradeDashboard(interaction, threadId);
|
await updateTradeDashboard(interaction, threadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRemoveItemClick(interaction: ButtonInteraction, threadId: string) {
|
async function handleRemoveItemClick(interaction: ButtonInteraction, threadId: string) {
|
||||||
const session = TradeService.getSession(threadId);
|
const session = tradeService.getSession(threadId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|
||||||
const participant = session.userA.id === interaction.user.id ? session.userA : session.userB;
|
const participant = session.userA.id === interaction.user.id ? session.userA : session.userB;
|
||||||
@@ -175,21 +142,15 @@ async function handleRemoveItemClick(interaction: ButtonInteraction, threadId: s
|
|||||||
value: i.id.toString(),
|
value: i.id.toString(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const select = new StringSelectMenuBuilder()
|
const { components } = getItemSelectMenu(options, 'trade_remove_item_select', 'Select an item to remove');
|
||||||
.setCustomId('trade_remove_item_select')
|
await interaction.reply({ content: "Select an item to remove:", components, ephemeral: true });
|
||||||
.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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRemoveItemSelect(interaction: StringSelectMenuInteraction, threadId: string) {
|
async function handleRemoveItemSelect(interaction: StringSelectMenuInteraction, threadId: string) {
|
||||||
const value = interaction.values[0];
|
const value = interaction.values[0];
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
const itemId = parseInt(value);
|
const itemId = parseInt(value);
|
||||||
TradeService.removeItem(threadId, interaction.user.id, itemId);
|
tradeService.removeItem(threadId, interaction.user.id, itemId);
|
||||||
|
|
||||||
await interaction.update({ content: `Removed item.`, components: [] });
|
await interaction.update({ content: `Removed item.`, components: [] });
|
||||||
await updateTradeDashboard(interaction, threadId);
|
await updateTradeDashboard(interaction, threadId);
|
||||||
@@ -199,23 +160,15 @@ async function handleRemoveItemSelect(interaction: StringSelectMenuInteraction,
|
|||||||
// --- DASHBOARD UPDATER ---
|
// --- DASHBOARD UPDATER ---
|
||||||
|
|
||||||
export async function updateTradeDashboard(interaction: Interaction, threadId: string) {
|
export async function updateTradeDashboard(interaction: Interaction, threadId: string) {
|
||||||
const session = TradeService.getSession(threadId);
|
const session = tradeService.getSession(threadId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|
||||||
// Check Auto-Execute (If both locked)
|
// Check Auto-Execute (If both locked)
|
||||||
if (session.userA.locked && session.userB.locked) {
|
if (session.userA.locked && session.userB.locked) {
|
||||||
// Execute Trade
|
// Execute Trade
|
||||||
try {
|
try {
|
||||||
await TradeService.executeTrade(threadId);
|
await tradeService.executeTrade(threadId);
|
||||||
const embed = new EmbedBuilder()
|
const embed = getTradeCompletedEmbed(session);
|
||||||
.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 updateDashboardMessage(interaction, { embeds: [embed], components: [] });
|
await updateDashboardMessage(interaction, { embeds: [embed], components: [] });
|
||||||
|
|
||||||
// Notify and Schedule Cleanup
|
// Notify and Schedule Cleanup
|
||||||
@@ -246,33 +199,8 @@ export async function updateTradeDashboard(interaction: Interaction, threadId: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build Status Embed
|
// Build Status Embed
|
||||||
const embed = new EmbedBuilder()
|
const { embeds, components } = getTradeDashboard(session);
|
||||||
.setTitle("🤝 Trading Session")
|
await updateDashboardMessage(interaction, { embeds, components });
|
||||||
.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] });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateDashboardMessage(interaction: Interaction, payload: any) {
|
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) {
|
async function scheduleThreadCleanup(channel: ThreadChannel | TextChannel, message: string, delayMs: number = 10000, embed?: EmbedBuilder) {
|
||||||
try {
|
try {
|
||||||
85
bot/modules/trade/trade.view.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ModalBuilder, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle } from "discord.js";
|
||||||
|
import { createBaseEmbed } from "@lib/embeds";
|
||||||
|
import type { TradeSession, TradeParticipant } from "./trade.types";
|
||||||
|
|
||||||
|
const EMBED_COLOR = 0xFFD700; // Gold
|
||||||
|
|
||||||
|
function formatOffer(participant: TradeParticipant) {
|
||||||
|
let text = "";
|
||||||
|
if (participant.offer.money > 0n) {
|
||||||
|
text += `💰 ${participant.offer.money} 🪙\n`;
|
||||||
|
}
|
||||||
|
if (participant.offer.items.length > 0) {
|
||||||
|
text += participant.offer.items.map((i) => `- ${i.name} (x${i.quantity})`).join("\n");
|
||||||
|
}
|
||||||
|
if (text === "") text = "*Empty Offer*";
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTradeDashboard(session: TradeSession) {
|
||||||
|
const embed = createBaseEmbed("🤝 Trading Session", undefined, 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),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { embeds: [embed], components: [row] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTradeCompletedEmbed(session: TradeSession) {
|
||||||
|
const embed = createBaseEmbed("✅ Trade Completed", undefined, "Green")
|
||||||
|
.addFields(
|
||||||
|
{ name: session.userA.username, value: formatOffer(session.userA), inline: true },
|
||||||
|
{ name: session.userB.username, value: formatOffer(session.userB), inline: true }
|
||||||
|
)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTradeMoneyModal() {
|
||||||
|
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);
|
||||||
|
|
||||||
|
return modal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getItemSelectMenu(items: { label: string, value: string, description?: string }[], customId: string, placeholder: string) {
|
||||||
|
const select = new StringSelectMenuBuilder()
|
||||||
|
.setCustomId(customId)
|
||||||
|
.setPlaceholder(placeholder)
|
||||||
|
.addOptions(items);
|
||||||
|
|
||||||
|
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
|
||||||
|
|
||||||
|
return { components: [row] };
|
||||||
|
}
|
||||||
93
bot/modules/user/enrollment.interaction.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { ButtonInteraction, MessageFlags } from "discord.js";
|
||||||
|
import { config } from "@shared/lib/config";
|
||||||
|
import { getEnrollmentSuccessMessage } from "./enrollment.view";
|
||||||
|
import { classService } from "@shared/modules/class/class.service";
|
||||||
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
|
import { UserError } from "@/lib/errors";
|
||||||
|
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
||||||
|
|
||||||
|
export async function handleEnrollmentInteraction(interaction: ButtonInteraction) {
|
||||||
|
if (!interaction.inCachedGuild()) {
|
||||||
|
throw new UserError("This action can only be performed in a server.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { studentRole, visitorRole } = config;
|
||||||
|
|
||||||
|
if (!studentRole || !visitorRole) {
|
||||||
|
throw new UserError("No student or visitor role configured for enrollment.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Ensure user exists in DB and check current enrollment status
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check DB enrollment
|
||||||
|
if (user.class) {
|
||||||
|
throw new UserError("You are already enrolled in a class.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = interaction.member;
|
||||||
|
|
||||||
|
// Check Discord role enrollment (Double safety)
|
||||||
|
if (member.roles.cache.has(studentRole)) {
|
||||||
|
throw new UserError("You already have the student role.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get available classes
|
||||||
|
const allClasses = await classService.getAllClasses();
|
||||||
|
const validClasses = allClasses.filter((c: any) => c.roleId);
|
||||||
|
|
||||||
|
if (validClasses.length === 0) {
|
||||||
|
throw new UserError("No classes with specified roles found in database.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Pick random class
|
||||||
|
const selectedClass = validClasses[Math.floor(Math.random() * validClasses.length)]!;
|
||||||
|
const classRoleId = selectedClass.roleId!;
|
||||||
|
|
||||||
|
// Check if the role exists in the guild
|
||||||
|
const classRole = interaction.guild.roles.cache.get(classRoleId);
|
||||||
|
if (!classRole) {
|
||||||
|
throw new UserError(`The configured role ID \`${classRoleId}\` for class **${selectedClass.name}** does not exist in this server.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Perform Enrollment Actions
|
||||||
|
await member.roles.remove(visitorRole);
|
||||||
|
await member.roles.add(studentRole);
|
||||||
|
await member.roles.add(classRole);
|
||||||
|
|
||||||
|
// Persist to DB
|
||||||
|
await classService.assignClass(user.id.toString(), selectedClass.id);
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
...getEnrollmentSuccessMessage(classRole.name),
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Send Welcome Message (if configured)
|
||||||
|
if (config.welcomeChannelId) {
|
||||||
|
const welcomeChannel = interaction.guild.channels.cache.get(config.welcomeChannelId);
|
||||||
|
if (welcomeChannel && welcomeChannel.isTextBased()) {
|
||||||
|
const rawMessage = config.welcomeMessage || "Welcome to Aurora, {user}! You have been enrolled as a **{class}**.";
|
||||||
|
|
||||||
|
const processedMessage = rawMessage
|
||||||
|
.replace(/{user}/g, member.toString())
|
||||||
|
.replace(/{username}/g, member.user.username)
|
||||||
|
.replace(/{class}/g, selectedClass.name)
|
||||||
|
.replace(/{guild}/g, interaction.guild.name);
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(processedMessage);
|
||||||
|
} catch {
|
||||||
|
payload = processedMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire and forget webhook
|
||||||
|
sendWebhookMessage(welcomeChannel, payload, interaction.client.user, "New Student Enrollment")
|
||||||
|
.catch((err: any) => console.error("Failed to send welcome message:", err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||