forked from syntaxbullet/AuroraBot-discord
Compare commits
62 Commits
216189b0a4
...
a227e5db59
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -42,4 +42,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
.DS_Store
|
||||
|
||||
src/db/data
|
||||
src/db/log
|
||||
src/db/log
|
||||
scratchpad/
|
||||
|
||||
@@ -13,6 +13,7 @@ services:
|
||||
- ./src/db/log:/var/log/postgresql
|
||||
app:
|
||||
container_name: aurora_app
|
||||
restart: unless-stopped
|
||||
image: aurora-app
|
||||
build:
|
||||
context: .
|
||||
|
||||
72
docs/MODULE_STRUCTURE.md
Normal file
72
docs/MODULE_STRUCTURE.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Aurora Module Structure Guide
|
||||
|
||||
This guide documents the standard module organization patterns used in the Aurora codebase. Following these patterns ensures consistency, maintainability, and clear separation of concerns.
|
||||
|
||||
## Module Anatomy
|
||||
|
||||
A typical module in `@modules/` is organized into several files, each with a specific responsibility.
|
||||
|
||||
Example: `trade` module
|
||||
- `trade.service.ts`: Business logic and data access.
|
||||
- `trade.view.ts`: Discord UI components (embeds, modals, select menus).
|
||||
- `trade.interaction.ts`: Handler for interaction events (buttons, modals, etc.).
|
||||
- `trade.types.ts`: TypeScript interfaces and types.
|
||||
- `trade.service.test.ts`: Unit tests for the service logic.
|
||||
|
||||
## File Responsibilities
|
||||
|
||||
### 1. Service (`*.service.ts`)
|
||||
The core of the module. It contains the business logic, database interactions (using Drizzle), and state management.
|
||||
- **Rules**:
|
||||
- Export a singleton instance: `export const tradeService = new TradeService();`
|
||||
- Should not contain Discord-specific rendering logic (return data, not embeds).
|
||||
- Throw `UserError` for validation issues that should be shown to the user.
|
||||
|
||||
### 2. View (`*.view.ts`)
|
||||
Handles the creation of Discord-specific UI elements like `EmbedBuilder`, `ActionRowBuilder`, and `ModalBuilder`.
|
||||
- **Rules**:
|
||||
- Focus on formatting and presentation.
|
||||
- Takes raw data (from services) and returns Discord components.
|
||||
|
||||
### 3. Interaction Handler (`*.interaction.ts`)
|
||||
The entry point for Discord component interactions (buttons, select menus, modals).
|
||||
- **Rules**:
|
||||
- Export a single handler function: `export async function handleTradeInteraction(interaction: Interaction) { ... }`
|
||||
- Routes internal `customId` patterns to specific logic.
|
||||
- Relies on `ComponentInteractionHandler` for centralized error handling.
|
||||
- **No local try-catch** for standard validation errors; let them bubble up as `UserError`.
|
||||
|
||||
### 4. Types (`*.types.ts`)
|
||||
Central location for module-specific TypeScript types and constants.
|
||||
- **Rules**:
|
||||
- Define interfaces for complex data structures.
|
||||
- Use enums or literal types for states and custom IDs.
|
||||
|
||||
## Interaction Routing
|
||||
|
||||
All interaction handlers must be registered in `src/lib/interaction.routes.ts`.
|
||||
|
||||
```typescript
|
||||
{
|
||||
predicate: (i) => i.customId.startsWith("module_"),
|
||||
handler: () => import("@/modules/module/module.interaction"),
|
||||
method: 'handleModuleInteraction'
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling Standards
|
||||
|
||||
Aurora uses a centralized error handling pattern in `ComponentInteractionHandler`.
|
||||
|
||||
1. **UserError**: Use this for validation errors or issues the user can fix (e.g., "Insufficient funds").
|
||||
- `throw new UserError("You need more coins!");`
|
||||
2. **SystemError / Generic Error**: Use this for unexpected system failures.
|
||||
- These are logged to the console/logger and show a generic "Unexpected error" message to the user.
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- **Directory Name**: Lowercase, singular (e.g., `trade`, `inventory`).
|
||||
- **File Names**: `moduleName.type.ts` (e.g., `trade.service.ts`).
|
||||
- **Class Names**: PascalCase (e.g., `TradeService`).
|
||||
- **Service Instances**: camelCase (e.g., `tradeService`).
|
||||
- **Interaction Method**: `handle[ModuleName]Interaction`.
|
||||
17
drizzle/0001_heavy_thundra.sql
Normal file
17
drizzle/0001_heavy_thundra.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE "moderation_cases" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"case_id" varchar(50) NOT NULL,
|
||||
"type" varchar(20) NOT NULL,
|
||||
"user_id" bigint NOT NULL,
|
||||
"username" varchar(255) NOT NULL,
|
||||
"moderator_id" bigint NOT NULL,
|
||||
"moderator_name" varchar(255) NOT NULL,
|
||||
"reason" text NOT NULL,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb,
|
||||
"active" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"resolved_at" timestamp with time zone,
|
||||
"resolved_by" bigint,
|
||||
"resolved_reason" text,
|
||||
CONSTRAINT "moderation_cases_case_id_unique" UNIQUE("case_id")
|
||||
);
|
||||
878
drizzle/meta/0001_snapshot.json
Normal file
878
drizzle/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,878 @@
|
||||
{
|
||||
"id": "72cb5e22-fb44-4db8-9527-020dbec017d0",
|
||||
"prevId": "d43c3f7b-afe5-4974-ab67-fcd69256f3d8",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.classes": {
|
||||
"name": "classes",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigint",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"balance": {
|
||||
"name": "balance",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "0"
|
||||
},
|
||||
"role_id": {
|
||||
"name": "role_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"classes_name_unique": {
|
||||
"name": "classes_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.inventory": {
|
||||
"name": "inventory",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"item_id": {
|
||||
"name": "item_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"quantity": {
|
||||
"name": "quantity",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "1"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"inventory_user_id_users_id_fk": {
|
||||
"name": "inventory_user_id_users_id_fk",
|
||||
"tableFrom": "inventory",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"inventory_item_id_items_id_fk": {
|
||||
"name": "inventory_item_id_items_id_fk",
|
||||
"tableFrom": "inventory",
|
||||
"tableTo": "items",
|
||||
"columnsFrom": [
|
||||
"item_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"inventory_user_id_item_id_pk": {
|
||||
"name": "inventory_user_id_item_id_pk",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"item_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"quantity_check": {
|
||||
"name": "quantity_check",
|
||||
"value": "\"inventory\".\"quantity\" > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.item_transactions": {
|
||||
"name": "item_transactions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigserial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"related_user_id": {
|
||||
"name": "related_user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"item_id": {
|
||||
"name": "item_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"quantity": {
|
||||
"name": "quantity",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"item_transactions_user_id_users_id_fk": {
|
||||
"name": "item_transactions_user_id_users_id_fk",
|
||||
"tableFrom": "item_transactions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"item_transactions_related_user_id_users_id_fk": {
|
||||
"name": "item_transactions_related_user_id_users_id_fk",
|
||||
"tableFrom": "item_transactions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"related_user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"item_transactions_item_id_items_id_fk": {
|
||||
"name": "item_transactions_item_id_items_id_fk",
|
||||
"tableFrom": "item_transactions",
|
||||
"tableTo": "items",
|
||||
"columnsFrom": [
|
||||
"item_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.items": {
|
||||
"name": "items",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"rarity": {
|
||||
"name": "rarity",
|
||||
"type": "varchar(20)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'Common'"
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'MATERIAL'"
|
||||
},
|
||||
"usage_data": {
|
||||
"name": "usage_data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"price": {
|
||||
"name": "price",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"icon_url": {
|
||||
"name": "icon_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image_url": {
|
||||
"name": "image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"items_name_unique": {
|
||||
"name": "items_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.lootdrops": {
|
||||
"name": "lootdrops",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"message_id": {
|
||||
"name": "message_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"channel_id": {
|
||||
"name": "channel_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"reward_amount": {
|
||||
"name": "reward_amount",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"currency": {
|
||||
"name": "currency",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"claimed_by": {
|
||||
"name": "claimed_by",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"lootdrops_claimed_by_users_id_fk": {
|
||||
"name": "lootdrops_claimed_by_users_id_fk",
|
||||
"tableFrom": "lootdrops",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"claimed_by"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.moderation_cases": {
|
||||
"name": "moderation_cases",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigserial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"case_id": {
|
||||
"name": "case_id",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(20)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"moderator_id": {
|
||||
"name": "moderator_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"moderator_name": {
|
||||
"name": "moderator_name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"reason": {
|
||||
"name": "reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"active": {
|
||||
"name": "active",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"resolved_by": {
|
||||
"name": "resolved_by",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"resolved_reason": {
|
||||
"name": "resolved_reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"moderation_cases_case_id_unique": {
|
||||
"name": "moderation_cases_case_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"case_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.quests": {
|
||||
"name": "quests",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"trigger_event": {
|
||||
"name": "trigger_event",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"requirements": {
|
||||
"name": "requirements",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"rewards": {
|
||||
"name": "rewards",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.transactions": {
|
||||
"name": "transactions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigserial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"related_user_id": {
|
||||
"name": "related_user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"amount": {
|
||||
"name": "amount",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"transactions_user_id_users_id_fk": {
|
||||
"name": "transactions_user_id_users_id_fk",
|
||||
"tableFrom": "transactions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"transactions_related_user_id_users_id_fk": {
|
||||
"name": "transactions_related_user_id_users_id_fk",
|
||||
"tableFrom": "transactions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"related_user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user_quests": {
|
||||
"name": "user_quests",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"quest_id": {
|
||||
"name": "quest_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"progress": {
|
||||
"name": "progress",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_quests_user_id_users_id_fk": {
|
||||
"name": "user_quests_user_id_users_id_fk",
|
||||
"tableFrom": "user_quests",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"user_quests_quest_id_quests_id_fk": {
|
||||
"name": "user_quests_quest_id_quests_id_fk",
|
||||
"tableFrom": "user_quests",
|
||||
"tableTo": "quests",
|
||||
"columnsFrom": [
|
||||
"quest_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"user_quests_user_id_quest_id_pk": {
|
||||
"name": "user_quests_user_id_quest_id_pk",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"quest_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user_timers": {
|
||||
"name": "user_timers",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'{}'::jsonb"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_timers_user_id_users_id_fk": {
|
||||
"name": "user_timers_user_id_users_id_fk",
|
||||
"tableFrom": "user_timers",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"user_timers_user_id_type_key_pk": {
|
||||
"name": "user_timers_user_id_type_key_pk",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"type",
|
||||
"key"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "bigint",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"class_id": {
|
||||
"name": "class_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"balance": {
|
||||
"name": "balance",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "0"
|
||||
},
|
||||
"xp": {
|
||||
"name": "xp",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "0"
|
||||
},
|
||||
"level": {
|
||||
"name": "level",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 1
|
||||
},
|
||||
"daily_streak": {
|
||||
"name": "daily_streak",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"users_class_id_classes_id_fk": {
|
||||
"name": "users_class_id_classes_id_fk",
|
||||
"tableFrom": "users",
|
||||
"tableTo": "classes",
|
||||
"columnsFrom": [
|
||||
"class_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
"when": 1766137924760,
|
||||
"tag": "0000_fixed_tomas",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1766606046050,
|
||||
"tag": "0001_heavy_thundra",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
src/assets/graphics/lootdrop/template.png
Normal file
BIN
src/assets/graphics/lootdrop/template.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
54
src/commands/admin/case.ts
Normal file
54
src/commands/admin/case.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@/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
src/commands/admin/cases.ts
Normal file
54
src/commands/admin/cases.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@/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
src/commands/admin/clearwarning.ts
Normal file
84
src/commands/admin/clearwarning.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@/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.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
94
src/commands/admin/create_color.ts
Normal file
94
src/commands/admin/create_color.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js";
|
||||
import { config, saveConfig } from "@/lib/config";
|
||||
import { DrizzleClient } from "@/lib/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,5 +1,6 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from "discord.js";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createBaseEmbed } from "@lib/embeds";
|
||||
import { configManager } from "@/lib/configManager";
|
||||
import { config, reloadConfig } from "@/lib/config";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
@@ -45,9 +46,7 @@ export const features = createCommand({
|
||||
const overrides = Object.entries(config.commands)
|
||||
.map(([name, enabled]) => `• **${name}**: ${enabled ? "✅ Enabled (Override)" : "❌ Disabled"}`);
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("Command Features")
|
||||
.setColor("Blue");
|
||||
const embed = createBaseEmbed("Command Features", undefined, "Blue");
|
||||
|
||||
// Add fields for each category
|
||||
const sortedCategories = [...categories.keys()].sort();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import {
|
||||
SlashCommandBuilder,
|
||||
EmbedBuilder,
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
@@ -10,10 +9,12 @@ import {
|
||||
MessageFlags
|
||||
} from "discord.js";
|
||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
|
||||
import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { items } from "@/db/schema";
|
||||
import { ilike, isNotNull, and } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import { getShopListingMessage } from "@/modules/economy/shop.view";
|
||||
|
||||
export const listing = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -49,33 +50,26 @@ export const listing = createCommand({
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`Shop: ${item.name}`)
|
||||
.setDescription(item.description || "No description available.")
|
||||
.addFields({ name: "Price", value: `${item.price} 🪙`, inline: true })
|
||||
.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);
|
||||
const listingMessage = getShopListingMessage({
|
||||
...item,
|
||||
formattedPrice: `${item.price} 🪙`,
|
||||
price: item.price
|
||||
});
|
||||
|
||||
try {
|
||||
await targetChannel.send({ embeds: [embed], components: [actionRow] });
|
||||
await targetChannel.send(listingMessage);
|
||||
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
|
||||
} catch (error) {
|
||||
console.error("Failed to send listing message:", error);
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Failed to post the listing.")] });
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
||||
} else {
|
||||
console.error("Error creating listing:", error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
autocomplete: async (interaction) => {
|
||||
|
||||
61
src/commands/admin/note.ts
Normal file
61
src/commands/admin/note.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@/modules/moderation/moderation.service";
|
||||
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: '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
src/commands/admin/notes.ts
Normal file
43
src/commands/admin/notes.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@/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
src/commands/admin/prune.ts
Normal file
179
src/commands/admin/prune.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||
import { config } from "@/lib/config";
|
||||
import { PruneService } from "@/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 { AuroraClient } from "@/lib/BotClient";
|
||||
import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createErrorEmbed, createSuccessEmbed, createWarningEmbed } from "@lib/embeds";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
|
||||
export const refresh = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
|
||||
37
src/commands/admin/terminal.ts
Normal file
37
src/commands/admin/terminal.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
|
||||
import { terminalService } from "@/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." });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,124 +1,99 @@
|
||||
import { createCommand } from "@lib/utils";
|
||||
import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits, MessageFlags, ButtonBuilder, ButtonStyle, ActionRowBuilder, ComponentType } from "discord.js";
|
||||
import { createErrorEmbed, createSuccessEmbed, createWarningEmbed, createInfoEmbed } from "@lib/embeds";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||
import { UpdateService } from "@/modules/admin/update.service";
|
||||
import {
|
||||
getCheckingEmbed,
|
||||
getNoUpdatesEmbed,
|
||||
getUpdatesAvailableMessage,
|
||||
getPreparingEmbed,
|
||||
getUpdatingEmbed,
|
||||
getCancelledEmbed,
|
||||
getTimeoutEmbed,
|
||||
getErrorEmbed
|
||||
} from "@/modules/admin/update.view";
|
||||
|
||||
export const update = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("update")
|
||||
.setDescription("Check for updates and restart the bot")
|
||||
.addBooleanOption(option =>
|
||||
option.setName("force")
|
||||
.setDescription("Force update even if checks fail (not recommended)")
|
||||
.setRequired(false)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const { exec } = await import("child_process");
|
||||
const { promisify } = await import("util");
|
||||
const { writeFile, appendFile } = await import("fs/promises");
|
||||
const execAsync = promisify(exec);
|
||||
const force = interaction.options.getBoolean("force") || false;
|
||||
|
||||
try {
|
||||
// Get current branch
|
||||
const { stdout: branchName } = await execAsync("git rev-parse --abbrev-ref HEAD");
|
||||
const branch = branchName.trim();
|
||||
await interaction.editReply({ embeds: [getCheckingEmbed()] });
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createInfoEmbed("Fetching latest changes...", "Checking for Updates")]
|
||||
});
|
||||
const { hasUpdates, log, branch } = await UpdateService.checkForUpdates();
|
||||
|
||||
// Fetch remote
|
||||
await execAsync("git fetch --all");
|
||||
|
||||
// Check for potential changes
|
||||
const { stdout: logOutput } = await execAsync(`git log HEAD..origin/${branch} --oneline`);
|
||||
|
||||
if (!logOutput.trim()) {
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed("The bot is already up to date.", "No Updates Found")]
|
||||
});
|
||||
if (!hasUpdates && !force) {
|
||||
await interaction.editReply({ embeds: [getNoUpdatesEmbed()] });
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare confirmation UI
|
||||
const confirmButton = new ButtonBuilder()
|
||||
.setCustomId("confirm_update")
|
||||
.setLabel("Update & Restart")
|
||||
.setStyle(ButtonStyle.Success);
|
||||
|
||||
const cancelButton = new ButtonBuilder()
|
||||
.setCustomId("cancel_update")
|
||||
.setLabel("Cancel")
|
||||
.setStyle(ButtonStyle.Secondary);
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>()
|
||||
.addComponents(confirmButton, cancelButton);
|
||||
|
||||
const updateEmbed = createInfoEmbed(
|
||||
`The following changes are available:\n\`\`\`\n${logOutput.substring(0, 1000)}${logOutput.length > 1000 ? "\n...and more" : ""}\n\`\`\`\n**Do you want to update and restart?**`,
|
||||
"Updates Available"
|
||||
);
|
||||
|
||||
const response = await interaction.editReply({
|
||||
embeds: [updateEmbed],
|
||||
components: [row]
|
||||
});
|
||||
const { embeds, components } = getUpdatesAvailableMessage(branch, log, force);
|
||||
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 // 30 seconds timeout
|
||||
time: 30000
|
||||
});
|
||||
|
||||
if (confirmation.customId === "confirm_update") {
|
||||
await confirmation.update({
|
||||
embeds: [createWarningEmbed("Applying updates and restarting...\nThe bot will run database migrations on next startup.", "Update In Progress")],
|
||||
embeds: [getPreparingEmbed()],
|
||||
components: []
|
||||
});
|
||||
|
||||
// Write context BEFORE reset, because reset -> watcher restart
|
||||
await writeFile(".restart_context.json", JSON.stringify({
|
||||
// 1. Check what the update requires
|
||||
const { needsInstall, needsMigrations } = await UpdateService.checkUpdateRequirements(branch);
|
||||
|
||||
// 2. Prepare context BEFORE update
|
||||
await UpdateService.prepareRestartContext({
|
||||
channelId: interaction.channelId,
|
||||
userId: interaction.user.id,
|
||||
timestamp: Date.now(),
|
||||
runMigrations: true
|
||||
}));
|
||||
runMigrations: needsMigrations,
|
||||
installDependencies: needsInstall
|
||||
});
|
||||
|
||||
const { stdout } = await execAsync(`git reset --hard origin/${branch}`);
|
||||
// 3. Update UI to "Restarting" state
|
||||
await interaction.editReply({ embeds: [getUpdatingEmbed(needsInstall)] });
|
||||
|
||||
// In case we are not running with a watcher, or if no files were changed (unlikely given log check),
|
||||
// we might need to manually trigger restart.
|
||||
// But if files changed, watcher kicks in here or slightly after.
|
||||
// If we are here, we can try to force a touch or just exit.
|
||||
// 4. Perform Update (Danger Zone)
|
||||
await UpdateService.performUpdate(branch);
|
||||
|
||||
// Trigger restart just in case watcher didn't catch it or we are in a mode without watcher (though update implies source change)
|
||||
try {
|
||||
await appendFile("src/index.ts", " ");
|
||||
} catch (err) {
|
||||
console.error("Failed to touch triggers:", err);
|
||||
}
|
||||
|
||||
// The process should die now or soon.
|
||||
// We do NOT run migrations here anymore.
|
||||
// 5. Trigger Restart (if we are still alive)
|
||||
await UpdateService.triggerRestart();
|
||||
|
||||
} else {
|
||||
await confirmation.update({
|
||||
embeds: [createInfoEmbed("Update cancelled.", "Cancelled")],
|
||||
embeds: [getCancelledEmbed()],
|
||||
components: []
|
||||
});
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
// Timeout
|
||||
await interaction.editReply({
|
||||
embeds: [createWarningEmbed("Update confirmation timed out.", "Timed Out")],
|
||||
components: []
|
||||
});
|
||||
if (e instanceof Error && e.message.includes("time")) {
|
||||
await interaction.editReply({
|
||||
embeds: [getTimeoutEmbed()],
|
||||
components: []
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(`Failed to check for updates:\n\`\`\`\n${error instanceof Error ? error.message : String(error)}\n\`\`\``, "Update Check Failed")]
|
||||
});
|
||||
console.error("Update failed:", error);
|
||||
await interaction.editReply({ embeds: [getErrorEmbed(error)] });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
131
src/commands/admin/warn.ts
Normal file
131
src/commands/admin/warn.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@/modules/moderation/moderation.service";
|
||||
import {
|
||||
getWarnSuccessEmbed,
|
||||
getModerationErrorEmbed,
|
||||
getUserWarningEmbed
|
||||
} from "@/modules/moderation/moderation.view";
|
||||
import { config } from "@/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;
|
||||
}
|
||||
|
||||
// Create the warning case
|
||||
const moderationCase = await ModerationService.createCase({
|
||||
type: 'warn',
|
||||
userId: targetUser.id,
|
||||
username: targetUser.username,
|
||||
moderatorId: interaction.user.id,
|
||||
moderatorName: interaction.user.username,
|
||||
reason,
|
||||
});
|
||||
|
||||
if (!moderationCase) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("Failed to create warning case.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get total warning count for the user
|
||||
const warningCount = await ModerationService.getActiveWarningCount(targetUser.id);
|
||||
|
||||
// Send success message to moderator
|
||||
await interaction.editReply({
|
||||
embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
|
||||
});
|
||||
|
||||
// Try to DM the user if configured
|
||||
if (config.moderation.cases.dmOnWarn) {
|
||||
try {
|
||||
const serverName = interaction.guild?.name || 'this server';
|
||||
await targetUser.send({
|
||||
embeds: [getUserWarningEmbed(serverName, reason, moderationCase.caseId, warningCount)]
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail if user has DMs disabled
|
||||
console.log(`Could not DM warning to ${targetUser.username}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: Check for auto-timeout threshold
|
||||
if (config.moderation.cases.autoTimeoutThreshold &&
|
||||
warningCount >= config.moderation.cases.autoTimeoutThreshold) {
|
||||
|
||||
try {
|
||||
const member = await interaction.guild?.members.fetch(targetUser.id);
|
||||
if (member) {
|
||||
// Auto-timeout for 24 hours (86400000 ms)
|
||||
await member.timeout(86400000, `Automatic timeout: ${warningCount} warnings`);
|
||||
|
||||
// Create a timeout case
|
||||
await ModerationService.createCase({
|
||||
type: 'timeout',
|
||||
userId: targetUser.id,
|
||||
username: targetUser.username,
|
||||
moderatorId: interaction.client.user!.id,
|
||||
moderatorName: interaction.client.user!.username,
|
||||
reason: `Automatic timeout: reached ${warningCount} warnings`,
|
||||
metadata: { duration: '24h', automatic: true }
|
||||
});
|
||||
|
||||
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('Failed to auto-timeout user:', error);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Warn command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while issuing the warning.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
39
src/commands/admin/warnings.ts
Normal file
39
src/commands/admin/warnings.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@/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 { SlashCommandBuilder, PermissionFlagsBits, TextChannel, NewsChannel, VoiceChannel, MessageFlags } from "discord.js";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { createWarningEmbed } from "@/lib/embeds";
|
||||
import { createBaseEmbed } from "@lib/embeds";
|
||||
|
||||
export const balance = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -18,15 +18,13 @@ export const balance = createCommand({
|
||||
const targetUser = interaction.options.getUser("user") || interaction.user;
|
||||
|
||||
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 embed = new EmbedBuilder()
|
||||
.setAuthor({ name: targetUser.username, iconURL: targetUser.displayAvatarURL() })
|
||||
.setDescription(`**Balance**: ${user.balance || 0n} AU`)
|
||||
.setColor("Yellow");
|
||||
const embed = createBaseEmbed(undefined, `**Balance**: ${user.balance || 0n} AU`, "Yellow")
|
||||
.setAuthor({ name: targetUser.username, iconURL: targetUser.displayAvatarURL() });
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, EmbedBuilder, MessageFlags } from "discord.js";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { economyService } from "@/modules/economy/economy.service";
|
||||
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
|
||||
export const daily = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -11,26 +13,23 @@ export const daily = createCommand({
|
||||
try {
|
||||
const result = await economyService.claimDaily(interaction.user.id);
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("💰 Daily Reward Claimed!")
|
||||
.setDescription(`You claimed **${result.amount}** Astral Units!`)
|
||||
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: "Next Reward", value: `<t:${Math.floor(result.nextReadyAt.getTime() / 1000)}:R>`, 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")
|
||||
.setTimestamp();
|
||||
.setColor("Gold");
|
||||
|
||||
await interaction.reply({ embeds: [embed] });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error.message.includes("Daily already claimed")) {
|
||||
await interaction.reply({ embeds: [createWarningEmbed(error.message, "Cooldown")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
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 });
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An error occurred while claiming your daily reward.")], flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { userTimers, users } from "@/db/schema";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
@@ -40,8 +41,9 @@ export const exam = createCommand({
|
||||
// 2. First Run Logic
|
||||
if (!timer) {
|
||||
// Set exam day to today
|
||||
const nextWeek = new Date(now);
|
||||
nextWeek.setDate(now.getDate() + 7);
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + 7);
|
||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||
|
||||
const metadata: ExamMetadata = {
|
||||
examDay: currentDay,
|
||||
@@ -52,14 +54,14 @@ export const exam = createCommand({
|
||||
userId: user.id,
|
||||
type: EXAM_TIMER_TYPE,
|
||||
key: EXAM_TIMER_KEY,
|
||||
expiresAt: nextWeek,
|
||||
expiresAt: nextExamDate,
|
||||
metadata: metadata
|
||||
});
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}**.\n` +
|
||||
`Come back next week on **${DAYS[currentDay]}** to take your first exam and earn rewards based on your XP gain!`,
|
||||
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}** (Server Time).\n` +
|
||||
`Come back on <t:${nextExamTimestamp}:F> (<t:${nextExamTimestamp}:R>) to take your first exam!`,
|
||||
"Exam Registration Successful"
|
||||
)]
|
||||
});
|
||||
@@ -73,13 +75,12 @@ export const exam = createCommand({
|
||||
if (now < new Date(timer.expiresAt)) {
|
||||
// Calculate time remaining
|
||||
const expiresAt = new Date(timer.expiresAt);
|
||||
// Simple formatting
|
||||
const timestamp = Math.floor(expiresAt.getTime() / 1000);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
||||
`Next exam available: <t:${timestamp}:R> (${DAYS[examDay]})`
|
||||
`Next exam available: <t:${timestamp}:F> (<t:${timestamp}:R>)`
|
||||
)]
|
||||
});
|
||||
return;
|
||||
@@ -87,11 +88,13 @@ export const exam = createCommand({
|
||||
|
||||
// 4. Day Check
|
||||
if (currentDay !== examDay) {
|
||||
// "If not executed on same weekday... we do not reward"
|
||||
// Consume the attempt (reset timer) but give 0 reward.
|
||||
// Calculate next correct exam day to correct the schedule
|
||||
let daysUntil = (examDay - currentDay + 7) % 7;
|
||||
if (daysUntil === 0) daysUntil = 7;
|
||||
|
||||
const nextWeek = new Date(now);
|
||||
nextWeek.setDate(now.getDate() + 7);
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + daysUntil);
|
||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||
|
||||
const newMetadata: ExamMetadata = {
|
||||
examDay: examDay,
|
||||
@@ -100,7 +103,7 @@ export const exam = createCommand({
|
||||
|
||||
await DrizzleClient.update(userTimers)
|
||||
.set({
|
||||
expiresAt: nextWeek,
|
||||
expiresAt: nextExamDate,
|
||||
metadata: newMetadata
|
||||
})
|
||||
.where(and(
|
||||
@@ -111,8 +114,9 @@ export const exam = createCommand({
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You missed your exam day! Your exam is on **${DAYS[examDay]}**, but today is ${DAYS[currentDay]}.\n` +
|
||||
`You verify your attendance but score a **0**. Come back next **${DAYS[examDay]}**!`,
|
||||
`You missed your exam day! Your exam day is **${DAYS[examDay]}** (Server Time).\n` +
|
||||
`You verify your attendance but score a **0**.\n` +
|
||||
`Your next exam opportunity is: <t:${nextExamTimestamp}:F> (<t:${nextExamTimestamp}:R>)`,
|
||||
"Exam Failed"
|
||||
)]
|
||||
});
|
||||
@@ -137,8 +141,9 @@ export const exam = createCommand({
|
||||
}
|
||||
|
||||
// 6. Update State
|
||||
const nextWeek = new Date(now);
|
||||
nextWeek.setDate(now.getDate() + 7);
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + 7);
|
||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||
|
||||
const newMetadata: ExamMetadata = {
|
||||
examDay: examDay,
|
||||
@@ -149,7 +154,7 @@ export const exam = createCommand({
|
||||
// Update Timer
|
||||
await tx.update(userTimers)
|
||||
.set({
|
||||
expiresAt: nextWeek,
|
||||
expiresAt: nextExamDate,
|
||||
metadata: newMetadata
|
||||
})
|
||||
.where(and(
|
||||
@@ -173,14 +178,18 @@ export const exam = createCommand({
|
||||
`**XP Gained:** ${diff.toString()}\n` +
|
||||
`**Multiplier:** x${multiplier.toFixed(2)}\n` +
|
||||
`**Reward:** ${reward.toString()} Currency\n\n` +
|
||||
`See you next week on **${DAYS[examDay]}**!`,
|
||||
`See you next week: <t:${nextExamTimestamp}:F>`,
|
||||
"Exam Passed!"
|
||||
)]
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Exam command error:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An error occurred while processing your exam.")] });
|
||||
if (error instanceof UserError) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
||||
} else {
|
||||
console.error("Error in exam command:", error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, EmbedBuilder, MessageFlags } from "discord.js";
|
||||
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
||||
import { economyService } from "@/modules/economy/economy.service";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { config } from "@/lib/config";
|
||||
import { createErrorEmbed, createWarningEmbed } from "@/lib/embeds";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
|
||||
export const pay = createCommand({
|
||||
@@ -26,7 +27,7 @@ export const pay = createCommand({
|
||||
const discordUser = interaction.options.getUser("user", true);
|
||||
|
||||
if (discordUser.bot) {
|
||||
await interaction.reply({ embeds: [createWarningEmbed("You cannot send money to bots.")], flags: MessageFlags.Ephemeral });
|
||||
await interaction.reply({ embeds: [createErrorEmbed("You cannot send money to bots.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -35,33 +36,29 @@ export const pay = createCommand({
|
||||
const receiverId = targetUser.id;
|
||||
|
||||
if (amount < config.economy.transfers.minAmount) {
|
||||
await interaction.reply({ embeds: [createWarningEmbed(`Amount must be at least ${config.economy.transfers.minAmount}.`)], flags: MessageFlags.Ephemeral });
|
||||
await interaction.reply({ embeds: [createErrorEmbed(`Amount must be at least ${config.economy.transfers.minAmount}.`)], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
if (senderId === receiverId) {
|
||||
await interaction.reply({ embeds: [createWarningEmbed("You cannot pay yourself.")], flags: MessageFlags.Ephemeral });
|
||||
await interaction.reply({ embeds: [createErrorEmbed("You cannot pay yourself.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await interaction.deferReply();
|
||||
await economyService.transfer(senderId, receiverId, amount);
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("💸 Transfer Successful")
|
||||
.setDescription(`Successfully sent **${amount}** Astral Units to <@${targetUser.id}>.`)
|
||||
.setColor("Green")
|
||||
.setTimestamp();
|
||||
|
||||
await interaction.reply({ embeds: [embed] });
|
||||
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.reply({ embeds: [createWarningEmbed(error.message)], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error sending payment:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||
}
|
||||
console.error(error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("Transfer failed due to an unexpected error.")], flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, EmbedBuilder, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, ThreadAutoArchiveDuration, MessageFlags } from "discord.js";
|
||||
import { TradeService } from "@/modules/trade/trade.service";
|
||||
import { SlashCommandBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags } from "discord.js";
|
||||
import { tradeService } from "@/modules/trade/trade.service";
|
||||
import { getTradeDashboard } from "@/modules/trade/trade.view";
|
||||
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
|
||||
|
||||
export const trade = createCommand({
|
||||
@@ -58,32 +59,15 @@ export const trade = createCommand({
|
||||
}
|
||||
|
||||
// Setup Session
|
||||
TradeService.createSession(thread.id,
|
||||
const session = tradeService.createSession(thread.id,
|
||||
{ id: interaction.user.id, username: interaction.user.username },
|
||||
{ id: targetUser.id, username: targetUser.username }
|
||||
);
|
||||
|
||||
// Send Dashboard to Thread
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("🤝 Trading Session")
|
||||
.setDescription(`Trade started between ${interaction.user} and ${targetUser}.\nUse the controls below to build your offer.`)
|
||||
.setColor(0xFFD700)
|
||||
.addFields(
|
||||
{ name: interaction.user.username, value: "*Empty Offer*", inline: true },
|
||||
{ name: targetUser.username, value: "*Empty Offer*", inline: true }
|
||||
)
|
||||
.setFooter({ text: "Both parties must click Lock to confirm trade." });
|
||||
const dashboard = getTradeDashboard(session);
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder().setCustomId('trade_add_item').setLabel('Add Item').setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId('trade_add_money').setLabel('Add Money').setStyle(ButtonStyle.Success),
|
||||
new ButtonBuilder().setCustomId('trade_remove_item').setLabel('Remove Item').setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId('trade_lock').setLabel('Lock / Unlock').setStyle(ButtonStyle.Primary),
|
||||
new ButtonBuilder().setCustomId('trade_cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger),
|
||||
);
|
||||
|
||||
await thread.send({ content: `${interaction.user} ${targetUser} Welcome to your trading session!`, embeds: [embed], components: [row] });
|
||||
await thread.send({ content: `${interaction.user} ${targetUser} Welcome to your trading session!`, ...dashboard });
|
||||
|
||||
// Update original reply
|
||||
await interaction.editReply({ content: `✅ Trade opened: <#${thread.id}>` });
|
||||
|
||||
29
src/commands/feedback/feedback.ts
Normal file
29
src/commands/feedback/feedback.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { config } from "@/lib/config";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { getFeedbackTypeMenu } from "@/modules/feedback/feedback.view";
|
||||
|
||||
export const feedback = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("feedback")
|
||||
.setDescription("Submit feedback, feature requests, or bug reports"),
|
||||
execute: async (interaction) => {
|
||||
// Check if feedback channel is configured
|
||||
if (!config.feedbackChannelId) {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("Feedback system is not configured. Please contact an administrator.")],
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Show feedback type selection menu
|
||||
const menu = getFeedbackTypeMenu();
|
||||
await interaction.reply({
|
||||
content: "## 🌟 Share Your Thoughts\n\nThank you for helping improve Aurora! Please select the type of feedback you'd like to submit:",
|
||||
...menu,
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { createWarningEmbed } from "@lib/embeds";
|
||||
import { getInventoryEmbed } from "@/modules/inventory/inventory.view";
|
||||
|
||||
export const inventory = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -24,22 +25,19 @@ export const inventory = createCommand({
|
||||
}
|
||||
|
||||
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
||||
const items = await inventoryService.getInventory(user.id);
|
||||
if (!user) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("Failed to load user data.", "Error")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const items = await inventoryService.getInventory(user.id.toString());
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("Inventory is empty.", `${user.username}'s Inventory`)] });
|
||||
return;
|
||||
}
|
||||
|
||||
const description = items.map(entry => {
|
||||
return `**${entry.item.name}** x${entry.quantity}`;
|
||||
}).join("\n");
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`${user.username}'s Inventory`)
|
||||
.setDescription(description)
|
||||
.setColor("Blue")
|
||||
.setTimestamp();
|
||||
const embed = getInventoryEmbed(items, user.username);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, EmbedBuilder, MessageFlags } from "discord.js";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
||||
import { inventory, items } from "@/db/schema";
|
||||
import { eq, and, like } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import type { ItemUsageData } from "@/lib/types";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { config } from "@/lib/config";
|
||||
|
||||
export const use = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -23,18 +26,29 @@ export const use = createCommand({
|
||||
|
||||
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, itemId);
|
||||
const result = await inventoryService.useItem(user.id.toString(), itemId);
|
||||
|
||||
const usageData = result.usageData;
|
||||
if (usageData) {
|
||||
for (const effect of usageData.effects) {
|
||||
if (effect.type === 'TEMP_ROLE') {
|
||||
if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') {
|
||||
try {
|
||||
const member = await interaction.guild?.members.fetch(user.id);
|
||||
const member = await interaction.guild?.members.fetch(user.id.toString());
|
||||
if (member) {
|
||||
await member.roles.add(effect.roleId);
|
||||
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);
|
||||
@@ -44,16 +58,17 @@ export const use = createCommand({
|
||||
}
|
||||
}
|
||||
|
||||
const embed = createSuccessEmbed(
|
||||
result.results.map(r => `• ${r}`).join("\n"),
|
||||
`Used ${result.usageData.effects.length > 0 ? 'Item' : 'Item'}` // Generic title, improves below
|
||||
);
|
||||
embed.setTitle("Item Used!");
|
||||
const embed = getItemUseResultEmbed(result.results);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
|
||||
} catch (error: any) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
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) => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import { users } from "@/db/schema";
|
||||
import { desc } from "drizzle-orm";
|
||||
import { createWarningEmbed } from "@lib/embeds";
|
||||
import { getLeaderboardEmbed } from "@/modules/leveling/leveling.view";
|
||||
|
||||
export const leaderboard = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -34,17 +35,7 @@ export const leaderboard = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
const description = leaders.map((user, index) => {
|
||||
const medal = index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : `${index + 1}.`;
|
||||
const value = isXp ? `Lvl ${user.level} (${user.xp} XP)` : `${user.balance} 🪙`;
|
||||
return `${medal} **${user.username}** — ${value}`;
|
||||
}).join("\n");
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(isXp ? "🏆 XP Leaderboard" : "💰 Richest Players")
|
||||
.setDescription(description)
|
||||
.setColor("Gold")
|
||||
.setTimestamp();
|
||||
const embed = getLeaderboardEmbed(leaders, isXp ? 'xp' : 'balance');
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, EmbedBuilder, MessageFlags } from "discord.js";
|
||||
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
||||
import { questService } from "@/modules/quest/quest.service";
|
||||
import { createWarningEmbed } from "@lib/embeds";
|
||||
import { getQuestListEmbed } from "@/modules/quest/quest.view";
|
||||
|
||||
export const quests = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -17,24 +18,7 @@ export const quests = createCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("📜 Quest Log")
|
||||
.setColor("Blue")
|
||||
.setTimestamp();
|
||||
|
||||
userQuests.forEach(entry => {
|
||||
const status = entry.completedAt ? "✅ Completed" : "In Progress";
|
||||
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
||||
const rewardStr = [];
|
||||
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
|
||||
if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`);
|
||||
|
||||
embed.addFields({
|
||||
name: `${entry.quest.name} (${status})`,
|
||||
value: `${entry.quest.description}\n**Rewards:** ${rewardStr.join(", ")}\n**Progress:** ${entry.progress}%`,
|
||||
inline: false
|
||||
});
|
||||
});
|
||||
const embed = getQuestListEmbed(userQuests);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
|
||||
@@ -142,6 +142,25 @@ export const lootdrops = pgTable('lootdrops', {
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
});
|
||||
|
||||
// 10. Moderation Cases
|
||||
export const moderationCases = pgTable('moderation_cases', {
|
||||
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
|
||||
caseId: varchar('case_id', { length: 50 }).unique().notNull(),
|
||||
type: varchar('type', { length: 20 }).notNull(), // 'warn', 'timeout', 'kick', 'ban', 'note', 'prune'
|
||||
userId: bigint('user_id', { mode: 'bigint' }).notNull(),
|
||||
username: varchar('username', { length: 255 }).notNull(),
|
||||
moderatorId: bigint('moderator_id', { mode: 'bigint' }).notNull(),
|
||||
moderatorName: varchar('moderator_name', { length: 255 }).notNull(),
|
||||
reason: text('reason').notNull(),
|
||||
metadata: jsonb('metadata').default({}),
|
||||
active: boolean('active').default(true).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
|
||||
resolvedBy: bigint('resolved_by', { mode: 'bigint' }),
|
||||
resolvedReason: text('resolved_reason'),
|
||||
});
|
||||
|
||||
|
||||
|
||||
export const classesRelations = relations(classes, ({ many }) => ({
|
||||
users: many(users),
|
||||
@@ -215,4 +234,19 @@ export const itemTransactionsRelations = relations(itemTransactions, ({ one }) =
|
||||
fields: [itemTransactions.itemId],
|
||||
references: [items.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const moderationCasesRelations = relations(moderationCases, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [moderationCases.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
moderator: one(users, {
|
||||
fields: [moderationCases.moderatorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
resolver: one(users, {
|
||||
fields: [moderationCases.resolvedBy],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
@@ -1,78 +1,20 @@
|
||||
import { Events, MessageFlags } from "discord.js";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { Events } from "discord.js";
|
||||
import { ComponentInteractionHandler, AutocompleteHandler, CommandHandler } from "@/lib/handlers";
|
||||
import type { Event } from "@lib/types";
|
||||
|
||||
const event: Event<Events.InteractionCreate> = {
|
||||
name: Events.InteractionCreate,
|
||||
execute: async (interaction) => {
|
||||
// Handle Trade Interactions
|
||||
if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
|
||||
if (interaction.customId.startsWith("trade_") || interaction.customId === "amount") {
|
||||
await import("@/modules/trade/trade.interaction").then(m => m.handleTradeInteraction(interaction));
|
||||
return;
|
||||
}
|
||||
if (interaction.customId.startsWith("shop_buy_") && interaction.isButton()) {
|
||||
await import("@/modules/economy/shop.interaction").then(m => m.handleShopInteraction(interaction));
|
||||
return;
|
||||
}
|
||||
if (interaction.customId.startsWith("lootdrop_") && interaction.isButton()) {
|
||||
await import("@/modules/economy/lootdrop.interaction").then(m => m.handleLootdropInteraction(interaction));
|
||||
return;
|
||||
}
|
||||
if (interaction.customId.startsWith("createitem_")) {
|
||||
await import("@/modules/admin/item_wizard").then(m => m.handleItemWizardInteraction(interaction));
|
||||
return;
|
||||
}
|
||||
if (interaction.customId === "enrollment" && interaction.isButton()) {
|
||||
await import("@/modules/user/enrollment.interaction").then(m => m.handleEnrollmentInteraction(interaction));
|
||||
return;
|
||||
}
|
||||
return ComponentInteractionHandler.handle(interaction);
|
||||
}
|
||||
|
||||
if (interaction.isAutocomplete()) {
|
||||
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);
|
||||
}
|
||||
return;
|
||||
return AutocompleteHandler.handle(interaction);
|
||||
}
|
||||
|
||||
if (!interaction.isChatInputCommand()) return;
|
||||
|
||||
const command = AuroraClient.commands.get(interaction.commandName);
|
||||
|
||||
if (!command) {
|
||||
console.error(`No command matching ${interaction.commandName} was found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Ensure user exists in database
|
||||
try {
|
||||
const user = await userService.getUserById(interaction.user.id);
|
||||
if (!user) {
|
||||
console.log(`🆕 Creating new user entry for ${interaction.user.tag}`);
|
||||
await userService.createUser(interaction.user.id, interaction.user.username);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check/create user:", error);
|
||||
}
|
||||
|
||||
try {
|
||||
await command.execute(interaction);
|
||||
} catch (error) {
|
||||
console.error(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 });
|
||||
}
|
||||
if (interaction.isChatInputCommand()) {
|
||||
return CommandHandler.handle(interaction);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,8 +15,6 @@ const event: Event<Events.MessageCreate> = {
|
||||
levelingService.processChatXp(message.author.id);
|
||||
|
||||
// Activity Tracking for Lootdrops
|
||||
// We do dynamic import to avoid circular dependency issues if any, though likely not needed here.
|
||||
// But better safe for modules. Actually direct import is fine if structure is clean.
|
||||
import("@/modules/economy/lootdrop.service").then(m => m.lootdropService.processMessage(message));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,55 +9,9 @@ const event: Event<Events.ClientReady> = {
|
||||
console.log(`Ready! Logged in as ${c.user.tag}`);
|
||||
schedulerService.start();
|
||||
|
||||
// Check for restart context
|
||||
const { readFile, unlink } = await import("fs/promises");
|
||||
const { createSuccessEmbed } = await import("@lib/embeds");
|
||||
|
||||
try {
|
||||
const contextData = await readFile(".restart_context.json", "utf-8");
|
||||
const context = JSON.parse(contextData);
|
||||
|
||||
// Validate context freshness (e.g., ignore if older than 5 minutes)
|
||||
if (Date.now() - context.timestamp < 5 * 60 * 1000) {
|
||||
const channel = await c.channels.fetch(context.channelId);
|
||||
|
||||
if (channel && channel.isSendable()) {
|
||||
let migrationOutput = "";
|
||||
let success = true;
|
||||
|
||||
if (context.runMigrations) {
|
||||
try {
|
||||
const { exec } = await import("child_process");
|
||||
const { promisify } = await import("util");
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Send intermediate update if possible, though ready event should be fast.
|
||||
// Maybe just run it and report result.
|
||||
const { stdout: dbOut } = await execAsync("bun run db:push:local");
|
||||
migrationOutput = dbOut;
|
||||
} catch (dbErr: any) {
|
||||
success = false;
|
||||
migrationOutput = `Migration Failed: ${dbErr.message}`;
|
||||
console.error("Migration Error:", dbErr);
|
||||
}
|
||||
}
|
||||
|
||||
if (context.runMigrations) {
|
||||
await channel.send({
|
||||
embeds: [createSuccessEmbed(`Bot is back online!\n\n**DB Migration Output:**\n\`\`\`\n${migrationOutput}\n\`\`\``, success ? "Update Successful" : "Update Completed with/Errors")]
|
||||
});
|
||||
} else {
|
||||
await channel.send({
|
||||
embeds: [createSuccessEmbed("Bot is back online! Redeploy successful.", "System Online")]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await unlink(".restart_context.json");
|
||||
} catch (error) {
|
||||
// Ignore errors (file not found, etc.)
|
||||
}
|
||||
// Handle post-update tasks
|
||||
const { UpdateService } = await import("@/modules/admin/update.service");
|
||||
await UpdateService.handlePostRestart(c);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
135
src/graphics/lootdrop.ts
Normal file
135
src/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(), 'src', '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(), 'src', '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(), 'src', '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');
|
||||
}
|
||||
@@ -97,9 +97,12 @@ export async function generateStudentIdCard(data: StudentCardData): Promise<Buff
|
||||
ctx.restore();
|
||||
|
||||
// Draw XP Bar
|
||||
const xpForNextLevel = levelingService.getXpForLevel(data.level);
|
||||
const xpForThisLevel = levelingService.getXpForNextLevel(data.level); // The total size of the current level bucket
|
||||
const xpAtStartOfLevel = levelingService.getXpToReachLevel(data.level); // The accumulated XP when this level started
|
||||
const currentLevelProgress = Number(data.xp) - xpAtStartOfLevel; // How much XP into this level
|
||||
|
||||
const xpBarMaxWidth = 382;
|
||||
const xpBarWidth = xpBarMaxWidth * Number(data.xp) / Number(xpForNextLevel);
|
||||
const xpBarWidth = Math.max(0, Math.min(xpBarMaxWidth, xpBarMaxWidth * currentLevelProgress / xpForThisLevel));
|
||||
const xpBarHeight = 3;
|
||||
ctx.save();
|
||||
ctx.fillStyle = '#B3AD93';
|
||||
|
||||
@@ -1,145 +1,55 @@
|
||||
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { Command, Event } from "@lib/types";
|
||||
import type { Command } from "@lib/types";
|
||||
import { env } from "@lib/env";
|
||||
import { config } from "@lib/config";
|
||||
import { CommandLoader } from "@lib/loaders/CommandLoader";
|
||||
import { EventLoader } from "@lib/loaders/EventLoader";
|
||||
import { logger } from "@lib/logger";
|
||||
|
||||
class Client extends DiscordClient {
|
||||
export class Client extends DiscordClient {
|
||||
|
||||
commands: Collection<string, Command>;
|
||||
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);
|
||||
}
|
||||
|
||||
async loadCommands(reload: boolean = false) {
|
||||
if (reload) {
|
||||
this.commands.clear();
|
||||
console.log("♻️ Reloading commands...");
|
||||
logger.info("♻️ Reloading commands...");
|
||||
}
|
||||
|
||||
const commandsPath = join(import.meta.dir, '../commands');
|
||||
await this.readCommandsRecursively(commandsPath, reload);
|
||||
const result = await this.commandLoader.loadFromDirectory(commandsPath, reload);
|
||||
|
||||
logger.info(`📦 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...");
|
||||
logger.info("♻️ Reloading events...");
|
||||
}
|
||||
|
||||
const eventsPath = join(import.meta.dir, '../events');
|
||||
await this.readEventsRecursively(eventsPath, reload);
|
||||
const result = await this.eventLoader.loadFromDirectory(eventsPath, reload);
|
||||
|
||||
logger.info(`📦 Event loading complete: ${result.loaded} loaded, ${result.skipped} skipped, ${result.errors.length} errors`);
|
||||
}
|
||||
|
||||
private async readCommandsRecursively(dir: string, reload: boolean = false) {
|
||||
try {
|
||||
const files = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = join(dir, file.name);
|
||||
|
||||
if (file.isDirectory()) {
|
||||
await this.readCommandsRecursively(filePath, reload);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!file.name.endsWith('.ts') && !file.name.endsWith('.js')) continue;
|
||||
|
||||
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 ${file.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract category from parent directory name
|
||||
// filePath is like /path/to/commands/admin/features.ts
|
||||
// we want "admin"
|
||||
const pathParts = filePath.split('/');
|
||||
const category = pathParts[pathParts.length - 2];
|
||||
|
||||
for (const command of commands) {
|
||||
if (this.isValidCommand(command)) {
|
||||
command.category = category; // Inject category
|
||||
|
||||
const isEnabled = config.commands[command.data.name] !== false; // Default true if undefined
|
||||
|
||||
if (!isEnabled) {
|
||||
console.log(`🚫 Skipping disabled command: ${command.data.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.commands.set(command.data.name, command);
|
||||
console.log(`✅ Loaded command: ${command.data.name}`);
|
||||
} else {
|
||||
console.warn(`⚠️ Skipping invalid command in ${file.name}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to load command from ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading directory ${dir}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async readEventsRecursively(dir: string, reload: boolean = false) {
|
||||
try {
|
||||
const files = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = join(dir, file.name);
|
||||
|
||||
if (file.isDirectory()) {
|
||||
await this.readEventsRecursively(filePath, reload);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!file.name.endsWith('.ts') && !file.name.endsWith('.js')) continue;
|
||||
|
||||
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.once(event.name, (...args) => event.execute(...args));
|
||||
} else {
|
||||
this.on(event.name, (...args) => event.execute(...args));
|
||||
}
|
||||
console.log(`✅ Loaded event: ${event.name}`);
|
||||
} else {
|
||||
console.warn(`⚠️ Skipping invalid event in ${file.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to load event from ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading directory ${dir}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private isValidCommand(command: any): command is Command {
|
||||
return command && typeof command === 'object' && 'data' in command && 'execute' in command;
|
||||
}
|
||||
|
||||
private isValidEvent(event: any): event is Event<any> {
|
||||
return event && typeof event === 'object' && 'name' in event && 'execute' in event;
|
||||
}
|
||||
|
||||
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.");
|
||||
logger.error("DISCORD_BOT_TOKEN is not set.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -149,16 +59,16 @@ class Client extends DiscordClient {
|
||||
const clientId = env.DISCORD_CLIENT_ID;
|
||||
|
||||
if (!clientId) {
|
||||
console.error("❌ DISCORD_CLIENT_ID is not set.");
|
||||
logger.error("DISCORD_CLIENT_ID is not set.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Started refreshing ${commandsData.length} application (/) commands.`);
|
||||
logger.info(`Started refreshing ${commandsData.length} application (/) commands.`);
|
||||
|
||||
let data;
|
||||
if (guildId) {
|
||||
console.log(`Registering commands to guild: ${guildId}`);
|
||||
logger.info(`Registering commands to guild: ${guildId}`);
|
||||
data = await rest.put(
|
||||
Routes.applicationGuildCommands(clientId, guildId),
|
||||
{ body: commandsData },
|
||||
@@ -166,20 +76,20 @@ class Client extends DiscordClient {
|
||||
// Clear global commands to avoid duplicates
|
||||
await rest.put(Routes.applicationCommands(clientId), { body: [] });
|
||||
} else {
|
||||
console.log('Registering commands globally');
|
||||
logger.info('Registering commands globally');
|
||||
data = await rest.put(
|
||||
Routes.applicationCommands(clientId),
|
||||
{ body: commandsData },
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`✅ Successfully reloaded ${(data as any).length} application (/) commands.`);
|
||||
logger.success(`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'.");
|
||||
logger.warn("Missing Access: The bot is not in the guild or lacks 'applications.commands' scope.");
|
||||
logger.warn(" If you are testing locally, make sure you invited the bot with scope 'bot applications.commands'.");
|
||||
} else {
|
||||
console.error(error);
|
||||
logger.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface GameConfigType {
|
||||
daily: {
|
||||
amount: bigint;
|
||||
streakBonus: bigint;
|
||||
weeklyBonus: bigint;
|
||||
cooldownMs: number;
|
||||
},
|
||||
transfers: {
|
||||
@@ -47,8 +48,27 @@ export interface GameConfigType {
|
||||
};
|
||||
studentRole: string;
|
||||
visitorRole: string;
|
||||
colorRoles: string[];
|
||||
welcomeChannelId?: string;
|
||||
welcomeMessage?: string;
|
||||
feedbackChannelId?: string;
|
||||
terminal?: {
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
};
|
||||
moderation: {
|
||||
prune: {
|
||||
maxAmount: number;
|
||||
confirmThreshold: number;
|
||||
batchSize: number;
|
||||
batchDelayMs: number;
|
||||
};
|
||||
cases: {
|
||||
dmOnWarn: boolean;
|
||||
logChannelId?: string;
|
||||
autoTimeoutThreshold?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Initial default config state
|
||||
@@ -79,6 +99,7 @@ const configSchema = z.object({
|
||||
daily: z.object({
|
||||
amount: bigIntSchema,
|
||||
streakBonus: bigIntSchema,
|
||||
weeklyBonus: bigIntSchema.default(50n),
|
||||
cooldownMs: z.number(),
|
||||
}),
|
||||
transfers: z.object({
|
||||
@@ -109,8 +130,37 @@ const configSchema = z.object({
|
||||
}),
|
||||
studentRole: z.string(),
|
||||
visitorRole: z.string(),
|
||||
colorRoles: z.array(z.string()).default([]),
|
||||
welcomeChannelId: z.string().optional(),
|
||||
welcomeMessage: z.string().optional()
|
||||
welcomeMessage: z.string().optional(),
|
||||
feedbackChannelId: z.string().optional(),
|
||||
terminal: z.object({
|
||||
channelId: z.string(),
|
||||
messageId: z.string()
|
||||
}).optional(),
|
||||
moderation: z.object({
|
||||
prune: z.object({
|
||||
maxAmount: z.number().default(100),
|
||||
confirmThreshold: z.number().default(50),
|
||||
batchSize: z.number().default(100),
|
||||
batchDelayMs: z.number().default(1000)
|
||||
}),
|
||||
cases: z.object({
|
||||
dmOnWarn: z.boolean().default(true),
|
||||
logChannelId: z.string().optional(),
|
||||
autoTimeoutThreshold: z.number().optional()
|
||||
})
|
||||
}).default({
|
||||
prune: {
|
||||
maxAmount: 100,
|
||||
confirmThreshold: 50,
|
||||
batchSize: 100,
|
||||
batchDelayMs: 1000
|
||||
},
|
||||
cases: {
|
||||
dmOnWarn: true
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
export function reloadConfig() {
|
||||
@@ -122,29 +172,9 @@ export function reloadConfig() {
|
||||
const rawConfig = JSON.parse(raw);
|
||||
|
||||
// Update config object in place
|
||||
config.leveling = rawConfig.leveling;
|
||||
config.economy = {
|
||||
daily: {
|
||||
...rawConfig.economy.daily,
|
||||
amount: BigInt(rawConfig.economy.daily.amount),
|
||||
streakBonus: BigInt(rawConfig.economy.daily.streakBonus),
|
||||
},
|
||||
transfers: {
|
||||
...rawConfig.economy.transfers,
|
||||
minAmount: BigInt(rawConfig.economy.transfers.minAmount),
|
||||
},
|
||||
exam: rawConfig.economy.exam,
|
||||
};
|
||||
config.inventory = {
|
||||
...rawConfig.inventory,
|
||||
maxStackSize: BigInt(rawConfig.inventory.maxStackSize),
|
||||
};
|
||||
config.commands = rawConfig.commands || {};
|
||||
config.lootdrop = rawConfig.lootdrop;
|
||||
config.studentRole = rawConfig.studentRole;
|
||||
config.visitorRole = rawConfig.visitorRole;
|
||||
config.welcomeChannelId = rawConfig.welcomeChannelId;
|
||||
config.welcomeMessage = rawConfig.welcomeMessage;
|
||||
// We use Object.assign to keep the reference to the exported 'config' object same
|
||||
const validatedConfig = configSchema.parse(rawConfig);
|
||||
Object.assign(config, validatedConfig);
|
||||
|
||||
console.log("🔄 Config reloaded from disk.");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { AuroraClient } from '@/lib/BotClient';
|
||||
|
||||
const configPath = join(process.cwd(), 'config', 'config.json');
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EmbedBuilder, Colors } from "discord.js";
|
||||
import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js";
|
||||
|
||||
/**
|
||||
* Creates a standardized error embed.
|
||||
@@ -55,3 +55,21 @@ export function createInfoEmbed(message: string, title: string = "Info"): EmbedB
|
||||
.setColor(Colors.Blue)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a standardized base embed with common configuration.
|
||||
* @param title Optional title for the embed.
|
||||
* @param description Optional description for the embed.
|
||||
* @param color Optional color for the embed.
|
||||
* @returns An EmbedBuilder instance with base configuration.
|
||||
*/
|
||||
export function createBaseEmbed(title?: string, description?: string, color?: ColorResolvable): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTimestamp();
|
||||
|
||||
if (title) embed.setTitle(title);
|
||||
if (description) embed.setDescription(description);
|
||||
if (color) embed.setColor(color);
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
22
src/lib/handlers/AutocompleteHandler.ts
Normal file
22
src/lib/handlers/AutocompleteHandler.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { AutocompleteInteraction } from "discord.js";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { logger } from "@lib/logger";
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
logger.error(`Error handling autocomplete for ${interaction.commandName}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
40
src/lib/handlers/CommandHandler.ts
Normal file
40
src/lib/handlers/CommandHandler.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { logger } from "@lib/logger";
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
logger.error(`No command matching ${interaction.commandName} was found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure user exists in database
|
||||
try {
|
||||
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
} catch (error) {
|
||||
logger.error("Failed to ensure user exists:", error);
|
||||
}
|
||||
|
||||
try {
|
||||
await command.execute(interaction);
|
||||
} catch (error) {
|
||||
logger.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
src/lib/handlers/ComponentInteractionHandler.ts
Normal file
78
src/lib/handlers/ComponentInteractionHandler.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js";
|
||||
import { logger } from "@lib/logger";
|
||||
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 {
|
||||
logger.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) {
|
||||
logger.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
|
||||
logger.error(`Failed to send error response in ${handlerName}:`, replyError);
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/lib/handlers/index.ts
Normal file
3
src/lib/handlers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { ComponentInteractionHandler } from "./ComponentInteractionHandler";
|
||||
export { AutocompleteHandler } from "./AutocompleteHandler";
|
||||
export { CommandHandler } from "./CommandHandler";
|
||||
61
src/lib/interaction.routes.ts
Normal file
61
src/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
src/lib/loaders/CommandLoader.ts
Normal file
111
src/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 "@lib/types";
|
||||
import { config } from "@lib/config";
|
||||
import type { LoadResult, LoadError } from "./types";
|
||||
import type { Client } from "../BotClient";
|
||||
import { logger } from "@lib/logger";
|
||||
|
||||
/**
|
||||
* 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')) continue;
|
||||
|
||||
await this.loadCommandFile(filePath, reload, result);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.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) {
|
||||
logger.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) {
|
||||
logger.info(`🚫 Skipping disabled command: ${command.data.name}`);
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
this.client.commands.set(command.data.name, command);
|
||||
logger.success(`Loaded command: ${command.data.name}`);
|
||||
result.loaded++;
|
||||
} else {
|
||||
logger.warn(`Skipping invalid command in ${filePath}`);
|
||||
result.skipped++;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.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
src/lib/loaders/EventLoader.ts
Normal file
85
src/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 "@lib/types";
|
||||
import type { LoadResult } from "./types";
|
||||
import type { Client } from "../BotClient";
|
||||
import { logger } from "@lib/logger";
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
logger.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));
|
||||
}
|
||||
logger.success(`Loaded event: ${event.name}`);
|
||||
result.loaded++;
|
||||
} else {
|
||||
logger.warn(`Skipping invalid event in ${filePath}`);
|
||||
result.skipped++;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.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
src/lib/loaders/types.ts
Normal file
16
src/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;
|
||||
}
|
||||
39
src/lib/logger.ts
Normal file
39
src/lib/logger.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Centralized logging utility with consistent formatting
|
||||
*/
|
||||
export const logger = {
|
||||
/**
|
||||
* General information message
|
||||
*/
|
||||
info: (message: string, ...args: any[]) => {
|
||||
console.log(`ℹ️ ${message}`, ...args);
|
||||
},
|
||||
|
||||
/**
|
||||
* Success message
|
||||
*/
|
||||
success: (message: string, ...args: any[]) => {
|
||||
console.log(`✅ ${message}`, ...args);
|
||||
},
|
||||
|
||||
/**
|
||||
* Warning message
|
||||
*/
|
||||
warn: (message: string, ...args: any[]) => {
|
||||
console.warn(`⚠️ ${message}`, ...args);
|
||||
},
|
||||
|
||||
/**
|
||||
* Error message
|
||||
*/
|
||||
error: (message: string, ...args: any[]) => {
|
||||
console.error(`❌ ${message}`, ...args);
|
||||
},
|
||||
|
||||
/**
|
||||
* Debug message
|
||||
*/
|
||||
debug: (message: string, ...args: any[]) => {
|
||||
console.log(`🔍 ${message}`, ...args);
|
||||
},
|
||||
};
|
||||
@@ -18,7 +18,8 @@ export type ItemEffect =
|
||||
| { type: 'ADD_BALANCE'; amount: number }
|
||||
| { type: 'XP_BOOST'; multiplier: number; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
|
||||
| { type: 'TEMP_ROLE'; roleId: string; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
|
||||
| { type: 'REPLY_MESSAGE'; message: string };
|
||||
| { type: 'REPLY_MESSAGE'; message: string }
|
||||
| { type: 'COLOR_ROLE'; roleId: string };
|
||||
|
||||
export interface ItemUsageData {
|
||||
consume: boolean;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, test, expect, spyOn, beforeEach, mock } from "bun:test";
|
||||
import { handleItemWizardInteraction, renderWizard } from "./item_wizard";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
|
||||
|
||||
// Mock Setup
|
||||
|
||||
@@ -1,50 +1,17 @@
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
EmbedBuilder,
|
||||
ModalBuilder,
|
||||
StringSelectMenuBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle,
|
||||
type Interaction,
|
||||
type MessageActionRowComponentBuilder
|
||||
} from "discord.js";
|
||||
import { type Interaction } from "discord.js";
|
||||
import { items } from "@/db/schema";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import type { ItemUsageData, ItemEffect } from "@/lib/types";
|
||||
import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
|
||||
import type { DraftItem } from "./item_wizard.types";
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
|
||||
|
||||
// --- State ---
|
||||
const draftSession = new Map<string, DraftItem>();
|
||||
|
||||
const getItemTypeOptions = () => [
|
||||
{ label: "Material", value: "MATERIAL", description: "Used for crafting or trading" },
|
||||
{ label: "Consumable", value: "CONSUMABLE", description: "Can be used to gain effects" },
|
||||
{ label: "Equipment", value: "EQUIPMENT", description: "Can be equipped (Not yet implemented)" },
|
||||
{ label: "Quest Item", value: "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" },
|
||||
];
|
||||
|
||||
// --- Render ---
|
||||
export const renderWizard = (userId: string, isDraft = true) => {
|
||||
@@ -65,43 +32,8 @@ export const renderWizard = (userId: string, isDraft = true) => {
|
||||
draftSession.set(userId, draft);
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`🛠️ Item Creator: ${draft.name}`)
|
||||
.setColor("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 },
|
||||
);
|
||||
|
||||
// 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_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] };
|
||||
const { embeds, components } = getItemWizardEmbed(draft);
|
||||
return { embeds, components };
|
||||
};
|
||||
|
||||
// --- Handler ---
|
||||
@@ -150,12 +82,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
// 1. Details Modal
|
||||
if (interaction.customId === "createitem_details") {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = new ModalBuilder().setCustomId("createitem_modal_details").setTitle("Edit Details");
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("name").setLabel("Name").setValue(draft.name).setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("desc").setLabel("Description").setValue(draft.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(draft.rarity).setStyle(TextInputStyle.Short).setPlaceholder("Common, Rare, Legendary...").setRequired(true))
|
||||
);
|
||||
const modal = getDetailsModal(draft);
|
||||
await interaction.showModal(modal);
|
||||
return;
|
||||
}
|
||||
@@ -163,10 +90,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
// 2. Economy Modal
|
||||
if (interaction.customId === "createitem_economy") {
|
||||
if (!interaction.isButton()) return;
|
||||
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(draft.price?.toString() || "0").setStyle(TextInputStyle.Short).setRequired(true))
|
||||
);
|
||||
const modal = getEconomyModal(draft);
|
||||
await interaction.showModal(modal);
|
||||
return;
|
||||
}
|
||||
@@ -174,11 +98,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
// 3. Visuals Modal
|
||||
if (interaction.customId === "createitem_visuals") {
|
||||
if (!interaction.isButton()) return;
|
||||
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(draft.iconUrl).setStyle(TextInputStyle.Short).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("image").setLabel("Image URL").setValue(draft.imageUrl).setStyle(TextInputStyle.Short).setRequired(false))
|
||||
);
|
||||
const modal = getVisualsModal(draft);
|
||||
await interaction.showModal(modal);
|
||||
return;
|
||||
}
|
||||
@@ -186,10 +106,8 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
// 4. Type Toggle (Start Select Menu)
|
||||
if (interaction.customId === "createitem_type_toggle") {
|
||||
if (!interaction.isButton()) return;
|
||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||
new StringSelectMenuBuilder().setCustomId("createitem_select_type").setPlaceholder("Select Item Type").addOptions(getItemTypeOptions())
|
||||
);
|
||||
await interaction.update({ components: [row as any] }); // Temporary view
|
||||
const { components } = getItemTypeSelection();
|
||||
await interaction.update({ components }); // Temporary view
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -208,16 +126,15 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
// 5. Add Effect Flow
|
||||
if (interaction.customId === "createitem_addeffect_start") {
|
||||
if (!interaction.isButton()) return;
|
||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||
new StringSelectMenuBuilder().setCustomId("createitem_select_effect_type").setPlaceholder("Select Effect Type").addOptions(getEffectTypeOptions())
|
||||
);
|
||||
await interaction.update({ components: [row as any] });
|
||||
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
|
||||
@@ -225,28 +142,20 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
// 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.
|
||||
|
||||
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"))
|
||||
);
|
||||
}
|
||||
|
||||
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") {
|
||||
@@ -284,6 +193,10 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
||||
if (roleId && !isNaN(duration)) effect = { type: "TEMP_ROLE", roleId: roleId, durationSeconds: duration };
|
||||
}
|
||||
else if (type === "COLOR_ROLE") {
|
||||
const roleId = interaction.fields.getTextInputValue("role_id");
|
||||
if (roleId) effect = { type: "COLOR_ROLE", roleId: roleId };
|
||||
}
|
||||
|
||||
if (effect) {
|
||||
draft.usageData.effects.push(effect);
|
||||
|
||||
14
src/modules/admin/item_wizard.types.ts
Normal file
14
src/modules/admin/item_wizard.types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ItemUsageData } from "@/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;
|
||||
}
|
||||
134
src/modules/admin/item_wizard.view.ts
Normal file
134
src/modules/admin/item_wizard.view.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
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";
|
||||
|
||||
const getItemTypeOptions = () => [
|
||||
{ label: "Material", value: "MATERIAL", description: "Used for crafting or trading" },
|
||||
{ label: "Consumable", value: "CONSUMABLE", description: "Can be used to gain effects" },
|
||||
{ label: "Equipment", value: "EQUIPMENT", description: "Can be equipped (Not yet implemented)" },
|
||||
{ label: "Quest Item", value: "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;
|
||||
};
|
||||
248
src/modules/admin/update.service.test.ts
Normal file
248
src/modules/admin/update.service.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { describe, expect, test, mock, beforeEach, afterAll, spyOn } from "bun:test";
|
||||
import * as fs from "fs/promises";
|
||||
|
||||
// Mock child_process BEFORE importing the service
|
||||
const mockExec = mock((cmd: string, callback?: any) => {
|
||||
// Handle calls without callback (like exec().unref())
|
||||
if (!callback) {
|
||||
return { unref: () => { } };
|
||||
}
|
||||
|
||||
if (cmd.includes("git rev-parse")) {
|
||||
callback(null, { stdout: "main\n" });
|
||||
} else if (cmd.includes("git fetch")) {
|
||||
callback(null, { stdout: "" });
|
||||
} else if (cmd.includes("git log")) {
|
||||
callback(null, { stdout: "abcdef Update 1\n123456 Update 2" });
|
||||
} else if (cmd.includes("git diff")) {
|
||||
callback(null, { stdout: "package.json\nsrc/index.ts" });
|
||||
} else if (cmd.includes("git reset")) {
|
||||
callback(null, { stdout: "HEAD is now at abcdef Update 1" });
|
||||
} else if (cmd.includes("bun install")) {
|
||||
callback(null, { stdout: "Installed dependencies" });
|
||||
} else if (cmd.includes("drizzle-kit migrate")) {
|
||||
callback(null, { stdout: "Migrations applied" });
|
||||
} else {
|
||||
callback(null, { stdout: "" });
|
||||
}
|
||||
});
|
||||
|
||||
mock.module("child_process", () => ({
|
||||
exec: mockExec
|
||||
}));
|
||||
|
||||
// Mock fs/promises
|
||||
const mockWriteFile = mock((path: string, content: string) => Promise.resolve());
|
||||
const mockReadFile = mock((path: string, encoding: string) => Promise.resolve("{}"));
|
||||
const mockUnlink = mock((path: string) => Promise.resolve());
|
||||
|
||||
mock.module("fs/promises", () => ({
|
||||
writeFile: mockWriteFile,
|
||||
readFile: mockReadFile,
|
||||
unlink: mockUnlink
|
||||
}));
|
||||
|
||||
// Mock view module to avoid import issues
|
||||
mock.module("./update.view", () => ({
|
||||
getPostRestartEmbed: () => ({ title: "Update Complete" }),
|
||||
getInstallingDependenciesEmbed: () => ({ title: "Installing..." }),
|
||||
}));
|
||||
|
||||
describe("UpdateService", () => {
|
||||
let UpdateService: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockExec.mockClear();
|
||||
mockWriteFile.mockClear();
|
||||
mockReadFile.mockClear();
|
||||
mockUnlink.mockClear();
|
||||
|
||||
// Dynamically import to ensure mock is used
|
||||
const module = await import("./update.service");
|
||||
UpdateService = module.UpdateService;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe("checkForUpdates", () => {
|
||||
test("should return updates if git log has output", async () => {
|
||||
const result = await UpdateService.checkForUpdates();
|
||||
|
||||
expect(result.hasUpdates).toBe(true);
|
||||
expect(result.branch).toBe("main");
|
||||
expect(result.log).toContain("Update 1");
|
||||
});
|
||||
|
||||
test("should call git rev-parse, fetch, and log commands", async () => {
|
||||
await UpdateService.checkForUpdates();
|
||||
|
||||
const calls = mockExec.mock.calls.map((c: any) => c[0]);
|
||||
expect(calls.some((cmd: string) => cmd.includes("git rev-parse"))).toBe(true);
|
||||
expect(calls.some((cmd: string) => cmd.includes("git fetch"))).toBe(true);
|
||||
expect(calls.some((cmd: string) => cmd.includes("git log"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("performUpdate", () => {
|
||||
test("should run git reset --hard with correct branch", async () => {
|
||||
await UpdateService.performUpdate("main");
|
||||
|
||||
const lastCall = mockExec.mock.lastCall;
|
||||
expect(lastCall).toBeDefined();
|
||||
expect(lastCall![0]).toContain("git reset --hard origin/main");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkUpdateRequirements", () => {
|
||||
test("should detect package.json and schema.ts changes", async () => {
|
||||
const result = await UpdateService.checkUpdateRequirements("main");
|
||||
|
||||
expect(result.needsInstall).toBe(true);
|
||||
expect(result.needsMigrations).toBe(false); // mock doesn't include schema.ts
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should call git diff with correct branch", async () => {
|
||||
await UpdateService.checkUpdateRequirements("develop");
|
||||
|
||||
const lastCall = mockExec.mock.lastCall;
|
||||
expect(lastCall).toBeDefined();
|
||||
expect(lastCall![0]).toContain("git diff HEAD..origin/develop");
|
||||
});
|
||||
});
|
||||
|
||||
describe("installDependencies", () => {
|
||||
test("should run bun install and return output", async () => {
|
||||
const output = await UpdateService.installDependencies();
|
||||
|
||||
expect(output).toBe("Installed dependencies");
|
||||
const lastCall = mockExec.mock.lastCall;
|
||||
expect(lastCall![0]).toBe("bun install");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareRestartContext", () => {
|
||||
test("should write context to file", async () => {
|
||||
const context = {
|
||||
channelId: "123",
|
||||
userId: "456",
|
||||
timestamp: Date.now(),
|
||||
runMigrations: true,
|
||||
installDependencies: false
|
||||
};
|
||||
|
||||
await UpdateService.prepareRestartContext(context);
|
||||
|
||||
expect(mockWriteFile).toHaveBeenCalled();
|
||||
const lastCall = mockWriteFile.mock.lastCall as [string, string] | undefined;
|
||||
expect(lastCall).toBeDefined();
|
||||
expect(lastCall![0]).toContain("restart_context");
|
||||
expect(JSON.parse(lastCall![1])).toEqual(context);
|
||||
});
|
||||
});
|
||||
|
||||
describe("triggerRestart", () => {
|
||||
test("should use RESTART_COMMAND env var when set", async () => {
|
||||
const originalEnv = process.env.RESTART_COMMAND;
|
||||
process.env.RESTART_COMMAND = "pm2 restart bot";
|
||||
|
||||
await UpdateService.triggerRestart();
|
||||
|
||||
const lastCall = mockExec.mock.lastCall;
|
||||
expect(lastCall).toBeDefined();
|
||||
expect(lastCall![0]).toBe("pm2 restart bot");
|
||||
|
||||
process.env.RESTART_COMMAND = originalEnv;
|
||||
});
|
||||
|
||||
test("should write to trigger file when no env var", async () => {
|
||||
const originalEnv = process.env.RESTART_COMMAND;
|
||||
delete process.env.RESTART_COMMAND;
|
||||
|
||||
await UpdateService.triggerRestart();
|
||||
|
||||
expect(mockWriteFile).toHaveBeenCalled();
|
||||
const lastCall = mockWriteFile.mock.lastCall as [string, string] | undefined;
|
||||
expect(lastCall).toBeDefined();
|
||||
expect(lastCall![0]).toContain("restart_trigger");
|
||||
|
||||
process.env.RESTART_COMMAND = originalEnv;
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlePostRestart", () => {
|
||||
const createMockClient = (channel: any = null) => ({
|
||||
channels: {
|
||||
fetch: mock(() => Promise.resolve(channel))
|
||||
}
|
||||
});
|
||||
|
||||
const createMockChannel = () => ({
|
||||
isSendable: () => true,
|
||||
send: mock(() => Promise.resolve())
|
||||
});
|
||||
|
||||
test("should ignore stale context (>10 mins old)", async () => {
|
||||
const staleContext = {
|
||||
channelId: "123",
|
||||
userId: "456",
|
||||
timestamp: Date.now() - (15 * 60 * 1000), // 15 mins ago
|
||||
runMigrations: true,
|
||||
installDependencies: true
|
||||
};
|
||||
|
||||
mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(staleContext)));
|
||||
|
||||
const mockChannel = createMockChannel();
|
||||
// Create mock with instanceof support
|
||||
const channel = Object.assign(mockChannel, { constructor: { name: "TextChannel" } });
|
||||
Object.setPrototypeOf(channel, Object.create({ constructor: { name: "TextChannel" } }));
|
||||
|
||||
const mockClient = createMockClient(channel);
|
||||
|
||||
await UpdateService.handlePostRestart(mockClient);
|
||||
|
||||
// Should not send any message for stale context
|
||||
expect(mockChannel.send).not.toHaveBeenCalled();
|
||||
// Should clean up the context file
|
||||
expect(mockUnlink).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should do nothing if no context file exists", async () => {
|
||||
mockReadFile.mockImplementationOnce(() => Promise.reject(new Error("ENOENT")));
|
||||
|
||||
const mockClient = createMockClient();
|
||||
|
||||
await UpdateService.handlePostRestart(mockClient);
|
||||
|
||||
// Should not throw and not try to clean up
|
||||
expect(mockUnlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should clean up context file after processing", async () => {
|
||||
const validContext = {
|
||||
channelId: "123",
|
||||
userId: "456",
|
||||
timestamp: Date.now(),
|
||||
runMigrations: false,
|
||||
installDependencies: false
|
||||
};
|
||||
|
||||
mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(validContext)));
|
||||
|
||||
// Create a proper TextChannel mock
|
||||
const { TextChannel } = await import("discord.js");
|
||||
const mockChannel = Object.create(TextChannel.prototype);
|
||||
mockChannel.isSendable = () => true;
|
||||
mockChannel.send = mock(() => Promise.resolve());
|
||||
|
||||
const mockClient = createMockClient(mockChannel);
|
||||
|
||||
await UpdateService.handlePostRestart(mockClient);
|
||||
|
||||
expect(mockUnlink).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
188
src/modules/admin/update.service.ts
Normal file
188
src/modules/admin/update.service.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { writeFile, readFile, unlink } from "fs/promises";
|
||||
import { Client, TextChannel } from "discord.js";
|
||||
import { getPostRestartEmbed, getInstallingDependenciesEmbed } from "./update.view";
|
||||
import type { PostRestartResult } from "./update.view";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Constants
|
||||
const STALE_CONTEXT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
export interface RestartContext {
|
||||
channelId: string;
|
||||
userId: string;
|
||||
timestamp: number;
|
||||
runMigrations: boolean;
|
||||
installDependencies: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
needsInstall: boolean;
|
||||
needsMigrations: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class UpdateService {
|
||||
private static readonly CONTEXT_FILE = ".restart_context.json";
|
||||
|
||||
static async checkForUpdates(): Promise<{ hasUpdates: boolean; log: string; branch: string }> {
|
||||
const { stdout: branchName } = await execAsync("git rev-parse --abbrev-ref HEAD");
|
||||
const branch = branchName.trim();
|
||||
|
||||
await execAsync("git fetch --all");
|
||||
const { stdout: logOutput } = await execAsync(`git log HEAD..origin/${branch} --oneline`);
|
||||
|
||||
return {
|
||||
hasUpdates: !!logOutput.trim(),
|
||||
log: logOutput.trim(),
|
||||
branch
|
||||
};
|
||||
}
|
||||
|
||||
static async performUpdate(branch: string): Promise<void> {
|
||||
await execAsync(`git reset --hard origin/${branch}`);
|
||||
}
|
||||
|
||||
static async checkUpdateRequirements(branch: string): Promise<UpdateCheckResult> {
|
||||
try {
|
||||
const { stdout } = await execAsync(`git diff HEAD..origin/${branch} --name-only`);
|
||||
return {
|
||||
needsInstall: stdout.includes("package.json"),
|
||||
needsMigrations: stdout.includes("schema.ts") || stdout.includes("drizzle/")
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Failed to check update requirements:", e);
|
||||
return {
|
||||
needsInstall: false,
|
||||
needsMigrations: false,
|
||||
error: e instanceof Error ? e : new Error(String(e))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static async installDependencies(): Promise<string> {
|
||||
const { stdout } = await execAsync("bun install");
|
||||
return stdout;
|
||||
}
|
||||
|
||||
static async prepareRestartContext(context: RestartContext): Promise<void> {
|
||||
await writeFile(this.CONTEXT_FILE, JSON.stringify(context));
|
||||
}
|
||||
|
||||
static async triggerRestart(): Promise<void> {
|
||||
if (process.env.RESTART_COMMAND) {
|
||||
// Run without awaiting - it may kill the process immediately
|
||||
exec(process.env.RESTART_COMMAND).unref();
|
||||
} else {
|
||||
// Fallback: exit the process and let Docker/PM2/systemd restart it
|
||||
// Small delay to allow any pending I/O to complete
|
||||
setTimeout(() => process.exit(0), 100);
|
||||
}
|
||||
}
|
||||
|
||||
static async handlePostRestart(client: Client): Promise<void> {
|
||||
try {
|
||||
const context = await this.loadRestartContext();
|
||||
if (!context) return;
|
||||
|
||||
if (this.isContextStale(context)) {
|
||||
await this.cleanupContext();
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = await this.fetchNotificationChannel(client, context.channelId);
|
||||
if (!channel) {
|
||||
await this.cleanupContext();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.executePostRestartTasks(context, channel);
|
||||
await this.notifyPostRestartResult(channel, result);
|
||||
await this.cleanupContext();
|
||||
} catch (e) {
|
||||
console.error("Failed to handle post-restart context:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Private Helper Methods ---
|
||||
|
||||
private static async loadRestartContext(): Promise<RestartContext | null> {
|
||||
try {
|
||||
const contextData = await readFile(this.CONTEXT_FILE, "utf-8");
|
||||
return JSON.parse(contextData) as RestartContext;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static isContextStale(context: RestartContext): boolean {
|
||||
return Date.now() - context.timestamp > STALE_CONTEXT_MS;
|
||||
}
|
||||
|
||||
private static async fetchNotificationChannel(client: Client, channelId: string): Promise<TextChannel | null> {
|
||||
try {
|
||||
const channel = await client.channels.fetch(channelId);
|
||||
if (channel && channel.isSendable() && channel instanceof TextChannel) {
|
||||
return channel;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async executePostRestartTasks(
|
||||
context: RestartContext,
|
||||
channel: TextChannel
|
||||
): Promise<PostRestartResult> {
|
||||
const result: PostRestartResult = {
|
||||
installSuccess: true,
|
||||
installOutput: "",
|
||||
migrationSuccess: true,
|
||||
migrationOutput: "",
|
||||
ranInstall: context.installDependencies,
|
||||
ranMigrations: context.runMigrations
|
||||
};
|
||||
|
||||
// 1. Install Dependencies if needed
|
||||
if (context.installDependencies) {
|
||||
try {
|
||||
await channel.send({ embeds: [getInstallingDependenciesEmbed()] });
|
||||
const { stdout } = await execAsync("bun install");
|
||||
result.installOutput = stdout;
|
||||
} catch (err: unknown) {
|
||||
result.installSuccess = false;
|
||||
result.installOutput = err instanceof Error ? err.message : String(err);
|
||||
console.error("Dependency Install Failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Run Migrations
|
||||
if (context.runMigrations) {
|
||||
try {
|
||||
const { stdout } = await execAsync("bun x drizzle-kit migrate");
|
||||
result.migrationOutput = stdout;
|
||||
} catch (err: unknown) {
|
||||
result.migrationSuccess = false;
|
||||
result.migrationOutput = err instanceof Error ? err.message : String(err);
|
||||
console.error("Migration Failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async notifyPostRestartResult(channel: TextChannel, result: PostRestartResult): Promise<void> {
|
||||
await channel.send({ embeds: [getPostRestartEmbed(result)] });
|
||||
}
|
||||
|
||||
private static async cleanupContext(): Promise<void> {
|
||||
try {
|
||||
await unlink(this.CONTEXT_FILE);
|
||||
} catch {
|
||||
// File may not exist, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
100
src/modules/admin/update.view.ts
Normal file
100
src/modules/admin/update.view.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
|
||||
import { createInfoEmbed, createSuccessEmbed, createWarningEmbed, createErrorEmbed } from "@lib/embeds";
|
||||
|
||||
// Constants for UI
|
||||
const LOG_TRUNCATE_LENGTH = 1000;
|
||||
const OUTPUT_TRUNCATE_LENGTH = 500;
|
||||
|
||||
function truncate(text: string, maxLength: number): string {
|
||||
return text.length > maxLength ? `${text.substring(0, maxLength)}\n...and more` : text;
|
||||
}
|
||||
|
||||
export function getCheckingEmbed() {
|
||||
return createInfoEmbed("Checking for updates...", "System Update");
|
||||
}
|
||||
|
||||
export function getNoUpdatesEmbed() {
|
||||
return createSuccessEmbed("The bot is already up to date.", "No Updates Found");
|
||||
}
|
||||
|
||||
export function getUpdatesAvailableMessage(branch: string, log: string, force: boolean) {
|
||||
const embed = createInfoEmbed(
|
||||
`**Branch:** \`${branch}\`\n\n**Pending Changes:**\n\`\`\`\n${truncate(log, LOG_TRUNCATE_LENGTH)}\n\`\`\`\n**Do you want to proceed?**`,
|
||||
"Updates Available"
|
||||
);
|
||||
|
||||
const confirmButton = new ButtonBuilder()
|
||||
.setCustomId("confirm_update")
|
||||
.setLabel(force ? "Force Update & Restart" : "Update & Restart")
|
||||
.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] };
|
||||
}
|
||||
|
||||
export function getPreparingEmbed() {
|
||||
return createInfoEmbed("⏳ Preparing update...", "Update In Progress");
|
||||
}
|
||||
|
||||
export function getUpdatingEmbed(needsDependencyInstall: boolean) {
|
||||
const message = `Downloading and applying updates...${needsDependencyInstall ? `\nExpect a slightly longer startup for dependency installation.` : ""}\nThe system will restart automatically.`;
|
||||
return createWarningEmbed(message, "Updating & Restarting");
|
||||
}
|
||||
|
||||
export function getCancelledEmbed() {
|
||||
return createInfoEmbed("Update cancelled.", "Cancelled");
|
||||
}
|
||||
|
||||
export function getTimeoutEmbed() {
|
||||
return createWarningEmbed("Update confirmation timed out.", "Timed Out");
|
||||
}
|
||||
|
||||
export function getErrorEmbed(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return createErrorEmbed(`Failed to update:\n\`\`\`\n${message}\n\`\`\``, "Update Failed");
|
||||
}
|
||||
|
||||
export interface PostRestartResult {
|
||||
installSuccess: boolean;
|
||||
installOutput: string;
|
||||
migrationSuccess: boolean;
|
||||
migrationOutput: string;
|
||||
ranInstall: boolean;
|
||||
ranMigrations: boolean;
|
||||
}
|
||||
|
||||
export function getPostRestartEmbed(result: PostRestartResult) {
|
||||
const parts: string[] = ["System updated successfully."];
|
||||
|
||||
if (result.ranInstall) {
|
||||
parts.push(`**Dependencies:** ${result.installSuccess ? "✅ Installed" : "❌ Failed"}`);
|
||||
}
|
||||
|
||||
if (result.ranMigrations) {
|
||||
parts.push(`**Migrations:** ${result.migrationSuccess ? "✅ Applied" : "❌ Failed"}`);
|
||||
}
|
||||
|
||||
if (result.installOutput) {
|
||||
parts.push(`\n**Install Output:**\n\`\`\`\n${truncate(result.installOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``);
|
||||
}
|
||||
|
||||
if (result.migrationOutput) {
|
||||
parts.push(`\n**Migration Output:**\n\`\`\`\n${truncate(result.migrationOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``);
|
||||
}
|
||||
|
||||
const isSuccess = result.installSuccess && result.migrationSuccess;
|
||||
const title = isSuccess ? "Update Complete" : "Update Completed with Errors";
|
||||
|
||||
return createSuccessEmbed(parts.join("\n"), title);
|
||||
}
|
||||
|
||||
export function getInstallingDependenciesEmbed() {
|
||||
return createSuccessEmbed("Installing dependencies...", "Post-Update Action");
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||
import { classService } from "./class.service";
|
||||
import { classes, users } from "@/db/schema";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
|
||||
// Define mock functions
|
||||
const mockFindMany = mock();
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
|
||||
import { classes, users } from "@/db/schema";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { withTransaction } from "@/lib/db";
|
||||
import type { Transaction } from "@/lib/types";
|
||||
|
||||
export const classService = {
|
||||
getAllClasses: async () => {
|
||||
return await DrizzleClient.query.classes.findMany();
|
||||
},
|
||||
|
||||
assignClass: async (userId: string, classId: bigint, tx?: any) => {
|
||||
const execute = async (txFn: any) => {
|
||||
assignClass: async (userId: string, classId: bigint, tx?: Transaction) => {
|
||||
return await withTransaction(async (txFn) => {
|
||||
const cls = await txFn.query.classes.findFirst({
|
||||
where: eq(classes.id, classId),
|
||||
});
|
||||
|
||||
if (!cls) throw new Error("Class not found");
|
||||
if (!cls) throw new UserError("Class not found");
|
||||
|
||||
const [user] = await txFn.update(users)
|
||||
.set({ classId: classId })
|
||||
@@ -22,8 +24,7 @@ export const classService = {
|
||||
.returning();
|
||||
|
||||
return user;
|
||||
};
|
||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
||||
}, tx);
|
||||
},
|
||||
getClassBalance: async (classId: bigint) => {
|
||||
const cls = await DrizzleClient.query.classes.findFirst({
|
||||
@@ -31,55 +32,51 @@ export const classService = {
|
||||
});
|
||||
return cls?.balance || 0n;
|
||||
},
|
||||
modifyClassBalance: async (classId: bigint, amount: bigint, tx?: any) => {
|
||||
const execute = async (txFn: any) => {
|
||||
modifyClassBalance: async (classId: bigint, amount: bigint, tx?: Transaction) => {
|
||||
return await withTransaction(async (txFn) => {
|
||||
const cls = await txFn.query.classes.findFirst({
|
||||
where: eq(classes.id, classId),
|
||||
});
|
||||
|
||||
if (!cls) throw new Error("Class not found");
|
||||
if (!cls) throw new UserError("Class not found");
|
||||
|
||||
if (amount < 0n && (cls.balance ?? 0n) < -amount) {
|
||||
throw new Error("Insufficient class funds");
|
||||
if ((cls.balance ?? 0n) + amount < 0n) {
|
||||
throw new UserError("Insufficient class funds");
|
||||
}
|
||||
|
||||
const [updatedClass] = await txFn.update(classes)
|
||||
.set({
|
||||
balance: sql`${classes.balance} + ${amount}`,
|
||||
balance: sql`${classes.balance} + ${amount} `,
|
||||
})
|
||||
.where(eq(classes.id, classId))
|
||||
.returning();
|
||||
|
||||
return updatedClass;
|
||||
};
|
||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
||||
}, tx);
|
||||
},
|
||||
|
||||
updateClass: async (id: bigint, data: Partial<typeof classes.$inferInsert>, tx?: any) => {
|
||||
const execute = async (txFn: any) => {
|
||||
updateClass: async (id: bigint, data: Partial<typeof classes.$inferInsert>, tx?: Transaction) => {
|
||||
return await withTransaction(async (txFn) => {
|
||||
const [updatedClass] = await txFn.update(classes)
|
||||
.set(data)
|
||||
.where(eq(classes.id, id))
|
||||
.returning();
|
||||
return updatedClass;
|
||||
};
|
||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
||||
}, tx);
|
||||
},
|
||||
|
||||
createClass: async (data: typeof classes.$inferInsert, tx?: any) => {
|
||||
const execute = async (txFn: any) => {
|
||||
createClass: async (data: typeof classes.$inferInsert, tx?: Transaction) => {
|
||||
return await withTransaction(async (txFn) => {
|
||||
const [newClass] = await txFn.insert(classes)
|
||||
.values(data)
|
||||
.returning();
|
||||
return newClass;
|
||||
};
|
||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
||||
}, tx);
|
||||
},
|
||||
|
||||
deleteClass: async (id: bigint, tx?: any) => {
|
||||
const execute = async (txFn: any) => {
|
||||
deleteClass: async (id: bigint, tx?: Transaction) => {
|
||||
return await withTransaction(async (txFn) => {
|
||||
await txFn.delete(classes).where(eq(classes.id, id));
|
||||
};
|
||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
||||
}, tx);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -62,6 +62,7 @@ mock.module("@/lib/config", () => ({
|
||||
daily: {
|
||||
amount: 100n,
|
||||
streakBonus: 10n,
|
||||
weeklyBonus: 50n,
|
||||
cooldownMs: 86400000, // 24 hours
|
||||
}
|
||||
}
|
||||
@@ -139,6 +140,7 @@ describe("economyService", () => {
|
||||
expect(result.streak).toBe(6);
|
||||
// Base 100 + (6-1)*10 = 150
|
||||
expect(result.amount).toBe(150n);
|
||||
expect(result.isWeekly).toBe(false);
|
||||
|
||||
// Check updates
|
||||
expect(mockUpdate).toHaveBeenCalledWith(users);
|
||||
@@ -146,6 +148,28 @@ describe("economyService", () => {
|
||||
expect(mockInsert).toHaveBeenCalledWith(transactions);
|
||||
});
|
||||
|
||||
it("should claim weekly bonus correctly on 7th day", async () => {
|
||||
const recentPast = new Date("2023-01-01T11:00:00Z");
|
||||
|
||||
mockFindFirst
|
||||
.mockResolvedValueOnce({ expiresAt: recentPast })
|
||||
.mockResolvedValueOnce({ id: 1n, dailyStreak: 6, balance: 1000n }); // User currently at 6 days
|
||||
|
||||
const result = await economyService.claimDaily("1");
|
||||
|
||||
expect(result.claimed).toBe(true);
|
||||
// Streak should increase: 6 + 1 = 7
|
||||
expect(result.streak).toBe(7);
|
||||
|
||||
// Base: 100
|
||||
// Streak Bonus: (7-1)*10 = 60
|
||||
// Weekly Bonus: 50
|
||||
// Total: 210
|
||||
expect(result.amount).toBe(210n);
|
||||
expect(result.isWeekly).toBe(true);
|
||||
expect(result.weeklyBonus).toBe(50n);
|
||||
});
|
||||
|
||||
it("should throw if cooldown is active", async () => {
|
||||
const future = new Date("2023-01-02T12:00:00Z"); // +24h
|
||||
mockFindFirst.mockResolvedValue({ expiresAt: future });
|
||||
|
||||
@@ -106,7 +106,11 @@ export const economyService = {
|
||||
|
||||
const bonus = (BigInt(streak) - 1n) * config.economy.daily.streakBonus;
|
||||
|
||||
const totalReward = config.economy.daily.amount + bonus;
|
||||
// Weekly bonus check
|
||||
const isWeeklyCurrent = streak > 0 && streak % 7 === 0;
|
||||
const weeklyBonusAmount = isWeeklyCurrent ? config.economy.daily.weeklyBonus : 0n;
|
||||
|
||||
const totalReward = config.economy.daily.amount + bonus + weeklyBonusAmount;
|
||||
await txFn.update(users)
|
||||
.set({
|
||||
balance: sql`${users.balance} + ${totalReward}`,
|
||||
@@ -138,7 +142,7 @@ export const economyService = {
|
||||
description: `Daily reward (Streak: ${streak})`,
|
||||
});
|
||||
|
||||
return { claimed: true, amount: totalReward, streak, nextReadyAt };
|
||||
return { claimed: true, amount: totalReward, streak, nextReadyAt, isWeekly: isWeeklyCurrent, weeklyBonus: weeklyBonusAmount };
|
||||
}, tx);
|
||||
},
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ActionRowBuilder, ButtonBuilder, ButtonInteraction, EmbedBuilder, ButtonStyle } from "discord.js";
|
||||
import { ButtonInteraction } from "discord.js";
|
||||
import { lootdropService } from "./lootdrop.service";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { getLootdropClaimedMessage } from "./lootdrop.view";
|
||||
|
||||
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
|
||||
if (interaction.customId === "lootdrop_claim") {
|
||||
@@ -8,37 +9,27 @@ export async function handleLootdropInteraction(interaction: ButtonInteraction)
|
||||
|
||||
const result = await lootdropService.tryClaim(interaction.message.id, interaction.user.id, interaction.user.username);
|
||||
|
||||
if (result.success) {
|
||||
await interaction.editReply({
|
||||
content: `🎉 You successfully claimed **${result.amount} ${result.currency}**!`
|
||||
});
|
||||
|
||||
// Update original message to show claimed state
|
||||
const originalEmbed = interaction.message.embeds[0];
|
||||
if (!originalEmbed) return;
|
||||
|
||||
const newEmbed = new EmbedBuilder(originalEmbed.data)
|
||||
.setDescription(`✅ Claimed by <@${interaction.user.id}> for **${result.amount} ${result.currency}**!`)
|
||||
.setColor("#00FF00");
|
||||
|
||||
// Disable button
|
||||
// We reconstruct the button using builders for safety
|
||||
const newRow = new ActionRowBuilder<ButtonBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId("lootdrop_claim_disabled")
|
||||
.setLabel("CLAIMED")
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji("✅")
|
||||
.setDisabled(true)
|
||||
);
|
||||
|
||||
await interaction.message.edit({ embeds: [newEmbed], components: [newRow] });
|
||||
|
||||
} else {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(result.error || "Failed to claim.")]
|
||||
});
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import { lootdropService } from "./lootdrop.service";
|
||||
import { lootdrops } from "@/db/schema";
|
||||
import { eq, and, isNull } from "drizzle-orm";
|
||||
import { economyService } from "./economy.service";
|
||||
|
||||
// Mock dependencies BEFORE using service functionality
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
|
||||
import { Message, TextChannel, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType } from "discord.js";
|
||||
import { Message, TextChannel } from "discord.js";
|
||||
import { getLootdropMessage } from "./lootdrop.view";
|
||||
import { config } from "@/lib/config";
|
||||
import { economyService } from "./economy.service";
|
||||
import { terminalService } from "@/modules/terminal/terminal.service";
|
||||
|
||||
|
||||
import { lootdrops } from "@/db/schema";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
@@ -91,23 +94,10 @@ class LootdropService {
|
||||
const reward = Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
const currency = config.lootdrop.reward.currency;
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("💰 LOOTDROP!")
|
||||
.setDescription(`A lootdrop has appeared! Click the button below to claim **${reward} ${currency}**!`)
|
||||
.setColor("#FFD700")
|
||||
.setTimestamp();
|
||||
|
||||
const claimButton = new ButtonBuilder()
|
||||
.setCustomId("lootdrop_claim")
|
||||
.setLabel("CLAIM REWARD")
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setEmoji("💸");
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>()
|
||||
.addComponents(claimButton);
|
||||
const { content, files, components } = await getLootdropMessage(reward, currency);
|
||||
|
||||
try {
|
||||
const message = await channel.send({ embeds: [embed], components: [row] });
|
||||
const message = await channel.send({ content, files, components });
|
||||
|
||||
// Persist to DB
|
||||
await DrizzleClient.insert(lootdrops).values({
|
||||
@@ -120,6 +110,9 @@ class LootdropService {
|
||||
expiresAt: new Date(Date.now() + 600000)
|
||||
});
|
||||
|
||||
// Trigger Terminal Update
|
||||
terminalService.update();
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to spawn lootdrop:", error);
|
||||
}
|
||||
@@ -155,6 +148,9 @@ class LootdropService {
|
||||
`Claimed lootdrop in channel ${drop.channelId}`
|
||||
);
|
||||
|
||||
// Trigger Terminal Update
|
||||
terminalService.update();
|
||||
|
||||
return { success: true, amount: drop.rewardAmount, currency: drop.currency };
|
||||
|
||||
} catch (error) {
|
||||
|
||||
43
src/modules/economy/lootdrop.view.ts
Normal file
43
src/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]
|
||||
};
|
||||
}
|
||||
@@ -1,40 +1,34 @@
|
||||
import { ButtonInteraction, MessageFlags } from "discord.js";
|
||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||
import { userService } from "@/modules/user/user.service";
|
||||
import { createErrorEmbed, createWarningEmbed } from "@/lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
|
||||
export async function handleShopInteraction(interaction: ButtonInteraction) {
|
||||
if (!interaction.customId.startsWith("shop_buy_")) return;
|
||||
|
||||
try {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const itemId = parseInt(interaction.customId.replace("shop_buy_", ""));
|
||||
if (isNaN(itemId)) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Item ID.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const item = await inventoryService.getItem(itemId);
|
||||
if (!item || !item.price) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Item not found or not for sale.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
|
||||
// Double check balance here too, although service handles it, we want a nice message
|
||||
if ((user.balance ?? 0n) < item.price) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed(`You need ${item.price} 🪙 to buy this item. You have ${user.balance} 🪙.`)] });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await inventoryService.buyItem(user.id, item.id, 1n);
|
||||
|
||||
await interaction.editReply({ content: `✅ **Success!** You bought **${item.name}** for ${item.price} 🪙.` });
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Shop Purchase Error:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An error occurred while processing your purchase.")] });
|
||||
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
src/modules/economy/shop.view.ts
Normal file
20
src/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
src/modules/feedback/feedback.interaction.ts
Normal file
79
src/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 "@/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
src/modules/feedback/feedback.types.ts
Normal file
23
src/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
src/modules/feedback/feedback.view.ts
Normal file
123
src/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];
|
||||
}
|
||||
62
src/modules/inventory/effects/handlers.ts
Normal file
62
src/modules/inventory/effects/handlers.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { levelingService } from "@/modules/leveling/leveling.service";
|
||||
import { economyService } from "@/modules/economy/economy.service";
|
||||
import { userTimers } from "@/db/schema";
|
||||
import type { EffectHandler } from "./types";
|
||||
|
||||
// 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), '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: '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: '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";
|
||||
};
|
||||
18
src/modules/inventory/effects/registry.ts
Normal file
18
src/modules/inventory/effects/registry.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
handleAddXp,
|
||||
handleAddBalance,
|
||||
handleReplyMessage,
|
||||
handleXpBoost,
|
||||
handleTempRole,
|
||||
handleColorRole
|
||||
} 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
|
||||
};
|
||||
4
src/modules/inventory/effects/types.ts
Normal file
4
src/modules/inventory/effects/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
import type { Transaction } from "@/lib/types";
|
||||
|
||||
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<string>;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import { inventoryService } from "./inventory.service";
|
||||
import { inventory, items, userTimers } from "@/db/schema";
|
||||
import { inventory, userTimers } from "@/db/schema";
|
||||
// Helper to mock resolved value for spyOn
|
||||
import { economyService } from "@/modules/economy/economy.service";
|
||||
import { levelingService } from "@/modules/leveling/leveling.service";
|
||||
|
||||
@@ -4,15 +4,11 @@ import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import { economyService } from "@/modules/economy/economy.service";
|
||||
import { levelingService } from "@/modules/leveling/leveling.service";
|
||||
import { config } from "@/lib/config";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { withTransaction } from "@/lib/db";
|
||||
import type { Transaction, ItemUsageData } from "@/lib/types";
|
||||
|
||||
// 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 inventoryService = {
|
||||
addItem: async (userId: string, itemId: number, quantity: bigint = 1n, tx?: Transaction) => {
|
||||
@@ -28,7 +24,7 @@ export const inventoryService = {
|
||||
if (existing) {
|
||||
const newQuantity = (existing.quantity ?? 0n) + quantity;
|
||||
if (newQuantity > config.inventory.maxStackSize) {
|
||||
throw new Error(`Cannot exceed max stack size of ${config.inventory.maxStackSize}`);
|
||||
throw new UserError(`Cannot exceed max stack size of ${config.inventory.maxStackSize}`);
|
||||
}
|
||||
|
||||
const [entry] = await txFn.update(inventory)
|
||||
@@ -49,11 +45,11 @@ export const inventoryService = {
|
||||
.where(eq(inventory.userId, BigInt(userId)));
|
||||
|
||||
if (inventoryCount && inventoryCount.count >= config.inventory.maxSlots) {
|
||||
throw new Error(`Inventory full (Max ${config.inventory.maxSlots} slots)`);
|
||||
throw new UserError(`Inventory full (Max ${config.inventory.maxSlots} slots)`);
|
||||
}
|
||||
|
||||
if (quantity > config.inventory.maxStackSize) {
|
||||
throw new Error(`Cannot exceed max stack size of ${config.inventory.maxStackSize}`);
|
||||
throw new UserError(`Cannot exceed max stack size of ${config.inventory.maxStackSize}`);
|
||||
}
|
||||
|
||||
const [entry] = await txFn.insert(inventory)
|
||||
@@ -78,7 +74,7 @@ export const inventoryService = {
|
||||
});
|
||||
|
||||
if (!existing || (existing.quantity ?? 0n) < quantity) {
|
||||
throw new Error("Insufficient item quantity");
|
||||
throw new UserError("Insufficient item quantity");
|
||||
}
|
||||
|
||||
if ((existing.quantity ?? 0n) === quantity) {
|
||||
@@ -119,8 +115,8 @@ export const inventoryService = {
|
||||
where: eq(items.id, itemId),
|
||||
});
|
||||
|
||||
if (!item) throw new Error("Item not found");
|
||||
if (!item.price) throw new Error("Item is not for sale");
|
||||
if (!item) throw new UserError("Item not found");
|
||||
if (!item.price) throw new UserError("Item is not for sale");
|
||||
|
||||
const totalPrice = item.price * quantity;
|
||||
|
||||
@@ -151,63 +147,30 @@ export const inventoryService = {
|
||||
});
|
||||
|
||||
if (!entry || (entry.quantity ?? 0n) < 1n) {
|
||||
throw new Error("You do not own this item.");
|
||||
throw new UserError("You do not own this item.");
|
||||
}
|
||||
|
||||
const item = entry.item;
|
||||
const usageData = item.usageData as ItemUsageData | null;
|
||||
|
||||
if (!usageData || !usageData.effects || usageData.effects.length === 0) {
|
||||
throw new Error("This item cannot be used.");
|
||||
throw new UserError("This item cannot be used.");
|
||||
}
|
||||
|
||||
const results: string[] = [];
|
||||
|
||||
// 2. Apply Effects
|
||||
// 2. Apply Effects
|
||||
const { effectHandlers } = await import("./effects/registry");
|
||||
|
||||
for (const effect of usageData.effects) {
|
||||
switch (effect.type) {
|
||||
case 'ADD_XP':
|
||||
await levelingService.addXp(userId, BigInt(effect.amount), txFn);
|
||||
results.push(`Gained ${effect.amount} XP`);
|
||||
break;
|
||||
case 'ADD_BALANCE':
|
||||
await economyService.modifyUserBalance(userId, BigInt(effect.amount), 'ITEM_USE', `Used ${item.name}`, null, txFn);
|
||||
results.push(`Gained ${effect.amount} 🪙`);
|
||||
break;
|
||||
case 'REPLY_MESSAGE':
|
||||
results.push(effect.message);
|
||||
break;
|
||||
case 'XP_BOOST':
|
||||
const boostDuration = getDuration(effect);
|
||||
const expiresAt = new Date(Date.now() + boostDuration * 1000);
|
||||
await txFn.insert(userTimers).values({
|
||||
userId: BigInt(userId),
|
||||
type: '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 } }
|
||||
});
|
||||
results.push(`XP Boost (${effect.multiplier}x) active for ${Math.floor(boostDuration / 60)}m`);
|
||||
break;
|
||||
case 'TEMP_ROLE':
|
||||
const roleDuration = getDuration(effect);
|
||||
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
|
||||
await txFn.insert(userTimers).values({
|
||||
userId: BigInt(userId),
|
||||
type: '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
|
||||
results.push(`Temporary Role granted for ${Math.floor(roleDuration / 60)}m`);
|
||||
break;
|
||||
const handler = effectHandlers[effect.type];
|
||||
if (handler) {
|
||||
const result = await handler(userId, effect, txFn);
|
||||
results.push(result);
|
||||
} else {
|
||||
console.warn(`No handler found for effect type: ${effect.type}`);
|
||||
results.push(`Effect ${effect.type} applied (no description)`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
40
src/modules/inventory/inventory.view.ts
Normal file
40
src/modules/inventory/inventory.view.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { EmbedBuilder } from "discord.js";
|
||||
import type { ItemUsageData } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* 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[], itemName?: string): EmbedBuilder {
|
||||
const description = results.map(r => `• ${r}`).join("\n");
|
||||
|
||||
return new EmbedBuilder()
|
||||
.setTitle("✅ Item Used!")
|
||||
.setDescription(description)
|
||||
.setColor(0x2ecc71); // Green/Success
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, setSystemTime } from "bun:test";
|
||||
import { levelingService } from "./leveling.service";
|
||||
import { users, userTimers } from "@/db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
// Mock dependencies
|
||||
const mockFindFirst = mock();
|
||||
|
||||
@@ -5,12 +5,42 @@ import { config } from "@/lib/config";
|
||||
import type { Transaction } from "@/lib/types";
|
||||
|
||||
export const levelingService = {
|
||||
// Calculate XP required for a specific level
|
||||
getXpForLevel: (level: number) => {
|
||||
return Math.floor(config.leveling.base * Math.pow(level, config.leveling.exponent));
|
||||
// Calculate total XP required to REACH a specific level (Cumulative)
|
||||
// Level 1 = 0 XP
|
||||
// Level 2 = Base * (1^Exp)
|
||||
// Level 3 = Level 2 + Base * (2^Exp)
|
||||
// ...
|
||||
getXpToReachLevel: (level: number) => {
|
||||
let total = 0;
|
||||
for (let l = 1; l < level; l++) {
|
||||
total += Math.floor(config.leveling.base * Math.pow(l, config.leveling.exponent));
|
||||
}
|
||||
return total;
|
||||
},
|
||||
|
||||
// Pure XP addition - No cooldown checks
|
||||
// Calculate level from Total XP
|
||||
getLevelFromXp: (totalXp: bigint) => {
|
||||
let level = 1;
|
||||
let xp = Number(totalXp);
|
||||
|
||||
while (true) {
|
||||
// XP needed to complete current level and reach next
|
||||
const xpForNext = Math.floor(config.leveling.base * Math.pow(level, config.leveling.exponent));
|
||||
if (xp < xpForNext) {
|
||||
return level;
|
||||
}
|
||||
xp -= xpForNext;
|
||||
level++;
|
||||
}
|
||||
},
|
||||
|
||||
// Get XP needed to complete the current level (for calculating next level threshold in isolation)
|
||||
// Used internally or for display
|
||||
getXpForNextLevel: (currentLevel: number) => {
|
||||
return Math.floor(config.leveling.base * Math.pow(currentLevel, config.leveling.exponent));
|
||||
},
|
||||
|
||||
// Cumulative XP addition
|
||||
addXp: async (id: string, amount: bigint, tx?: Transaction) => {
|
||||
return await withTransaction(async (txFn) => {
|
||||
// Get current state
|
||||
@@ -20,30 +50,24 @@ export const levelingService = {
|
||||
|
||||
if (!user) throw new Error("User not found");
|
||||
|
||||
let newXp = (user.xp ?? 0n) + amount;
|
||||
let currentLevel = user.level ?? 1;
|
||||
let levelUp = false;
|
||||
const currentXp = user.xp ?? 0n;
|
||||
const newXp = currentXp + amount;
|
||||
|
||||
// Check for level up loop
|
||||
let xpForNextLevel = BigInt(levelingService.getXpForLevel(currentLevel));
|
||||
|
||||
while (newXp >= xpForNextLevel) {
|
||||
newXp -= xpForNextLevel;
|
||||
currentLevel++;
|
||||
levelUp = true;
|
||||
xpForNextLevel = BigInt(levelingService.getXpForLevel(currentLevel));
|
||||
}
|
||||
// Calculate new level based on TOTAL accumulated XP
|
||||
const newLevel = levelingService.getLevelFromXp(newXp);
|
||||
const currentLevel = user.level ?? 1;
|
||||
const levelUp = newLevel > currentLevel;
|
||||
|
||||
// Update user
|
||||
const [updatedUser] = await txFn.update(users)
|
||||
.set({
|
||||
xp: newXp,
|
||||
level: currentLevel,
|
||||
level: newLevel,
|
||||
})
|
||||
.where(eq(users.id, BigInt(id)))
|
||||
.returning();
|
||||
|
||||
return { user: updatedUser, levelUp, currentLevel };
|
||||
return { user: updatedUser, levelUp, currentLevel: newLevel };
|
||||
}, tx);
|
||||
},
|
||||
|
||||
|
||||
48
src/modules/leveling/leveling.view.ts
Normal file
48
src/modules/leveling/leveling.view.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { EmbedBuilder } from "discord.js";
|
||||
|
||||
/**
|
||||
* User data for leaderboard display
|
||||
*/
|
||||
interface LeaderboardUser {
|
||||
username: string;
|
||||
level: number | null;
|
||||
xp: bigint | null;
|
||||
balance: 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'): string {
|
||||
const medal = getMedalEmoji(index);
|
||||
const value = type === 'xp'
|
||||
? `Lvl ${user.level ?? 1} (${user.xp ?? 0n} XP)`
|
||||
: `${user.balance ?? 0n} 🪙`;
|
||||
return `${medal} **${user.username}** — ${value}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a leaderboard embed for either XP or Balance rankings
|
||||
*/
|
||||
export function getLeaderboardEmbed(leaders: LeaderboardUser[], type: 'xp' | 'balance'): EmbedBuilder {
|
||||
const description = leaders.map((user, index) =>
|
||||
formatLeaderEntry(user, index, type)
|
||||
).join("\n");
|
||||
|
||||
const title = type === 'xp' ? "🏆 XP Leaderboard" : "💰 Richest Players";
|
||||
|
||||
return new EmbedBuilder()
|
||||
.setTitle(title)
|
||||
.setDescription(description)
|
||||
.setColor(0xFFD700); // Gold
|
||||
}
|
||||
158
src/modules/moderation/moderation.service.ts
Normal file
158
src/modules/moderation/moderation.service.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { moderationCases } from "@/db/schema";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import type { CreateCaseOptions, ClearCaseOptions, SearchCasesFilter, CaseType } from "./moderation.types";
|
||||
|
||||
export class ModerationService {
|
||||
/**
|
||||
* Generate the next sequential case ID
|
||||
*/
|
||||
private static async getNextCaseId(): Promise<string> {
|
||||
const latestCase = await DrizzleClient.query.moderationCases.findFirst({
|
||||
orderBy: [desc(moderationCases.id)],
|
||||
});
|
||||
|
||||
if (!latestCase) {
|
||||
return "CASE-0001";
|
||||
}
|
||||
|
||||
// Extract number from case ID (e.g., "CASE-0042" -> 42)
|
||||
const match = latestCase.caseId.match(/CASE-(\d+)/);
|
||||
if (!match || !match[1]) {
|
||||
return "CASE-0001";
|
||||
}
|
||||
|
||||
const nextNumber = parseInt(match[1], 10) + 1;
|
||||
return `CASE-${nextNumber.toString().padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new moderation case
|
||||
*/
|
||||
static async createCase(options: CreateCaseOptions) {
|
||||
const caseId = await this.getNextCaseId();
|
||||
|
||||
const [newCase] = await DrizzleClient.insert(moderationCases).values({
|
||||
caseId,
|
||||
type: options.type,
|
||||
userId: BigInt(options.userId),
|
||||
username: options.username,
|
||||
moderatorId: BigInt(options.moderatorId),
|
||||
moderatorName: options.moderatorName,
|
||||
reason: options.reason,
|
||||
metadata: options.metadata || {},
|
||||
active: options.type === 'warn' ? true : false, // Only warnings are "active" by default
|
||||
}).returning();
|
||||
|
||||
return newCase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a case by its case ID
|
||||
*/
|
||||
static async getCaseById(caseId: string) {
|
||||
return await DrizzleClient.query.moderationCases.findFirst({
|
||||
where: eq(moderationCases.caseId, caseId),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cases for a specific user
|
||||
*/
|
||||
static async getUserCases(userId: string, activeOnly: boolean = false) {
|
||||
const conditions = [eq(moderationCases.userId, BigInt(userId))];
|
||||
|
||||
if (activeOnly) {
|
||||
conditions.push(eq(moderationCases.active, true));
|
||||
}
|
||||
|
||||
return await DrizzleClient.query.moderationCases.findMany({
|
||||
where: and(...conditions),
|
||||
orderBy: [desc(moderationCases.createdAt)],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active warnings for a user
|
||||
*/
|
||||
static async getUserWarnings(userId: string) {
|
||||
return await DrizzleClient.query.moderationCases.findMany({
|
||||
where: and(
|
||||
eq(moderationCases.userId, BigInt(userId)),
|
||||
eq(moderationCases.type, 'warn'),
|
||||
eq(moderationCases.active, true)
|
||||
),
|
||||
orderBy: [desc(moderationCases.createdAt)],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all notes for a user
|
||||
*/
|
||||
static async getUserNotes(userId: string) {
|
||||
return await DrizzleClient.query.moderationCases.findMany({
|
||||
where: and(
|
||||
eq(moderationCases.userId, BigInt(userId)),
|
||||
eq(moderationCases.type, 'note')
|
||||
),
|
||||
orderBy: [desc(moderationCases.createdAt)],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear/resolve a warning
|
||||
*/
|
||||
static async clearCase(options: ClearCaseOptions) {
|
||||
const [updatedCase] = await DrizzleClient.update(moderationCases)
|
||||
.set({
|
||||
active: false,
|
||||
resolvedAt: new Date(),
|
||||
resolvedBy: BigInt(options.clearedBy),
|
||||
resolvedReason: options.reason || 'Manually cleared',
|
||||
})
|
||||
.where(eq(moderationCases.caseId, options.caseId))
|
||||
.returning();
|
||||
|
||||
return updatedCase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search cases with various filters
|
||||
*/
|
||||
static async searchCases(filter: SearchCasesFilter) {
|
||||
const conditions = [];
|
||||
|
||||
if (filter.userId) {
|
||||
conditions.push(eq(moderationCases.userId, BigInt(filter.userId)));
|
||||
}
|
||||
|
||||
if (filter.moderatorId) {
|
||||
conditions.push(eq(moderationCases.moderatorId, BigInt(filter.moderatorId)));
|
||||
}
|
||||
|
||||
if (filter.type) {
|
||||
conditions.push(eq(moderationCases.type, filter.type));
|
||||
}
|
||||
|
||||
if (filter.active !== undefined) {
|
||||
conditions.push(eq(moderationCases.active, filter.active));
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
return await DrizzleClient.query.moderationCases.findMany({
|
||||
where: whereClause,
|
||||
orderBy: [desc(moderationCases.createdAt)],
|
||||
limit: filter.limit || 50,
|
||||
offset: filter.offset || 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total count of active warnings for a user (useful for auto-timeout)
|
||||
*/
|
||||
static async getActiveWarningCount(userId: string): Promise<number> {
|
||||
const warnings = await this.getUserWarnings(userId);
|
||||
return warnings.length;
|
||||
}
|
||||
}
|
||||
44
src/modules/moderation/moderation.types.ts
Normal file
44
src/modules/moderation/moderation.types.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export type CaseType = 'warn' | 'timeout' | 'kick' | 'ban' | 'note' | 'prune';
|
||||
|
||||
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
src/modules/moderation/moderation.view.ts
Normal file
241
src/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.' });
|
||||
}
|
||||
198
src/modules/moderation/prune.service.ts
Normal file
198
src/modules/moderation/prune.service.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { Collection, Message, PermissionFlagsBits } from "discord.js";
|
||||
import type { TextBasedChannel } from "discord.js";
|
||||
import type { PruneOptions, PruneResult, PruneProgress } from "./prune.types";
|
||||
import { config } from "@/lib/config";
|
||||
|
||||
export class PruneService {
|
||||
/**
|
||||
* Delete messages from a channel based on provided options
|
||||
*/
|
||||
static async deleteMessages(
|
||||
channel: TextBasedChannel,
|
||||
options: PruneOptions,
|
||||
progressCallback?: (progress: PruneProgress) => Promise<void>
|
||||
): Promise<PruneResult> {
|
||||
// Validate channel permissions
|
||||
if (!('permissionsFor' in channel)) {
|
||||
throw new Error("Cannot check permissions for this channel type");
|
||||
}
|
||||
|
||||
const permissions = channel.permissionsFor(channel.client.user!);
|
||||
if (!permissions?.has(PermissionFlagsBits.ManageMessages)) {
|
||||
throw new Error("Missing permission to manage messages in this channel");
|
||||
}
|
||||
|
||||
const { amount, userId, all } = options;
|
||||
const batchSize = config.moderation.prune.batchSize;
|
||||
const batchDelay = config.moderation.prune.batchDelayMs;
|
||||
|
||||
let totalDeleted = 0;
|
||||
let totalSkipped = 0;
|
||||
let requestedCount = amount || 10;
|
||||
let lastMessageId: string | undefined;
|
||||
let username: string | undefined;
|
||||
|
||||
if (all) {
|
||||
// Delete all messages in batches
|
||||
const estimatedTotal = await this.estimateMessageCount(channel);
|
||||
requestedCount = estimatedTotal;
|
||||
|
||||
while (true) {
|
||||
const messages = await this.fetchMessages(channel, batchSize, lastMessageId);
|
||||
|
||||
if (messages.size === 0) break;
|
||||
|
||||
const { deleted, skipped } = await this.processBatch(
|
||||
channel,
|
||||
messages,
|
||||
userId
|
||||
);
|
||||
|
||||
totalDeleted += deleted;
|
||||
totalSkipped += skipped;
|
||||
|
||||
// Update progress
|
||||
if (progressCallback) {
|
||||
await progressCallback({
|
||||
current: totalDeleted,
|
||||
total: estimatedTotal
|
||||
});
|
||||
}
|
||||
|
||||
// If we deleted fewer than we fetched, we've hit old messages
|
||||
if (deleted < messages.size) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Get the ID of the last message for pagination
|
||||
const lastMessage = Array.from(messages.values()).pop();
|
||||
lastMessageId = lastMessage?.id;
|
||||
|
||||
// Delay to avoid rate limits
|
||||
if (messages.size >= batchSize) {
|
||||
await this.delay(batchDelay);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Delete specific amount
|
||||
const limit = Math.min(amount || 10, config.moderation.prune.maxAmount);
|
||||
const messages = await this.fetchMessages(channel, limit, undefined);
|
||||
|
||||
const { deleted, skipped } = await this.processBatch(
|
||||
channel,
|
||||
messages,
|
||||
userId
|
||||
);
|
||||
|
||||
totalDeleted = deleted;
|
||||
totalSkipped = skipped;
|
||||
requestedCount = limit;
|
||||
}
|
||||
|
||||
// Get username if filtering by user
|
||||
if (userId && totalDeleted > 0) {
|
||||
try {
|
||||
const user = await channel.client.users.fetch(userId);
|
||||
username = user.username;
|
||||
} catch {
|
||||
username = "Unknown User";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
deletedCount: totalDeleted,
|
||||
requestedCount,
|
||||
filtered: !!userId,
|
||||
username,
|
||||
skippedOld: totalSkipped > 0 ? totalSkipped : undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch messages from a channel
|
||||
*/
|
||||
private static async fetchMessages(
|
||||
channel: TextBasedChannel,
|
||||
limit: number,
|
||||
before?: string
|
||||
): Promise<Collection<string, Message>> {
|
||||
if (!('messages' in channel)) {
|
||||
return new Collection();
|
||||
}
|
||||
|
||||
return await channel.messages.fetch({
|
||||
limit,
|
||||
before
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batch of messages for deletion
|
||||
*/
|
||||
private static async processBatch(
|
||||
channel: TextBasedChannel,
|
||||
messages: Collection<string, Message>,
|
||||
userId?: string
|
||||
): Promise<{ deleted: number; skipped: number }> {
|
||||
if (!('bulkDelete' in channel)) {
|
||||
throw new Error("This channel type does not support bulk deletion");
|
||||
}
|
||||
|
||||
// Filter by user if specified
|
||||
let messagesToDelete = messages;
|
||||
if (userId) {
|
||||
messagesToDelete = messages.filter(msg => msg.author.id === userId);
|
||||
}
|
||||
|
||||
if (messagesToDelete.size === 0) {
|
||||
return { deleted: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
// bulkDelete with filterOld=true will automatically skip messages >14 days
|
||||
const deleted = await channel.bulkDelete(messagesToDelete, true);
|
||||
const skipped = messagesToDelete.size - deleted.size;
|
||||
|
||||
return {
|
||||
deleted: deleted.size,
|
||||
skipped
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error during bulk delete:", error);
|
||||
throw new Error("Failed to delete messages");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate the total number of messages in a channel
|
||||
*/
|
||||
static async estimateMessageCount(channel: TextBasedChannel): Promise<number> {
|
||||
if (!('messages' in channel)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch a small sample to get the oldest message
|
||||
const sample = await channel.messages.fetch({ limit: 1 });
|
||||
if (sample.size === 0) return 0;
|
||||
|
||||
// This is a rough estimate - Discord doesn't provide exact counts
|
||||
// We'll return a conservative estimate
|
||||
const oldestMessage = sample.first();
|
||||
const channelAge = Date.now() - (oldestMessage?.createdTimestamp || Date.now());
|
||||
const estimatedRate = 100; // messages per day (conservative)
|
||||
const daysOld = channelAge / (1000 * 60 * 60 * 24);
|
||||
|
||||
return Math.max(100, Math.round(daysOld * estimatedRate));
|
||||
} catch {
|
||||
return 100; // Default estimate
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to delay execution
|
||||
*/
|
||||
private static delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
18
src/modules/moderation/prune.types.ts
Normal file
18
src/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
src/modules/moderation/prune.view.ts
Normal file
115
src/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();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
import { quests, userQuests, users } from "@/db/schema";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { userQuests } from "@/db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import { economyService } from "@/modules/economy/economy.service";
|
||||
import { levelingService } from "@/modules/leveling/leveling.service";
|
||||
@@ -45,8 +45,8 @@ export const questService = {
|
||||
}
|
||||
});
|
||||
|
||||
if (!userQuest) throw new Error("Quest not assigned");
|
||||
if (userQuest.completedAt) throw new Error("Quest already completed");
|
||||
if (!userQuest) throw new UserError("Quest not assigned");
|
||||
if (userQuest.completedAt) throw new UserError("Quest already completed");
|
||||
|
||||
// Mark completed
|
||||
await txFn.update(userQuests)
|
||||
|
||||
54
src/modules/quest/quest.view.ts
Normal file
54
src/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;
|
||||
}
|
||||
@@ -18,6 +18,12 @@ export const schedulerService = {
|
||||
setInterval(() => {
|
||||
schedulerService.runJanitor();
|
||||
}, 60 * 1000);
|
||||
|
||||
// Terminal Update Loop (every 60s)
|
||||
const { terminalService } = require("@/modules/terminal/terminal.service");
|
||||
setInterval(() => {
|
||||
terminalService.update();
|
||||
}, 60 * 1000);
|
||||
},
|
||||
|
||||
runJanitor: async () => {
|
||||
|
||||
158
src/modules/terminal/terminal.service.ts
Normal file
158
src/modules/terminal/terminal.service.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { TextChannel, Message, ContainerBuilder, TextDisplayBuilder, SectionBuilder, MessageFlags } from "discord.js";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import { users, transactions, lootdrops } from "@/db/schema";
|
||||
import { desc } from "drizzle-orm";
|
||||
import { config, saveConfig } from "@/lib/config";
|
||||
|
||||
export const terminalService = {
|
||||
init: async (channel: TextChannel) => {
|
||||
// limit to one terminal for now
|
||||
if (config.terminal) {
|
||||
try {
|
||||
const oldChannel = await AuroraClient.channels.fetch(config.terminal.channelId) as TextChannel;
|
||||
if (oldChannel) {
|
||||
const oldMsg = await oldChannel.messages.fetch(config.terminal.messageId);
|
||||
if (oldMsg) await oldMsg.delete();
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore if old message doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
const msg = await channel.send({ content: "🔄 Initializing Aurora Observatory..." });
|
||||
|
||||
config.terminal = {
|
||||
channelId: channel.id,
|
||||
messageId: msg.id
|
||||
};
|
||||
saveConfig(config);
|
||||
|
||||
await terminalService.update();
|
||||
},
|
||||
|
||||
update: async () => {
|
||||
if (!config.terminal) return;
|
||||
|
||||
try {
|
||||
const channel = await AuroraClient.channels.fetch(config.terminal.channelId).catch(() => null) as TextChannel;
|
||||
if (!channel) {
|
||||
console.warn("Terminal channel not found");
|
||||
return;
|
||||
}
|
||||
const message = await channel.messages.fetch(config.terminal.messageId).catch(() => null);
|
||||
if (!message) {
|
||||
console.warn("Terminal message not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const containers = await terminalService.buildMessage();
|
||||
|
||||
// Components V2 requires the IsComponentsV2 flag and no content/embeds
|
||||
// Disable allowedMentions to prevent pings from the dashboard
|
||||
await message.edit({
|
||||
content: null,
|
||||
embeds: null as any,
|
||||
components: containers as any,
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
allowedMentions: { parse: [] }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to update terminal:", error);
|
||||
}
|
||||
},
|
||||
|
||||
buildMessage: async () => {
|
||||
// 1. Data Fetching
|
||||
const allUsers = await DrizzleClient.select().from(users);
|
||||
const totalUsers = allUsers.length;
|
||||
const totalWealth = allUsers.reduce((acc, u) => acc + (u.balance || 0n), 0n);
|
||||
|
||||
// 2. Leaderboards Calculation
|
||||
const topLevels = [...allUsers].sort((a, b) => (b.level || 0) - (a.level || 0)).slice(0, 3);
|
||||
const topWealth = [...allUsers].sort((a, b) => Number(b.balance || 0n) - Number(a.balance || 0n)).slice(0, 3);
|
||||
|
||||
const formatUser = (u: typeof users.$inferSelect, i: number) => {
|
||||
const star = i === 0 ? "🌟" : i === 1 ? "⭐" : "✨";
|
||||
return `${star} <@${u.id}>`;
|
||||
};
|
||||
|
||||
const levelText = topLevels.map((u, i) => `> ${formatUser(u, i)} • Lvl ${u.level}`).join("\n") || "> *The sky is empty...*";
|
||||
const wealthText = topWealth.map((u, i) => `> ${formatUser(u, i)} • ${u.balance} AU`).join("\n") || "> *The sky is empty...*";
|
||||
|
||||
// 3. Lootdrops Data
|
||||
const activeDrops = await DrizzleClient.query.lootdrops.findMany({
|
||||
where: (lootdrops, { isNull }) => isNull(lootdrops.claimedBy),
|
||||
limit: 1,
|
||||
orderBy: desc(lootdrops.createdAt)
|
||||
});
|
||||
|
||||
const recentDrops = await DrizzleClient.query.lootdrops.findMany({
|
||||
where: (lootdrops, { isNotNull }) => isNotNull(lootdrops.claimedBy),
|
||||
limit: 1,
|
||||
orderBy: desc(lootdrops.createdAt)
|
||||
});
|
||||
|
||||
// --- CONTAINER 1: Header ---
|
||||
const headerContainer = new ContainerBuilder()
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("# 🌌 AURORA OBSERVATORY"),
|
||||
new TextDisplayBuilder().setContent("*Current Moon Phase: Waxing Crescent 🌒*")
|
||||
);
|
||||
|
||||
// --- CONTAINER 2: Observation Log ---
|
||||
let phenomenaContent = "";
|
||||
|
||||
if (activeDrops.length > 0 && activeDrops[0]) {
|
||||
const drop = activeDrops[0];
|
||||
phenomenaContent = `\n**SHOOTING STAR DETECTED**\nRadiance: \`${drop.rewardAmount} ${drop.currency}\`\nCoordinates: <#${drop.channelId}>\nImpact: <t:${Math.floor(drop.expiresAt!.getTime() / 1000)}:R>`;
|
||||
} else if (recentDrops.length > 0 && recentDrops[0]) {
|
||||
const drop = recentDrops[0];
|
||||
const claimer = allUsers.find(u => u.id === drop.claimedBy);
|
||||
phenomenaContent = `\n**RECENT EVENT**\nStar yielded \`${drop.rewardAmount} ${drop.currency}\` to ${claimer ? `<@${claimer.id}>` : '**Unknown**'}`;
|
||||
}
|
||||
|
||||
const logContainer = new ContainerBuilder()
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("## 🔭 OBSERVATION LOG"),
|
||||
new TextDisplayBuilder().setContent(`> **Stargazers**: \`${totalUsers}\`\n> **Astral Wealth**: \`${totalWealth.toLocaleString()} AU\`${phenomenaContent}`)
|
||||
);
|
||||
|
||||
// --- CONTAINER 3: Leaders ---
|
||||
const leaderContainer = new ContainerBuilder()
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("## ✨ CONSTELLATION LEADERS"),
|
||||
new TextDisplayBuilder().setContent(`**Brightest Stars**\n${levelText}`),
|
||||
new TextDisplayBuilder().setContent(`**Gilded Nebulas**\n${wealthText}`)
|
||||
);
|
||||
|
||||
// --- CONTAINER 4: Echoes ---
|
||||
const recentTx = await DrizzleClient.query.transactions.findMany({
|
||||
limit: 5,
|
||||
orderBy: [desc(transactions.createdAt)]
|
||||
});
|
||||
|
||||
const activityLines = recentTx.map(tx => {
|
||||
const time = Math.floor(tx.createdAt!.getTime() / 1000);
|
||||
let icon = "💫";
|
||||
if (tx.type.includes("LOOT")) icon = "🌠";
|
||||
if (tx.type.includes("GIFT")) icon = "🌕";
|
||||
const user = allUsers.find(u => u.id === tx.userId);
|
||||
|
||||
// the description might contain a channel id all the way at the end
|
||||
const channelId = tx.description?.split(" ").pop() || "";
|
||||
const text = tx.description?.replace(channelId, "<#" + channelId + ">") || "";
|
||||
return `<t:${time}:F> ${icon} ${user ? `<@${user.id}>` : '**Unknown**'}: ${text}`;
|
||||
});
|
||||
|
||||
const echoesContainer = new ContainerBuilder()
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("## 📡 COSMIC ECHOES"),
|
||||
new TextDisplayBuilder().setContent(activityLines.join("\n") || "Silence...")
|
||||
);
|
||||
|
||||
|
||||
return [headerContainer, logContainer, leaderContainer, echoesContainer];
|
||||
}
|
||||
};
|
||||
@@ -1,24 +1,19 @@
|
||||
import {
|
||||
type Interaction,
|
||||
ButtonInteraction,
|
||||
ModalSubmitInteraction,
|
||||
StringSelectMenuInteraction,
|
||||
type Interaction,
|
||||
EmbedBuilder,
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
StringSelectMenuBuilder,
|
||||
ModalBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle,
|
||||
ThreadChannel,
|
||||
TextChannel,
|
||||
EmbedBuilder
|
||||
} from "discord.js";
|
||||
import { TradeService } from "./trade.service";
|
||||
import { tradeService } from "./trade.service";
|
||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@lib/errors";
|
||||
import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view";
|
||||
|
||||
|
||||
const EMBED_COLOR = 0xFFD700; // Gold
|
||||
|
||||
export async function handleTradeInteraction(interaction: Interaction) {
|
||||
if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return;
|
||||
@@ -28,43 +23,34 @@ export async function handleTradeInteraction(interaction: Interaction) {
|
||||
|
||||
if (!threadId) return;
|
||||
|
||||
try {
|
||||
if (customId === 'trade_cancel') {
|
||||
await handleCancel(interaction, threadId);
|
||||
} else if (customId === 'trade_lock') {
|
||||
await handleLock(interaction, threadId);
|
||||
} else if (customId === 'trade_confirm') {
|
||||
// Confirm logic is handled implicitly by both locking or explicitly if needed.
|
||||
// For now, locking both triggers execution, so no separate confirm handler is actively used
|
||||
// unless we re-introduce a specific button. keeping basic handler stub if needed.
|
||||
} else if (customId === 'trade_add_money') {
|
||||
await handleAddMoneyClick(interaction);
|
||||
} else if (customId === 'trade_money_modal') {
|
||||
await handleMoneySubmit(interaction as ModalSubmitInteraction, threadId);
|
||||
} else if (customId === 'trade_add_item') {
|
||||
await handleAddItemClick(interaction as ButtonInteraction, threadId);
|
||||
} else if (customId === 'trade_select_item') {
|
||||
await handleItemSelect(interaction as StringSelectMenuInteraction, threadId);
|
||||
} else if (customId === 'trade_remove_item') {
|
||||
await handleRemoveItemClick(interaction as ButtonInteraction, threadId);
|
||||
} else if (customId === 'trade_remove_item_select') {
|
||||
await handleRemoveItemSelect(interaction as StringSelectMenuInteraction, threadId);
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorEmbed = createErrorEmbed(error.message);
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
await interaction.followUp({ embeds: [errorEmbed], ephemeral: true });
|
||||
} else {
|
||||
await interaction.reply({ embeds: [errorEmbed], ephemeral: true });
|
||||
}
|
||||
if (customId === 'trade_cancel') {
|
||||
await handleCancel(interaction, threadId);
|
||||
} else if (customId === 'trade_lock') {
|
||||
await handleLock(interaction, threadId);
|
||||
} else if (customId === 'trade_confirm') {
|
||||
// Confirm logic is handled implicitly by both locking or explicitly if needed.
|
||||
// For now, locking both triggers execution, so no separate confirm handler is actively used
|
||||
// unless we re-introduce a specific button. keeping basic handler stub if needed.
|
||||
} else if (customId === 'trade_add_money') {
|
||||
await handleAddMoneyClick(interaction);
|
||||
} else if (customId === 'trade_money_modal') {
|
||||
await handleMoneySubmit(interaction as ModalSubmitInteraction, threadId);
|
||||
} else if (customId === 'trade_add_item') {
|
||||
await handleAddItemClick(interaction as ButtonInteraction, threadId);
|
||||
} else if (customId === 'trade_select_item') {
|
||||
await handleItemSelect(interaction as StringSelectMenuInteraction, threadId);
|
||||
} else if (customId === 'trade_remove_item') {
|
||||
await handleRemoveItemClick(interaction as ButtonInteraction, threadId);
|
||||
} else if (customId === 'trade_remove_item_select') {
|
||||
await handleRemoveItemSelect(interaction as StringSelectMenuInteraction, threadId);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
|
||||
const session = TradeService.getSession(threadId);
|
||||
const session = tradeService.getSession(threadId);
|
||||
const user = interaction.user;
|
||||
|
||||
TradeService.endSession(threadId);
|
||||
tradeService.endSession(threadId);
|
||||
|
||||
await interaction.deferUpdate();
|
||||
|
||||
@@ -76,11 +62,11 @@ async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInt
|
||||
|
||||
async function handleLock(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
|
||||
await interaction.deferUpdate();
|
||||
const isLocked = TradeService.toggleLock(threadId, interaction.user.id);
|
||||
const isLocked = tradeService.toggleLock(threadId, interaction.user.id);
|
||||
await updateTradeDashboard(interaction, threadId);
|
||||
|
||||
// Check if trade executed (both locked)
|
||||
const session = TradeService.getSession(threadId);
|
||||
const session = tradeService.getSession(threadId);
|
||||
if (session && session.state === 'COMPLETED') {
|
||||
// Trade executed during updateTradeDashboard
|
||||
return;
|
||||
@@ -91,20 +77,7 @@ async function handleLock(interaction: ButtonInteraction | StringSelectMenuInter
|
||||
|
||||
async function handleAddMoneyClick(interaction: Interaction) {
|
||||
if (!interaction.isButton()) return;
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId('trade_money_modal')
|
||||
.setTitle('Add Money');
|
||||
|
||||
const input = new TextInputBuilder()
|
||||
.setCustomId('amount')
|
||||
.setLabel("Amount to trade")
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setPlaceholder("100")
|
||||
.setRequired(true);
|
||||
|
||||
const row = new ActionRowBuilder<TextInputBuilder>().addComponents(input);
|
||||
modal.addComponents(row);
|
||||
|
||||
const modal = getTradeMoneyModal();
|
||||
await interaction.showModal(modal);
|
||||
}
|
||||
|
||||
@@ -112,9 +85,9 @@ async function handleMoneySubmit(interaction: ModalSubmitInteraction, threadId:
|
||||
const amountStr = interaction.fields.getTextInputValue('amount');
|
||||
const amount = BigInt(amountStr);
|
||||
|
||||
if (amount < 0n) throw new Error("Amount must be positive");
|
||||
if (amount < 0n) throw new UserError("Amount must be positive");
|
||||
|
||||
TradeService.updateMoney(threadId, interaction.user.id, amount);
|
||||
tradeService.updateMoney(threadId, interaction.user.id, amount);
|
||||
await interaction.deferUpdate(); // Acknowledge modal
|
||||
await updateTradeDashboard(interaction, threadId);
|
||||
}
|
||||
@@ -131,17 +104,11 @@ async function handleAddItemClick(interaction: ButtonInteraction, threadId: stri
|
||||
const options = inventory.slice(0, 25).map(entry => ({
|
||||
label: `${entry.item.name} (${entry.quantity})`,
|
||||
value: entry.item.id.toString(),
|
||||
description: `Rarity: ${entry.item.rarity}`
|
||||
description: `Rarity: ${entry.item.rarity} `
|
||||
}));
|
||||
|
||||
const select = new StringSelectMenuBuilder()
|
||||
.setCustomId('trade_select_item')
|
||||
.setPlaceholder('Select an item to add')
|
||||
.addOptions(options);
|
||||
|
||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
|
||||
|
||||
await interaction.reply({ content: "Select an item to add:", components: [row], ephemeral: true });
|
||||
const { components } = getItemSelectMenu(options, 'trade_select_item', 'Select an item to add');
|
||||
await interaction.reply({ content: "Select an item to add:", components, ephemeral: true });
|
||||
}
|
||||
|
||||
async function handleItemSelect(interaction: StringSelectMenuInteraction, threadId: string) {
|
||||
@@ -151,16 +118,16 @@ async function handleItemSelect(interaction: StringSelectMenuInteraction, thread
|
||||
|
||||
// Assuming implementation implies adding 1 item for now
|
||||
const item = await inventoryService.getItem(itemId);
|
||||
if (!item) throw new Error("Item not found");
|
||||
if (!item) throw new UserError("Item not found");
|
||||
|
||||
TradeService.addItem(threadId, interaction.user.id, { id: item.id, name: item.name }, 1n);
|
||||
tradeService.addItem(threadId, interaction.user.id, { id: item.id, name: item.name }, 1n);
|
||||
|
||||
await interaction.update({ content: `Added ${item.name} x1`, components: [] });
|
||||
await updateTradeDashboard(interaction, threadId);
|
||||
}
|
||||
|
||||
async function handleRemoveItemClick(interaction: ButtonInteraction, threadId: string) {
|
||||
const session = TradeService.getSession(threadId);
|
||||
const session = tradeService.getSession(threadId);
|
||||
if (!session) return;
|
||||
|
||||
const participant = session.userA.id === interaction.user.id ? session.userA : session.userB;
|
||||
@@ -175,21 +142,15 @@ async function handleRemoveItemClick(interaction: ButtonInteraction, threadId: s
|
||||
value: i.id.toString(),
|
||||
}));
|
||||
|
||||
const select = new StringSelectMenuBuilder()
|
||||
.setCustomId('trade_remove_item_select')
|
||||
.setPlaceholder('Select an item to remove')
|
||||
.addOptions(options);
|
||||
|
||||
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
|
||||
|
||||
await interaction.reply({ content: "Select an item to remove:", components: [row], ephemeral: true });
|
||||
const { components } = getItemSelectMenu(options, 'trade_remove_item_select', 'Select an item to remove');
|
||||
await interaction.reply({ content: "Select an item to remove:", components, ephemeral: true });
|
||||
}
|
||||
|
||||
async function handleRemoveItemSelect(interaction: StringSelectMenuInteraction, threadId: string) {
|
||||
const value = interaction.values[0];
|
||||
if (!value) return;
|
||||
const itemId = parseInt(value);
|
||||
TradeService.removeItem(threadId, interaction.user.id, itemId);
|
||||
tradeService.removeItem(threadId, interaction.user.id, itemId);
|
||||
|
||||
await interaction.update({ content: `Removed item.`, components: [] });
|
||||
await updateTradeDashboard(interaction, threadId);
|
||||
@@ -199,23 +160,15 @@ async function handleRemoveItemSelect(interaction: StringSelectMenuInteraction,
|
||||
// --- DASHBOARD UPDATER ---
|
||||
|
||||
export async function updateTradeDashboard(interaction: Interaction, threadId: string) {
|
||||
const session = TradeService.getSession(threadId);
|
||||
const session = tradeService.getSession(threadId);
|
||||
if (!session) return;
|
||||
|
||||
// Check Auto-Execute (If both locked)
|
||||
if (session.userA.locked && session.userB.locked) {
|
||||
// Execute Trade
|
||||
try {
|
||||
await TradeService.executeTrade(threadId);
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("✅ Trade Completed")
|
||||
.setColor("Green")
|
||||
.addFields(
|
||||
{ name: session.userA.username, value: formatOffer(session.userA), inline: true },
|
||||
{ name: session.userB.username, value: formatOffer(session.userB), inline: true }
|
||||
)
|
||||
.setTimestamp();
|
||||
|
||||
await tradeService.executeTrade(threadId);
|
||||
const embed = getTradeCompletedEmbed(session);
|
||||
await updateDashboardMessage(interaction, { embeds: [embed], components: [] });
|
||||
|
||||
// Notify and Schedule Cleanup
|
||||
@@ -223,7 +176,7 @@ export async function updateTradeDashboard(interaction: Interaction, threadId: s
|
||||
const successEmbed = createSuccessEmbed("Trade executed successfully. Items and funds have been transferred.", "Trade Complete");
|
||||
await scheduleThreadCleanup(
|
||||
interaction.channel,
|
||||
`🎉 Trade successful! <@${session.userA.id}> <@${session.userB.id}>\nThis thread will be deleted in 10 seconds.`,
|
||||
`🎉 Trade successful! < @${session.userA.id}> <@${session.userB.id}>\nThis thread will be deleted in 10 seconds.`,
|
||||
10000,
|
||||
successEmbed
|
||||
);
|
||||
@@ -246,33 +199,8 @@ export async function updateTradeDashboard(interaction: Interaction, threadId: s
|
||||
}
|
||||
|
||||
// Build Status Embed
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("🤝 Trading Session")
|
||||
.setColor(EMBED_COLOR)
|
||||
.addFields(
|
||||
{
|
||||
name: `${session.userA.username} ${session.userA.locked ? '✅ (Ready)' : '✏️ (Editing)'}`,
|
||||
value: formatOffer(session.userA),
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: `${session.userB.username} ${session.userB.locked ? '✅ (Ready)' : '✏️ (Editing)'}`,
|
||||
value: formatOffer(session.userB),
|
||||
inline: true
|
||||
}
|
||||
)
|
||||
.setFooter({ text: "Both parties must click Lock to confirm trade." });
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>()
|
||||
.addComponents(
|
||||
new ButtonBuilder().setCustomId('trade_add_item').setLabel('Add Item').setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId('trade_add_money').setLabel('Add Money').setStyle(ButtonStyle.Success),
|
||||
new ButtonBuilder().setCustomId('trade_remove_item').setLabel('Remove Item').setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId('trade_lock').setLabel('Lock / Unlock').setStyle(ButtonStyle.Primary),
|
||||
new ButtonBuilder().setCustomId('trade_cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger),
|
||||
);
|
||||
|
||||
await updateDashboardMessage(interaction, { embeds: [embed], components: [row] });
|
||||
const { embeds, components } = getTradeDashboard(session);
|
||||
await updateDashboardMessage(interaction, { embeds, components });
|
||||
}
|
||||
|
||||
async function updateDashboardMessage(interaction: Interaction, payload: any) {
|
||||
@@ -300,17 +228,7 @@ async function updateDashboardMessage(interaction: Interaction, payload: any) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatOffer(participant: any) {
|
||||
let text = "";
|
||||
if (participant.offer.money > 0n) {
|
||||
text += `💰 ${participant.offer.money} 🪙\n`;
|
||||
}
|
||||
if (participant.offer.items.length > 0) {
|
||||
text += participant.offer.items.map((i: any) => `- ${i.name} (x${i.quantity})`).join("\n");
|
||||
}
|
||||
if (text === "") text = "*Empty Offer*";
|
||||
return text;
|
||||
}
|
||||
|
||||
|
||||
async function scheduleThreadCleanup(channel: ThreadChannel | TextChannel, message: string, delayMs: number = 10000, embed?: EmbedBuilder) {
|
||||
try {
|
||||
@@ -322,7 +240,7 @@ async function scheduleThreadCleanup(channel: ThreadChannel | TextChannel, messa
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
if (channel.isThread()) {
|
||||
console.log(`Deleting thread: ${channel.id}`);
|
||||
console.log(`Deleting thread: ${channel.id} `);
|
||||
await channel.delete("Trade Session Ended");
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,17 +1,80 @@
|
||||
import type { TradeSession, TradeParticipant } from "./trade.types";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import { economyService } from "@/modules/economy/economy.service";
|
||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||
import { itemTransactions } from "@/db/schema";
|
||||
import { withTransaction } from "@/lib/db";
|
||||
import type { Transaction } from "@/lib/types";
|
||||
|
||||
export class TradeService {
|
||||
private static sessions = new Map<string, TradeSession>();
|
||||
// Module-level session storage
|
||||
const sessions = new Map<string, TradeSession>();
|
||||
|
||||
/**
|
||||
* Unlocks both participants in a trade session
|
||||
*/
|
||||
const unlockAll = (session: TradeSession) => {
|
||||
session.userA.locked = false;
|
||||
session.userB.locked = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Processes a one-way transfer from one participant to another
|
||||
*/
|
||||
const processTransfer = async (tx: Transaction, from: TradeParticipant, to: TradeParticipant, threadId: string) => {
|
||||
// 1. Money
|
||||
if (from.offer.money > 0n) {
|
||||
await economyService.modifyUserBalance(
|
||||
from.id,
|
||||
-from.offer.money,
|
||||
'TRADE_OUT',
|
||||
`Trade with ${to.username} (Thread: ${threadId})`,
|
||||
to.id,
|
||||
tx
|
||||
);
|
||||
await economyService.modifyUserBalance(
|
||||
to.id,
|
||||
from.offer.money,
|
||||
'TRADE_IN',
|
||||
`Trade with ${from.username} (Thread: ${threadId})`,
|
||||
from.id,
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Items
|
||||
for (const item of from.offer.items) {
|
||||
// Remove from sender
|
||||
await inventoryService.removeItem(from.id, item.id, item.quantity, tx);
|
||||
|
||||
// Add to receiver
|
||||
await inventoryService.addItem(to.id, item.id, item.quantity, tx);
|
||||
|
||||
// Log Item Transaction (Sender)
|
||||
await tx.insert(itemTransactions).values({
|
||||
userId: BigInt(from.id),
|
||||
relatedUserId: BigInt(to.id),
|
||||
itemId: item.id,
|
||||
quantity: -item.quantity,
|
||||
type: 'TRADE_OUT',
|
||||
description: `Traded to ${to.username}`,
|
||||
});
|
||||
|
||||
// Log Item Transaction (Receiver)
|
||||
await tx.insert(itemTransactions).values({
|
||||
userId: BigInt(to.id),
|
||||
relatedUserId: BigInt(from.id),
|
||||
itemId: item.id,
|
||||
quantity: item.quantity,
|
||||
type: 'TRADE_IN',
|
||||
description: `Received from ${from.username}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const tradeService = {
|
||||
/**
|
||||
* Creates a new trade session
|
||||
*/
|
||||
static createSession(threadId: string, userA: { id: string, username: string }, userB: { id: string, username: string }): TradeSession {
|
||||
createSession: (threadId: string, userA: { id: string, username: string }, userB: { id: string, username: string }): TradeSession => {
|
||||
const session: TradeSession = {
|
||||
threadId,
|
||||
userA: {
|
||||
@@ -30,24 +93,24 @@ export class TradeService {
|
||||
lastInteraction: Date.now()
|
||||
};
|
||||
|
||||
this.sessions.set(threadId, session);
|
||||
sessions.set(threadId, session);
|
||||
return session;
|
||||
}
|
||||
},
|
||||
|
||||
static getSession(threadId: string): TradeSession | undefined {
|
||||
return this.sessions.get(threadId);
|
||||
}
|
||||
getSession: (threadId: string): TradeSession | undefined => {
|
||||
return sessions.get(threadId);
|
||||
},
|
||||
|
||||
static endSession(threadId: string) {
|
||||
this.sessions.delete(threadId);
|
||||
}
|
||||
endSession: (threadId: string) => {
|
||||
sessions.delete(threadId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Updates an offer. If allowed, validation checks should be done BEFORE calling this.
|
||||
* unlocking logic is handled here (if offer changes, unlock both).
|
||||
*/
|
||||
static updateMoney(threadId: string, userId: string, amount: bigint) {
|
||||
const session = this.getSession(threadId);
|
||||
updateMoney: (threadId: string, userId: string, amount: bigint) => {
|
||||
const session = tradeService.getSession(threadId);
|
||||
if (!session) throw new Error("Session not found");
|
||||
if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active");
|
||||
|
||||
@@ -55,12 +118,12 @@ export class TradeService {
|
||||
if (!participant) throw new Error("User not in trade");
|
||||
|
||||
participant.offer.money = amount;
|
||||
this.unlockAll(session);
|
||||
unlockAll(session);
|
||||
session.lastInteraction = Date.now();
|
||||
}
|
||||
},
|
||||
|
||||
static addItem(threadId: string, userId: string, item: { id: number, name: string }, quantity: bigint) {
|
||||
const session = this.getSession(threadId);
|
||||
addItem: (threadId: string, userId: string, item: { id: number, name: string }, quantity: bigint) => {
|
||||
const session = tradeService.getSession(threadId);
|
||||
if (!session) throw new Error("Session not found");
|
||||
if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active");
|
||||
|
||||
@@ -74,12 +137,12 @@ export class TradeService {
|
||||
participant.offer.items.push({ id: item.id, name: item.name, quantity });
|
||||
}
|
||||
|
||||
this.unlockAll(session);
|
||||
unlockAll(session);
|
||||
session.lastInteraction = Date.now();
|
||||
}
|
||||
},
|
||||
|
||||
static removeItem(threadId: string, userId: string, itemId: number) {
|
||||
const session = this.getSession(threadId);
|
||||
removeItem: (threadId: string, userId: string, itemId: number) => {
|
||||
const session = tradeService.getSession(threadId);
|
||||
if (!session) throw new Error("Session not found");
|
||||
|
||||
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
|
||||
@@ -87,12 +150,12 @@ export class TradeService {
|
||||
|
||||
participant.offer.items = participant.offer.items.filter(i => i.id !== itemId);
|
||||
|
||||
this.unlockAll(session);
|
||||
unlockAll(session);
|
||||
session.lastInteraction = Date.now();
|
||||
}
|
||||
},
|
||||
|
||||
static toggleLock(threadId: string, userId: string): boolean {
|
||||
const session = this.getSession(threadId);
|
||||
toggleLock: (threadId: string, userId: string): boolean => {
|
||||
const session = tradeService.getSession(threadId);
|
||||
if (!session) throw new Error("Session not found");
|
||||
|
||||
const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null;
|
||||
@@ -102,12 +165,7 @@ export class TradeService {
|
||||
session.lastInteraction = Date.now();
|
||||
|
||||
return participant.locked;
|
||||
}
|
||||
|
||||
private static unlockAll(session: TradeSession) {
|
||||
session.userA.locked = false;
|
||||
session.userB.locked = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Executes the trade atomically.
|
||||
@@ -116,8 +174,8 @@ export class TradeService {
|
||||
* 3. Swaps items.
|
||||
* 4. Logs transactions.
|
||||
*/
|
||||
static async executeTrade(threadId: string): Promise<void> {
|
||||
const session = this.getSession(threadId);
|
||||
executeTrade: async (threadId: string): Promise<void> => {
|
||||
const session = tradeService.getSession(threadId);
|
||||
if (!session) throw new Error("Session not found");
|
||||
|
||||
if (!session.userA.locked || !session.userB.locked) {
|
||||
@@ -126,65 +184,14 @@ export class TradeService {
|
||||
|
||||
session.state = 'COMPLETED'; // Prevent double execution
|
||||
|
||||
await DrizzleClient.transaction(async (tx) => {
|
||||
await withTransaction(async (tx) => {
|
||||
// -- Validate & Execute User A -> User B --
|
||||
await this.processTransfer(tx, session.userA, session.userB, session.threadId);
|
||||
await processTransfer(tx, session.userA, session.userB, session.threadId);
|
||||
|
||||
// -- Validate & Execute User B -> User A --
|
||||
await this.processTransfer(tx, session.userB, session.userA, session.threadId);
|
||||
await processTransfer(tx, session.userB, session.userA, session.threadId);
|
||||
});
|
||||
|
||||
this.endSession(threadId);
|
||||
tradeService.endSession(threadId);
|
||||
}
|
||||
|
||||
private static async processTransfer(tx: Transaction, from: TradeParticipant, to: TradeParticipant, threadId: string) {
|
||||
// 1. Money
|
||||
if (from.offer.money > 0n) {
|
||||
await economyService.modifyUserBalance(
|
||||
from.id,
|
||||
-from.offer.money,
|
||||
'TRADE_OUT',
|
||||
`Trade with ${to.username} (Thread: ${threadId})`,
|
||||
to.id,
|
||||
tx
|
||||
);
|
||||
await economyService.modifyUserBalance(
|
||||
to.id,
|
||||
from.offer.money,
|
||||
'TRADE_IN',
|
||||
`Trade with ${from.username} (Thread: ${threadId})`,
|
||||
from.id,
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Items
|
||||
for (const item of from.offer.items) {
|
||||
// Remove from sender
|
||||
await inventoryService.removeItem(from.id, item.id, item.quantity, tx);
|
||||
|
||||
// Add to receiver
|
||||
await inventoryService.addItem(to.id, item.id, item.quantity, tx);
|
||||
|
||||
// Log Item Transaction (Sender)
|
||||
await tx.insert(itemTransactions).values({
|
||||
userId: BigInt(from.id),
|
||||
relatedUserId: BigInt(to.id),
|
||||
itemId: item.id,
|
||||
quantity: -item.quantity,
|
||||
type: 'TRADE_OUT',
|
||||
description: `Traded to ${to.username}`,
|
||||
});
|
||||
|
||||
// Log Item Transaction (Receiver)
|
||||
await tx.insert(itemTransactions).values({
|
||||
userId: BigInt(to.id),
|
||||
relatedUserId: BigInt(from.id),
|
||||
itemId: item.id,
|
||||
quantity: item.quantity,
|
||||
type: 'TRADE_IN',
|
||||
description: `Received from ${from.username}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
85
src/modules/trade/trade.view.ts
Normal file
85
src/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] };
|
||||
}
|
||||
@@ -1,120 +1,93 @@
|
||||
import { ButtonInteraction, MessageFlags } from "discord.js";
|
||||
import { config } from "@/lib/config";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { getEnrollmentSuccessMessage } from "./enrollment.view";
|
||||
import { classService } from "@modules/class/class.service";
|
||||
import { userService } from "@modules/user/user.service";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
||||
|
||||
export async function handleEnrollmentInteraction(interaction: ButtonInteraction) {
|
||||
if (!interaction.inCachedGuild()) {
|
||||
await interaction.reply({ content: "This action can only be performed in a server.", flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
throw new UserError("This action can only be performed in a server.");
|
||||
}
|
||||
|
||||
const { studentRole, visitorRole } = config;
|
||||
|
||||
if (!studentRole || !visitorRole) {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("No student or visitor role configured for enrollment.", "Configuration Error")],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
return;
|
||||
throw new UserError("No student or visitor role configured for enrollment.");
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Ensure user exists in DB and check current enrollment status
|
||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
// 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) {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("You are already enrolled in a class.", "Enrollment Failed")],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Check DB enrollment
|
||||
if (user.class) {
|
||||
throw new UserError("You are already enrolled in a class.");
|
||||
}
|
||||
|
||||
const member = interaction.member;
|
||||
const member = interaction.member;
|
||||
|
||||
// Check Discord role enrollment (Double safety)
|
||||
if (member.roles.cache.has(studentRole)) {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("You already have the student role.", "Enrollment Failed")],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 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 => c.roleId);
|
||||
// 2. Get available classes
|
||||
const allClasses = await classService.getAllClasses();
|
||||
const validClasses = allClasses.filter(c => c.roleId);
|
||||
|
||||
if (validClasses.length === 0) {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("No classes with specified roles found in database.", "Configuration Error")],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
return;
|
||||
}
|
||||
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!;
|
||||
// 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) {
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed(`The configured role ID \`${classRoleId}\` for class **${selectedClass.name}** does not exist in this server.`, "Configuration Error")],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 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
|
||||
// 4. Perform Enrollment Actions
|
||||
await member.roles.remove(visitorRole);
|
||||
await member.roles.add(studentRole);
|
||||
await member.roles.add(classRole);
|
||||
|
||||
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);
|
||||
|
||||
// Persist to DB
|
||||
await classService.assignClass(user.id.toString(), selectedClass.id);
|
||||
await interaction.reply({
|
||||
...getEnrollmentSuccessMessage(classRole.name),
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
|
||||
await interaction.reply({
|
||||
content: `🎉 You have been successfully enrolled! You received the **${classRole.name}** role.`,
|
||||
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}**.";
|
||||
|
||||
// 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);
|
||||
|
||||
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));
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(processedMessage);
|
||||
} catch {
|
||||
payload = processedMessage;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Enrollment error:", error);
|
||||
await interaction.reply({
|
||||
embeds: [createErrorEmbed("An unexpected error occurred during enrollment. Please contact an administrator.", "System Error")],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
// Fire and forget webhook
|
||||
sendWebhookMessage(welcomeChannel, payload, interaction.client.user, "New Student Enrollment")
|
||||
.catch((err: any) => console.error("Failed to send welcome message:", err));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/modules/user/enrollment.view.ts
Normal file
12
src/modules/user/enrollment.view.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
|
||||
export function getEnrollmentErrorEmbed(message: string, title: string = "Enrollment Failed") {
|
||||
const embed = createErrorEmbed(message, title);
|
||||
return { embeds: [embed] };
|
||||
}
|
||||
|
||||
export function getEnrollmentSuccessMessage(roleName: string) {
|
||||
return {
|
||||
content: `🎉 You have been successfully enrolled! You received the **${roleName}** role.`
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test";
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||
import { userService } from "./user.service";
|
||||
|
||||
// Define mock functions outside so we can control them in tests
|
||||
@@ -20,6 +20,7 @@ mockUpdate.mockReturnValue({ set: mockSet });
|
||||
mockSet.mockReturnValue({ where: mockWhere });
|
||||
mockWhere.mockReturnValue({ returning: mockReturning });
|
||||
|
||||
mockDelete.mockReturnValue({ where: mockWhere });
|
||||
|
||||
// Mock DrizzleClient
|
||||
mock.module("@/lib/DrizzleClient", () => {
|
||||
@@ -51,12 +52,39 @@ mock.module("@/lib/DrizzleClient", () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock withTransaction helper to use the same pattern as DrizzleClient.transaction
|
||||
mock.module("@/lib/db", () => {
|
||||
return {
|
||||
withTransaction: async (callback: any, tx?: any) => {
|
||||
if (tx) {
|
||||
return callback(tx);
|
||||
}
|
||||
// Simulate transaction by calling the callback with mock db
|
||||
return callback({
|
||||
query: {
|
||||
users: {
|
||||
findFirst: mockFindFirst,
|
||||
},
|
||||
},
|
||||
insert: mockInsert,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
describe("userService", () => {
|
||||
beforeEach(() => {
|
||||
mockFindFirst.mockReset();
|
||||
mockInsert.mockClear();
|
||||
mockValues.mockClear();
|
||||
mockReturning.mockClear();
|
||||
mockUpdate.mockClear();
|
||||
mockSet.mockClear();
|
||||
mockWhere.mockClear();
|
||||
mockDelete.mockClear();
|
||||
});
|
||||
|
||||
describe("getUserById", () => {
|
||||
@@ -80,7 +108,91 @@ describe("userService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createUser", () => {
|
||||
describe("getUserByUsername", () => {
|
||||
it("should return user when username exists", async () => {
|
||||
const mockUser = { id: 456n, username: "alice", balance: 100n };
|
||||
mockFindFirst.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await userService.getUserByUsername("alice");
|
||||
|
||||
expect(result).toEqual(mockUser as any);
|
||||
expect(mockFindFirst).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should return undefined when username not found", async () => {
|
||||
mockFindFirst.mockResolvedValue(undefined);
|
||||
|
||||
const result = await userService.getUserByUsername("nonexistent");
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserClass", () => {
|
||||
it("should return user class when user has a class", async () => {
|
||||
const mockClass = { id: 1n, name: "Warrior", emoji: "⚔️" };
|
||||
const mockUser = { id: 123n, username: "testuser", class: mockClass };
|
||||
mockFindFirst.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await userService.getUserClass("123");
|
||||
|
||||
expect(result).toEqual(mockClass as any);
|
||||
});
|
||||
|
||||
it("should return null when user has no class", async () => {
|
||||
const mockUser = { id: 123n, username: "testuser", class: null };
|
||||
mockFindFirst.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await userService.getUserClass("123");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return undefined when user not found", async () => {
|
||||
mockFindFirst.mockResolvedValue(undefined);
|
||||
|
||||
const result = await userService.getUserClass("999");
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrCreateUser (withTransaction)", () => {
|
||||
it("should return existing user if found", async () => {
|
||||
const mockUser = { id: 123n, username: "existinguser", class: null };
|
||||
mockFindFirst.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await userService.getOrCreateUser("123", "existinguser");
|
||||
|
||||
expect(result).toEqual(mockUser as any);
|
||||
expect(mockFindFirst).toHaveBeenCalledTimes(1);
|
||||
expect(mockInsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should create new user if not found", async () => {
|
||||
const newUser = { id: 789n, username: "newuser", classId: null };
|
||||
|
||||
// First call returns undefined (user not found)
|
||||
// Second call returns the newly created user (after insert + re-query)
|
||||
mockFindFirst
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({ id: 789n, username: "newuser", class: null });
|
||||
|
||||
mockReturning.mockResolvedValue([newUser]);
|
||||
|
||||
const result = await userService.getOrCreateUser("789", "newuser");
|
||||
|
||||
expect(mockInsert).toHaveBeenCalledTimes(1);
|
||||
expect(mockValues).toHaveBeenCalledWith({
|
||||
id: 789n,
|
||||
username: "newuser"
|
||||
});
|
||||
// Should query twice: once to check, once after insert
|
||||
expect(mockFindFirst).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createUser (withTransaction)", () => {
|
||||
it("should create and return a new user", async () => {
|
||||
const newUser = { id: 456n, username: "newuser", classId: null };
|
||||
mockReturning.mockResolvedValue([newUser]);
|
||||
@@ -95,5 +207,53 @@ describe("userService", () => {
|
||||
classId: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it("should create user with classId when provided", async () => {
|
||||
const newUser = { id: 999n, username: "warrior", classId: 5n };
|
||||
mockReturning.mockResolvedValue([newUser]);
|
||||
|
||||
const result = await userService.createUser("999", "warrior", 5n);
|
||||
|
||||
expect(result).toEqual(newUser as any);
|
||||
expect(mockValues).toHaveBeenCalledWith({
|
||||
id: 999n,
|
||||
username: "warrior",
|
||||
classId: 5n
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateUser (withTransaction)", () => {
|
||||
it("should update user data", async () => {
|
||||
const updatedUser = { id: 123n, username: "testuser", balance: 500n };
|
||||
mockReturning.mockResolvedValue([updatedUser]);
|
||||
|
||||
const result = await userService.updateUser("123", { balance: 500n });
|
||||
|
||||
expect(result).toEqual(updatedUser as any);
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(mockSet).toHaveBeenCalledWith({ balance: 500n });
|
||||
});
|
||||
|
||||
it("should update multiple fields", async () => {
|
||||
const updatedUser = { id: 456n, username: "alice", xp: 100n, level: 5 };
|
||||
mockReturning.mockResolvedValue([updatedUser]);
|
||||
|
||||
const result = await userService.updateUser("456", { xp: 100n, level: 5 });
|
||||
|
||||
expect(result).toEqual(updatedUser as any);
|
||||
expect(mockSet).toHaveBeenCalledWith({ xp: 100n, level: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteUser (withTransaction)", () => {
|
||||
it("should delete user from database", async () => {
|
||||
mockWhere.mockResolvedValue(undefined);
|
||||
|
||||
await userService.deleteUser("123");
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledTimes(1);
|
||||
expect(mockWhere).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { users } from "@/db/schema";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import { withTransaction } from "@/lib/db";
|
||||
import type { Transaction } from "@/lib/types";
|
||||
|
||||
export const userService = {
|
||||
getUserById: async (id: string) => {
|
||||
@@ -14,23 +16,27 @@ export const userService = {
|
||||
const user = await DrizzleClient.query.users.findFirst({ where: eq(users.username, username) });
|
||||
return user;
|
||||
},
|
||||
getOrCreateUser: async (id: string, username: string, tx?: any) => {
|
||||
const execute = async (txFn: any) => {
|
||||
getOrCreateUser: async (id: string, username: string, tx?: Transaction) => {
|
||||
return await withTransaction(async (txFn) => {
|
||||
let user = await txFn.query.users.findFirst({
|
||||
where: eq(users.id, BigInt(id)),
|
||||
with: { class: true }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
const [newUser] = await txFn.insert(users).values({
|
||||
await txFn.insert(users).values({
|
||||
id: BigInt(id),
|
||||
username,
|
||||
}).returning();
|
||||
user = { ...newUser, class: null };
|
||||
|
||||
// Re-query to get the user with class relation
|
||||
user = await txFn.query.users.findFirst({
|
||||
where: eq(users.id, BigInt(id)),
|
||||
with: { class: true }
|
||||
});
|
||||
}
|
||||
return user;
|
||||
};
|
||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
||||
}, tx);
|
||||
},
|
||||
getUserClass: async (id: string) => {
|
||||
const user = await DrizzleClient.query.users.findFirst({
|
||||
@@ -39,31 +45,28 @@ export const userService = {
|
||||
});
|
||||
return user?.class;
|
||||
},
|
||||
createUser: async (id: string | bigint, username: string, classId?: bigint, tx?: any) => {
|
||||
const execute = async (txFn: any) => {
|
||||
createUser: async (id: string | bigint, username: string, classId?: bigint, tx?: Transaction) => {
|
||||
return await withTransaction(async (txFn) => {
|
||||
const [user] = await txFn.insert(users).values({
|
||||
id: BigInt(id),
|
||||
username,
|
||||
classId,
|
||||
}).returning();
|
||||
return user;
|
||||
};
|
||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
||||
}, tx);
|
||||
},
|
||||
updateUser: async (id: string, data: Partial<typeof users.$inferInsert>, tx?: any) => {
|
||||
const execute = async (txFn: any) => {
|
||||
updateUser: async (id: string, data: Partial<typeof users.$inferInsert>, tx?: Transaction) => {
|
||||
return await withTransaction(async (txFn) => {
|
||||
const [user] = await txFn.update(users)
|
||||
.set(data)
|
||||
.where(eq(users.id, BigInt(id)))
|
||||
.returning();
|
||||
return user;
|
||||
};
|
||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
||||
}, tx);
|
||||
},
|
||||
deleteUser: async (id: string, tx?: any) => {
|
||||
const execute = async (txFn: any) => {
|
||||
deleteUser: async (id: string, tx?: Transaction) => {
|
||||
return await withTransaction(async (txFn) => {
|
||||
await txFn.delete(users).where(eq(users.id, BigInt(id)));
|
||||
};
|
||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
||||
}, tx);
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { userTimers } from "@/db/schema";
|
||||
import { eq, and, lt } from "drizzle-orm";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
|
||||
export type TimerType = 'COOLDOWN' | 'EFFECT' | 'ACCESS';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user