forked from syntaxbullet/AuroraBot-discord
refactor: initial moves
This commit is contained in:
54
bot/commands/admin/case.ts
Normal file
54
bot/commands/admin/case.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
export const moderationCase = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("case")
|
||||
.setDescription("View details of a specific moderation case")
|
||||
.addStringOption(option =>
|
||||
option
|
||||
.setName("case_id")
|
||||
.setDescription("The case ID (e.g., CASE-0001)")
|
||||
.setRequired(true)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||
|
||||
// Validate case ID format
|
||||
if (!caseId.match(/^CASE-\d+$/)) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the case
|
||||
const moderationCase = await ModerationService.getCaseById(caseId);
|
||||
|
||||
if (!moderationCase) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Display the case
|
||||
await interaction.editReply({
|
||||
embeds: [getCaseEmbed(moderationCase)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Case command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching the case.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
54
bot/commands/admin/cases.ts
Normal file
54
bot/commands/admin/cases.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
export const cases = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("cases")
|
||||
.setDescription("View all moderation cases for a user")
|
||||
.addUserOption(option =>
|
||||
option
|
||||
.setName("user")
|
||||
.setDescription("The user to check cases for")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addBooleanOption(option =>
|
||||
option
|
||||
.setName("active_only")
|
||||
.setDescription("Show only active cases (warnings)")
|
||||
.setRequired(false)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
const activeOnly = interaction.options.getBoolean("active_only") || false;
|
||||
|
||||
// Get cases for the user
|
||||
const userCases = await ModerationService.getUserCases(targetUser.id, activeOnly);
|
||||
|
||||
const title = activeOnly
|
||||
? `⚠️ Active Cases for ${targetUser.username}`
|
||||
: `📋 All Cases for ${targetUser.username}`;
|
||||
|
||||
const description = userCases.length === 0
|
||||
? undefined
|
||||
: `Total cases: **${userCases.length}**`;
|
||||
|
||||
// Display the cases
|
||||
await interaction.editReply({
|
||||
embeds: [getCasesListEmbed(userCases, title, description)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Cases command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching cases.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
84
bot/commands/admin/clearwarning.ts
Normal file
84
bot/commands/admin/clearwarning.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
export const clearwarning = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("clearwarning")
|
||||
.setDescription("Clear/resolve a warning")
|
||||
.addStringOption(option =>
|
||||
option
|
||||
.setName("case_id")
|
||||
.setDescription("The case ID to clear (e.g., CASE-0001)")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addStringOption(option =>
|
||||
option
|
||||
.setName("reason")
|
||||
.setDescription("Reason for clearing the warning")
|
||||
.setRequired(false)
|
||||
.setMaxLength(500)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const caseId = interaction.options.getString("case_id", true).toUpperCase();
|
||||
const reason = interaction.options.getString("reason") || "Cleared by moderator";
|
||||
|
||||
// Validate case ID format
|
||||
if (!caseId.match(/^CASE-\d+$/)) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if case exists and is active
|
||||
const existingCase = await ModerationService.getCaseById(caseId);
|
||||
|
||||
if (!existingCase) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existingCase.active) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** is already resolved.`)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingCase.type !== 'warn') {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed(`Case **${caseId}** is not a warning. Only warnings can be cleared.`)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the warning
|
||||
await ModerationService.clearCase({
|
||||
caseId,
|
||||
clearedBy: interaction.user.id,
|
||||
clearedByName: interaction.user.username,
|
||||
reason
|
||||
});
|
||||
|
||||
// Send success message
|
||||
await interaction.editReply({
|
||||
embeds: [getClearSuccessEmbed(caseId)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Clear warning command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while clearing the warning.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
68
bot/commands/admin/config.ts
Normal file
68
bot/commands/admin/config.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ModalSubmitInteraction } from "discord.js";
|
||||
import { config, saveConfig } from "@lib/config";
|
||||
import type { GameConfigType } from "@lib/config";
|
||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
||||
|
||||
export const configCommand = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("config")
|
||||
.setDescription("Edit the bot configuration")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
console.log(`Config command executed by ${interaction.user.tag}`);
|
||||
const replacer = (key: string, value: any) => {
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const currentConfigJson = JSON.stringify(config, replacer, 4);
|
||||
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId("config-modal")
|
||||
.setTitle("Edit Configuration");
|
||||
|
||||
const jsonInput = new TextInputBuilder()
|
||||
.setCustomId("json-input")
|
||||
.setLabel("Configuration JSON")
|
||||
.setStyle(TextInputStyle.Paragraph)
|
||||
.setValue(currentConfigJson)
|
||||
.setRequired(true);
|
||||
|
||||
const actionRow = new ActionRowBuilder<TextInputBuilder>().addComponents(jsonInput);
|
||||
modal.addComponents(actionRow);
|
||||
|
||||
await interaction.showModal(modal);
|
||||
|
||||
try {
|
||||
const submitted = await interaction.awaitModalSubmit({
|
||||
time: 300000, // 5 minutes
|
||||
filter: (i) => i.customId === "config-modal" && i.user.id === interaction.user.id
|
||||
});
|
||||
|
||||
const jsonString = submitted.fields.getTextInputValue("json-input");
|
||||
|
||||
try {
|
||||
const newConfig = JSON.parse(jsonString);
|
||||
saveConfig(newConfig as GameConfigType);
|
||||
|
||||
await submitted.reply({
|
||||
embeds: [createSuccessEmbed("Configuration updated successfully.", "Config Saved")]
|
||||
});
|
||||
} catch (parseError) {
|
||||
await submitted.reply({
|
||||
embeds: [createErrorEmbed(`Invalid JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`, "Config Update Failed")],
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Timeout or other error handling if needed, usually just ignore timeouts for modals
|
||||
if (error instanceof Error && error.message.includes('time')) {
|
||||
// specific timeout handling if desired
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
94
bot/commands/admin/create_color.ts
Normal file
94
bot/commands/admin/create_color.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js";
|
||||
import { config, saveConfig } from "@/lib/config";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { items } from "@db/schema";
|
||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
||||
|
||||
export const createColor = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("createcolor")
|
||||
.setDescription("Create a new Color Role and corresponding Item")
|
||||
.addStringOption(option =>
|
||||
option.setName("name")
|
||||
.setDescription("The name of the role and item")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addStringOption(option =>
|
||||
option.setName("color")
|
||||
.setDescription("The hex color code (e.g. #FF0000)")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addNumberOption(option =>
|
||||
option.setName("price")
|
||||
.setDescription("Price of the item (Default: 500)")
|
||||
.setRequired(false)
|
||||
)
|
||||
.addStringOption(option =>
|
||||
option.setName("image")
|
||||
.setDescription("Image URL for the item")
|
||||
.setRequired(false)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
|
||||
const name = interaction.options.getString("name", true);
|
||||
const colorInput = interaction.options.getString("color", true);
|
||||
const price = interaction.options.getNumber("price") || 500;
|
||||
const imageUrl = interaction.options.getString("image") || "https://cdn.discordapp.com/attachments/1450061247365124199/1453122950822760559/Main_Chip_1.png";
|
||||
|
||||
// 1. Validate Color
|
||||
const colorRegex = /^#([0-9A-F]{3}){1,2}$/i;
|
||||
if (!colorRegex.test(colorInput)) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Hex Color code. Format: #RRGGBB")] });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. Create Role
|
||||
const role = await interaction.guild?.roles.create({
|
||||
name: name,
|
||||
color: colorInput as any, // Discord.js types are a bit strict on ColorResolvable, but string generally works or needs parsing
|
||||
reason: `Created via /createcolor by ${interaction.user.tag}`
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
throw new Error("Failed to create role.");
|
||||
}
|
||||
|
||||
// 3. Update Config
|
||||
if (!config.colorRoles.includes(role.id)) {
|
||||
config.colorRoles.push(role.id);
|
||||
saveConfig(config);
|
||||
}
|
||||
|
||||
// 4. Create Item
|
||||
await DrizzleClient.insert(items).values({
|
||||
name: `Color Role - ${name}`,
|
||||
description: `Use this item to apply the ${name} color to your name.`,
|
||||
type: "CONSUMABLE",
|
||||
rarity: "Common",
|
||||
price: BigInt(price),
|
||||
iconUrl: "",
|
||||
imageUrl: imageUrl,
|
||||
usageData: {
|
||||
consume: false,
|
||||
effects: [{ type: "COLOR_ROLE", roleId: role.id }]
|
||||
} as any
|
||||
});
|
||||
|
||||
// 5. Success
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`**Role:** <@&${role.id}> (${colorInput})\n**Item:** Color Role - ${name}\n**Price:** ${price} 🪙`,
|
||||
"✅ Color Role & Item Created"
|
||||
)]
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error in createcolor:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(`Failed to create color role: ${error.message}`)] });
|
||||
}
|
||||
}
|
||||
});
|
||||
14
bot/commands/admin/create_item.ts
Normal file
14
bot/commands/admin/create_item.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { renderWizard } from "@/modules/admin/item_wizard";
|
||||
|
||||
export const createItem = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("createitem")
|
||||
.setDescription("Create a new item using the interactive wizard")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
const payload = renderWizard(interaction.user.id);
|
||||
await interaction.reply({ ...payload, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
});
|
||||
95
bot/commands/admin/features.ts
Normal file
95
bot/commands/admin/features.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
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";
|
||||
|
||||
export const features = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("features")
|
||||
.setDescription("Manage bot features and commands")
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("list")
|
||||
.setDescription("List all commands and their status")
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("toggle")
|
||||
.setDescription("Enable or disable a command")
|
||||
.addStringOption(option =>
|
||||
option.setName("command")
|
||||
.setDescription("The name of the command")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addBooleanOption(option =>
|
||||
option.setName("enabled")
|
||||
.setDescription("Whether the command should be enabled")
|
||||
.setRequired(true)
|
||||
)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
if (subcommand === "list") {
|
||||
const activeCommands = AuroraClient.commands;
|
||||
const categories = new Map<string, string[]>();
|
||||
|
||||
// Group active commands
|
||||
activeCommands.forEach(cmd => {
|
||||
const cat = cmd.category || 'Uncategorized';
|
||||
if (!categories.has(cat)) categories.set(cat, []);
|
||||
categories.get(cat)!.push(cmd.data.name);
|
||||
});
|
||||
|
||||
// Config overrides
|
||||
const overrides = Object.entries(config.commands)
|
||||
.map(([name, enabled]) => `• **${name}**: ${enabled ? "✅ Enabled (Override)" : "❌ Disabled"}`);
|
||||
|
||||
const embed = createBaseEmbed("Command Features", undefined, "Blue");
|
||||
|
||||
// Add fields for each category
|
||||
const sortedCategories = [...categories.keys()].sort();
|
||||
for (const cat of sortedCategories) {
|
||||
const cmds = categories.get(cat)!.sort();
|
||||
const cmdList = cmds.map(name => {
|
||||
const isOverride = config.commands[name] !== undefined;
|
||||
return isOverride ? `**${name}** (See Overrides)` : `**${name}**`;
|
||||
}).join(", ");
|
||||
|
||||
embed.addFields({ name: `📂 ${cat.toUpperCase()}`, value: cmdList || "None" });
|
||||
}
|
||||
|
||||
if (overrides.length > 0) {
|
||||
embed.addFields({ name: "⚙️ Configuration Overrides", value: overrides.join("\n") });
|
||||
} else {
|
||||
embed.addFields({ name: "⚙️ Configuration Overrides", value: "No overrides set." });
|
||||
}
|
||||
|
||||
// Check permissions manually as a fallback (though defaultMemberPermissions handles it at the API level)
|
||||
if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) {
|
||||
await interaction.reply({ content: "❌ You need Administrator permissions to use this command.", flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
|
||||
} else if (subcommand === "toggle") {
|
||||
const commandName = interaction.options.getString("command", true);
|
||||
const enabled = interaction.options.getBoolean("enabled", true);
|
||||
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
configManager.toggleCommand(commandName, enabled);
|
||||
|
||||
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Reloading configuration...` });
|
||||
|
||||
// Reload config from disk (which was updated by configManager)
|
||||
reloadConfig();
|
||||
|
||||
await AuroraClient.loadCommands(true);
|
||||
await AuroraClient.deployCommands();
|
||||
|
||||
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Commands reloaded!` });
|
||||
}
|
||||
}
|
||||
});
|
||||
84
bot/commands/admin/health.test.ts
Normal file
84
bot/commands/admin/health.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { health } from "./health";
|
||||
import { ChatInputCommandInteraction, Colors } from "discord.js";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
|
||||
// Mock DrizzleClient
|
||||
const executeMock = mock(() => Promise.resolve());
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: {
|
||||
execute: executeMock
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock BotClient (already has lastCommandTimestamp if imported, but we might want to control it)
|
||||
AuroraClient.lastCommandTimestamp = 1641481200000; // Fixed timestamp for testing
|
||||
|
||||
describe("Health Command", () => {
|
||||
beforeEach(() => {
|
||||
executeMock.mockClear();
|
||||
});
|
||||
|
||||
test("should execute successfully and return health embed", async () => {
|
||||
const interaction = {
|
||||
deferReply: mock(() => Promise.resolve()),
|
||||
editReply: mock(() => Promise.resolve()),
|
||||
client: {
|
||||
ws: {
|
||||
ping: 42
|
||||
}
|
||||
},
|
||||
user: { id: "123", username: "testuser" },
|
||||
commandName: "health"
|
||||
} as unknown as ChatInputCommandInteraction;
|
||||
|
||||
await health.execute(interaction);
|
||||
|
||||
expect(interaction.deferReply).toHaveBeenCalled();
|
||||
expect(executeMock).toHaveBeenCalled();
|
||||
expect(interaction.editReply).toHaveBeenCalled();
|
||||
|
||||
const editReplyCall = (interaction.editReply as any).mock.calls[0][0];
|
||||
const embed = editReplyCall.embeds[0];
|
||||
|
||||
expect(embed.data.title).toBe("System Health Status");
|
||||
expect(embed.data.color).toBe(Colors.Aqua);
|
||||
|
||||
// Check fields
|
||||
const fields = embed.data.fields;
|
||||
expect(fields).toBeDefined();
|
||||
|
||||
// Connectivity field
|
||||
const connectivityField = fields.find((f: any) => f.name === "📡 Connectivity");
|
||||
expect(connectivityField.value).toContain("42ms");
|
||||
expect(connectivityField.value).toContain("Connected");
|
||||
|
||||
// Activity field
|
||||
const activityField = fields.find((f: any) => f.name === "⌨️ Activity");
|
||||
expect(activityField.value).toContain("R>"); // Relative Discord timestamp
|
||||
});
|
||||
|
||||
test("should handle database disconnection", async () => {
|
||||
executeMock.mockImplementationOnce(() => Promise.reject(new Error("DB Down")));
|
||||
|
||||
const interaction = {
|
||||
deferReply: mock(() => Promise.resolve()),
|
||||
editReply: mock(() => Promise.resolve()),
|
||||
client: {
|
||||
ws: {
|
||||
ping: 42
|
||||
}
|
||||
},
|
||||
user: { id: "123", username: "testuser" },
|
||||
commandName: "health"
|
||||
} as unknown as ChatInputCommandInteraction;
|
||||
|
||||
await health.execute(interaction);
|
||||
|
||||
const editReplyCall = (interaction.editReply as any).mock.calls[0][0];
|
||||
const embed = editReplyCall.embeds[0];
|
||||
const connectivityField = embed.data.fields.find((f: any) => f.name === "📡 Connectivity");
|
||||
|
||||
expect(connectivityField.value).toContain("Disconnected");
|
||||
});
|
||||
});
|
||||
60
bot/commands/admin/health.ts
Normal file
60
bot/commands/admin/health.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, Colors } from "discord.js";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { createBaseEmbed } from "@lib/embeds";
|
||||
|
||||
export const health = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("health")
|
||||
.setDescription("Check the bot's health status")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
|
||||
// 1. Check Discord API latency
|
||||
const wsPing = interaction.client.ws.ping;
|
||||
|
||||
// 2. Verify database connection
|
||||
let dbStatus = "Connected";
|
||||
let dbPing = -1;
|
||||
try {
|
||||
const start = Date.now();
|
||||
await DrizzleClient.execute(sql`SELECT 1`);
|
||||
dbPing = Date.now() - start;
|
||||
} catch (error) {
|
||||
dbStatus = "Disconnected";
|
||||
console.error("Health check DB error:", error);
|
||||
}
|
||||
|
||||
// 3. Uptime
|
||||
const uptime = process.uptime();
|
||||
const days = Math.floor(uptime / 86400);
|
||||
const hours = Math.floor((uptime % 86400) / 3600);
|
||||
const minutes = Math.floor((uptime % 3600) / 60);
|
||||
const seconds = Math.floor(uptime % 60);
|
||||
const uptimeString = `${days}d ${hours}h ${minutes}m ${seconds}s`;
|
||||
|
||||
// 4. Memory usage
|
||||
const memory = process.memoryUsage();
|
||||
const heapUsed = (memory.heapUsed / 1024 / 1024).toFixed(2);
|
||||
const heapTotal = (memory.heapTotal / 1024 / 1024).toFixed(2);
|
||||
const rss = (memory.rss / 1024 / 1024).toFixed(2);
|
||||
|
||||
// 5. Last successful command
|
||||
const lastCommand = AuroraClient.lastCommandTimestamp
|
||||
? `<t:${Math.floor(AuroraClient.lastCommandTimestamp / 1000)}:R>`
|
||||
: "None since startup";
|
||||
|
||||
const embed = createBaseEmbed("System Health Status", undefined, Colors.Aqua)
|
||||
.addFields(
|
||||
{ name: "📡 Connectivity", value: `**Discord WS:** ${wsPing}ms\n**Database:** ${dbStatus} ${dbPing >= 0 ? `(${dbPing}ms)` : ""}`, inline: true },
|
||||
{ name: "⏱️ Uptime", value: uptimeString, inline: true },
|
||||
{ name: "🧠 Memory Usage", value: `**RSS:** ${rss} MB\n**Heap:** ${heapUsed} / ${heapTotal} MB`, inline: false },
|
||||
{ name: "⌨️ Activity", value: `**Last Command:** ${lastCommand}`, inline: true }
|
||||
);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
});
|
||||
99
bot/commands/admin/listing.ts
Normal file
99
bot/commands/admin/listing.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import {
|
||||
SlashCommandBuilder,
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
type BaseGuildTextChannel,
|
||||
PermissionFlagsBits,
|
||||
MessageFlags
|
||||
} from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { items } from "@db/schema";
|
||||
import { ilike, isNotNull, and } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { getShopListingMessage } from "@/modules/economy/shop.view";
|
||||
|
||||
export const listing = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("listing")
|
||||
.setDescription("Post an item listing in the channel for users to buy")
|
||||
.addNumberOption(option =>
|
||||
option.setName("item")
|
||||
.setDescription("The item to list")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
)
|
||||
.addChannelOption(option =>
|
||||
option.setName("channel")
|
||||
.setDescription("The channel to post the listing in (defaults to current)")
|
||||
.setRequired(false)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const itemId = interaction.options.getNumber("item", true);
|
||||
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
|
||||
|
||||
if (!targetChannel || !targetChannel.isSendable()) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const item = await inventoryService.getItem(itemId);
|
||||
if (!item) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!item.price) {
|
||||
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item "${item.name}" is not for sale (no price set).`)] });
|
||||
return;
|
||||
}
|
||||
|
||||
const listingMessage = getShopListingMessage({
|
||||
...item,
|
||||
formattedPrice: `${item.price} 🪙`,
|
||||
price: item.price
|
||||
});
|
||||
|
||||
try {
|
||||
await targetChannel.send(listingMessage);
|
||||
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
||||
} else {
|
||||
console.error("Error creating listing:", error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
autocomplete: async (interaction) => {
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
|
||||
const results = await DrizzleClient.select({
|
||||
id: items.id,
|
||||
name: items.name,
|
||||
price: items.price
|
||||
})
|
||||
.from(items)
|
||||
.where(
|
||||
and(
|
||||
ilike(items.name, `%${focusedValue}%`),
|
||||
isNotNull(items.price)
|
||||
)
|
||||
)
|
||||
.limit(20);
|
||||
|
||||
await interaction.respond(
|
||||
results.map(item => ({
|
||||
name: `${item.name} (Price: ${item.price})`,
|
||||
value: item.id
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
62
bot/commands/admin/note.ts
Normal file
62
bot/commands/admin/note.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { CaseType } from "@shared/lib/constants";
|
||||
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
export const note = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("note")
|
||||
.setDescription("Add a staff-only note about a user")
|
||||
.addUserOption(option =>
|
||||
option
|
||||
.setName("user")
|
||||
.setDescription("The user to add a note for")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addStringOption(option =>
|
||||
option
|
||||
.setName("note")
|
||||
.setDescription("The note to add")
|
||||
.setRequired(true)
|
||||
.setMaxLength(1000)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
const noteText = interaction.options.getString("note", true);
|
||||
|
||||
// Create the note case
|
||||
const moderationCase = await ModerationService.createCase({
|
||||
type: CaseType.NOTE,
|
||||
userId: targetUser.id,
|
||||
username: targetUser.username,
|
||||
moderatorId: interaction.user.id,
|
||||
moderatorName: interaction.user.username,
|
||||
reason: noteText,
|
||||
});
|
||||
|
||||
if (!moderationCase) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("Failed to create note.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Send success message
|
||||
await interaction.editReply({
|
||||
embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Note command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while adding the note.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
43
bot/commands/admin/notes.ts
Normal file
43
bot/commands/admin/notes.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
export const notes = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("notes")
|
||||
.setDescription("View all staff notes for a user")
|
||||
.addUserOption(option =>
|
||||
option
|
||||
.setName("user")
|
||||
.setDescription("The user to check notes for")
|
||||
.setRequired(true)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
|
||||
// Get all notes for the user
|
||||
const userNotes = await ModerationService.getUserNotes(targetUser.id);
|
||||
|
||||
// Display the notes
|
||||
await interaction.editReply({
|
||||
embeds: [getCasesListEmbed(
|
||||
userNotes,
|
||||
`📝 Staff Notes for ${targetUser.username}`,
|
||||
userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**`
|
||||
)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Notes command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching notes.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
179
bot/commands/admin/prune.ts
Normal file
179
bot/commands/admin/prune.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||
import { config } from "@/lib/config";
|
||||
import { PruneService } from "@shared/modules/moderation/prune.service";
|
||||
import {
|
||||
getConfirmationMessage,
|
||||
getProgressEmbed,
|
||||
getSuccessEmbed,
|
||||
getPruneErrorEmbed,
|
||||
getPruneWarningEmbed,
|
||||
getCancelledEmbed
|
||||
} from "@/modules/moderation/prune.view";
|
||||
|
||||
export const prune = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("prune")
|
||||
.setDescription("Delete messages in bulk (admin only)")
|
||||
.addIntegerOption(option =>
|
||||
option
|
||||
.setName("amount")
|
||||
.setDescription(`Number of messages to delete (1-${config.moderation?.prune?.maxAmount || 100})`)
|
||||
.setRequired(false)
|
||||
.setMinValue(1)
|
||||
.setMaxValue(config.moderation?.prune?.maxAmount || 100)
|
||||
)
|
||||
.addUserOption(option =>
|
||||
option
|
||||
.setName("user")
|
||||
.setDescription("Only delete messages from this user")
|
||||
.setRequired(false)
|
||||
)
|
||||
.addBooleanOption(option =>
|
||||
option
|
||||
.setName("all")
|
||||
.setDescription("Delete all messages in the channel")
|
||||
.setRequired(false)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const amount = interaction.options.getInteger("amount");
|
||||
const user = interaction.options.getUser("user");
|
||||
const all = interaction.options.getBoolean("all") || false;
|
||||
|
||||
// Validate inputs
|
||||
if (!amount && !all) {
|
||||
// Default to 10 messages
|
||||
} else if (amount && all) {
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneErrorEmbed("Cannot specify both `amount` and `all`. Please use one or the other.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const finalAmount = all ? 'all' : (amount || 10);
|
||||
const confirmThreshold = config.moderation.prune.confirmThreshold;
|
||||
|
||||
// Check if confirmation is needed
|
||||
const needsConfirmation = all || (typeof finalAmount === 'number' && finalAmount > confirmThreshold);
|
||||
|
||||
if (needsConfirmation) {
|
||||
// Estimate message count for confirmation
|
||||
let estimatedCount: number | undefined;
|
||||
if (all) {
|
||||
try {
|
||||
estimatedCount = await PruneService.estimateMessageCount(interaction.channel!);
|
||||
} catch {
|
||||
estimatedCount = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const { embeds, components } = getConfirmationMessage(finalAmount, estimatedCount);
|
||||
const response = await interaction.editReply({ embeds, components });
|
||||
|
||||
try {
|
||||
const confirmation = await response.awaitMessageComponent({
|
||||
filter: (i) => i.user.id === interaction.user.id,
|
||||
componentType: ComponentType.Button,
|
||||
time: 30000
|
||||
});
|
||||
|
||||
if (confirmation.customId === "cancel_prune") {
|
||||
await confirmation.update({
|
||||
embeds: [getCancelledEmbed()],
|
||||
components: []
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// User confirmed, proceed with deletion
|
||||
await confirmation.update({
|
||||
embeds: [getProgressEmbed({ current: 0, total: estimatedCount || finalAmount as number })],
|
||||
components: []
|
||||
});
|
||||
|
||||
// Execute deletion with progress callback for 'all' mode
|
||||
const result = await PruneService.deleteMessages(
|
||||
interaction.channel!,
|
||||
{
|
||||
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
|
||||
userId: user?.id,
|
||||
all
|
||||
},
|
||||
all ? async (progress) => {
|
||||
await interaction.editReply({
|
||||
embeds: [getProgressEmbed(progress)]
|
||||
});
|
||||
} : undefined
|
||||
);
|
||||
|
||||
// Show success
|
||||
await interaction.editReply({
|
||||
embeds: [getSuccessEmbed(result)],
|
||||
components: []
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("time")) {
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneWarningEmbed("Confirmation timed out. Please try again.")],
|
||||
components: []
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No confirmation needed, proceed directly
|
||||
const result = await PruneService.deleteMessages(
|
||||
interaction.channel!,
|
||||
{
|
||||
amount: finalAmount as number,
|
||||
userId: user?.id,
|
||||
all: false
|
||||
}
|
||||
);
|
||||
|
||||
// Check if no messages were found
|
||||
if (result.deletedCount === 0) {
|
||||
if (user) {
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneWarningEmbed(`No messages found from **${user.username}** in the last ${finalAmount} messages.`)]
|
||||
});
|
||||
} else {
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneWarningEmbed("No messages found to delete.")]
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [getSuccessEmbed(result)]
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Prune command error:", error);
|
||||
|
||||
let errorMessage = "An unexpected error occurred while trying to delete messages.";
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("permission")) {
|
||||
errorMessage = "I don't have permission to delete messages in this channel.";
|
||||
} else if (error.message.includes("channel type")) {
|
||||
errorMessage = "This command cannot be used in this type of channel.";
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [getPruneErrorEmbed(errorMessage)]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
33
bot/commands/admin/refresh.ts
Normal file
33
bot/commands/admin/refresh.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
|
||||
export const refresh = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("refresh")
|
||||
.setDescription("Reloads all commands and config without restarting")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
await AuroraClient.loadCommands(true);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
// Deploy commands
|
||||
await AuroraClient.deployCommands();
|
||||
|
||||
const embed = createSuccessEmbed(
|
||||
`Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`,
|
||||
"System Refreshed"
|
||||
);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An error occurred while refreshing commands. Check console for details.", "Refresh Failed")] });
|
||||
}
|
||||
}
|
||||
});
|
||||
37
bot/commands/admin/terminal.ts
Normal file
37
bot/commands/admin/terminal.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
|
||||
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
||||
import { createBaseEmbed, createErrorEmbed } from "@/lib/embeds";
|
||||
|
||||
export const terminal = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("terminal")
|
||||
.setDescription("Manage the Aurora Terminal")
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("init")
|
||||
.setDescription("Initialize the terminal in the current channel")
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
execute: async (interaction) => {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
if (subcommand === "init") {
|
||||
const channel = interaction.channel;
|
||||
if (!channel || channel.type !== ChannelType.GuildText) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed("Terminal can only be initialized in text channels.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({ ephemeral: true, content: "Initializing terminal..." });
|
||||
|
||||
try {
|
||||
await terminalService.init(channel as TextChannel);
|
||||
await interaction.editReply({ content: "✅ Terminal initialized!" });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await interaction.editReply({ content: "❌ Failed to initialize terminal." });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
176
bot/commands/admin/update.ts
Normal file
176
bot/commands/admin/update.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||
import { UpdateService } from "@shared/modules/admin/update.service";
|
||||
import {
|
||||
getCheckingEmbed,
|
||||
getNoUpdatesEmbed,
|
||||
getUpdatesAvailableMessage,
|
||||
getPreparingEmbed,
|
||||
getUpdatingEmbed,
|
||||
getCancelledEmbed,
|
||||
getTimeoutEmbed,
|
||||
getErrorEmbed,
|
||||
getRollbackSuccessEmbed,
|
||||
getRollbackFailedEmbed
|
||||
} from "@/modules/admin/update.view";
|
||||
|
||||
export const update = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("update")
|
||||
.setDescription("Check for updates and restart the bot")
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("check")
|
||||
.setDescription("Check for and apply available updates")
|
||||
.addBooleanOption(option =>
|
||||
option.setName("force")
|
||||
.setDescription("Force update even if no changes detected")
|
||||
.setRequired(false)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName("rollback")
|
||||
.setDescription("Rollback to the previous version")
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
if (subcommand === "rollback") {
|
||||
await handleRollback(interaction);
|
||||
} else {
|
||||
await handleUpdate(interaction);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function handleUpdate(interaction: any) {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
const force = interaction.options.getBoolean("force") || false;
|
||||
|
||||
try {
|
||||
// 1. Check for updates
|
||||
await interaction.editReply({ embeds: [getCheckingEmbed()] });
|
||||
const updateInfo = await UpdateService.checkForUpdates();
|
||||
|
||||
if (!updateInfo.hasUpdates && !force) {
|
||||
await interaction.editReply({
|
||||
embeds: [getNoUpdatesEmbed(updateInfo.currentCommit)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Analyze requirements
|
||||
const requirements = await UpdateService.checkUpdateRequirements(updateInfo.branch);
|
||||
const categories = UpdateService.categorizeChanges(requirements.changedFiles);
|
||||
|
||||
// 3. Show confirmation with details
|
||||
const { embeds, components } = getUpdatesAvailableMessage(
|
||||
updateInfo,
|
||||
requirements,
|
||||
categories,
|
||||
force
|
||||
);
|
||||
const response = await interaction.editReply({ embeds, components });
|
||||
|
||||
// 4. Wait for confirmation
|
||||
try {
|
||||
const confirmation = await response.awaitMessageComponent({
|
||||
filter: (i: any) => i.user.id === interaction.user.id,
|
||||
componentType: ComponentType.Button,
|
||||
time: 30000
|
||||
});
|
||||
|
||||
if (confirmation.customId === "confirm_update") {
|
||||
await confirmation.update({
|
||||
embeds: [getPreparingEmbed()],
|
||||
components: []
|
||||
});
|
||||
|
||||
// 5. Save rollback point
|
||||
const previousCommit = await UpdateService.saveRollbackPoint();
|
||||
|
||||
// 6. Prepare restart context
|
||||
await UpdateService.prepareRestartContext({
|
||||
channelId: interaction.channelId,
|
||||
userId: interaction.user.id,
|
||||
timestamp: Date.now(),
|
||||
runMigrations: requirements.needsMigrations,
|
||||
installDependencies: requirements.needsRootInstall || requirements.needsWebInstall,
|
||||
previousCommit: previousCommit.substring(0, 7),
|
||||
newCommit: updateInfo.latestCommit
|
||||
});
|
||||
|
||||
// 7. Show updating status
|
||||
await interaction.editReply({
|
||||
embeds: [getUpdatingEmbed(requirements)]
|
||||
});
|
||||
|
||||
// 8. Perform update
|
||||
await UpdateService.performUpdate(updateInfo.branch);
|
||||
|
||||
// 9. Trigger restart
|
||||
await UpdateService.triggerRestart();
|
||||
|
||||
} else {
|
||||
await confirmation.update({
|
||||
embeds: [getCancelledEmbed()],
|
||||
components: []
|
||||
});
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes("time")) {
|
||||
await interaction.editReply({
|
||||
embeds: [getTimeoutEmbed()],
|
||||
components: []
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Update failed:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getErrorEmbed(error)],
|
||||
components: []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRollback(interaction: any) {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const hasRollback = await UpdateService.hasRollbackPoint();
|
||||
|
||||
if (!hasRollback) {
|
||||
await interaction.editReply({
|
||||
embeds: [getRollbackFailedEmbed("No rollback point available. Rollback is only possible after a recent update.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await UpdateService.rollback();
|
||||
|
||||
if (result.success) {
|
||||
await interaction.editReply({
|
||||
embeds: [getRollbackSuccessEmbed(result.message.split(" ").pop() || "unknown")]
|
||||
});
|
||||
|
||||
// Restart after rollback
|
||||
setTimeout(() => UpdateService.triggerRestart(), 1000);
|
||||
} else {
|
||||
await interaction.editReply({
|
||||
embeds: [getRollbackFailedEmbed(result.message)]
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Rollback failed:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getErrorEmbed(error)]
|
||||
});
|
||||
}
|
||||
}
|
||||
87
bot/commands/admin/warn.ts
Normal file
87
bot/commands/admin/warn.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import {
|
||||
getWarnSuccessEmbed,
|
||||
getModerationErrorEmbed,
|
||||
getUserWarningEmbed
|
||||
} from "@/modules/moderation/moderation.view";
|
||||
import { config } from "@/lib/config";
|
||||
|
||||
export const warn = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("warn")
|
||||
.setDescription("Issue a warning to a user")
|
||||
.addUserOption(option =>
|
||||
option
|
||||
.setName("user")
|
||||
.setDescription("The user to warn")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addStringOption(option =>
|
||||
option
|
||||
.setName("reason")
|
||||
.setDescription("Reason for the warning")
|
||||
.setRequired(true)
|
||||
.setMaxLength(1000)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
const reason = interaction.options.getString("reason", true);
|
||||
|
||||
// Don't allow warning bots
|
||||
if (targetUser.bot) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("You cannot warn bots.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't allow self-warnings
|
||||
if (targetUser.id === interaction.user.id) {
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("You cannot warn yourself.")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Issue the warning via service
|
||||
const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({
|
||||
userId: targetUser.id,
|
||||
username: targetUser.username,
|
||||
moderatorId: interaction.user.id,
|
||||
moderatorName: interaction.user.username,
|
||||
reason,
|
||||
guildName: interaction.guild?.name || undefined,
|
||||
dmTarget: targetUser,
|
||||
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id)
|
||||
});
|
||||
|
||||
// Send success message to moderator
|
||||
await interaction.editReply({
|
||||
embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
|
||||
});
|
||||
|
||||
// Follow up if auto-timeout was issued
|
||||
if (autoTimeoutIssued) {
|
||||
await interaction.followUp({
|
||||
embeds: [getModerationErrorEmbed(
|
||||
`⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.`
|
||||
)],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Warn command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while issuing the warning.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
39
bot/commands/admin/warnings.ts
Normal file
39
bot/commands/admin/warnings.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@shared/modules/moderation/moderation.service";
|
||||
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
export const warnings = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("warnings")
|
||||
.setDescription("View active warnings for a user")
|
||||
.addUserOption(option =>
|
||||
option
|
||||
.setName("user")
|
||||
.setDescription("The user to check warnings for")
|
||||
.setRequired(true)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
|
||||
// Get active warnings for the user
|
||||
const activeWarnings = await ModerationService.getUserWarnings(targetUser.id);
|
||||
|
||||
// Display the warnings
|
||||
await interaction.editReply({
|
||||
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Warnings command error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [getModerationErrorEmbed("An error occurred while fetching warnings.")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
56
bot/commands/admin/webhook.ts
Normal file
56
bot/commands/admin/webhook.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { createErrorEmbed } from "@/lib/embeds";
|
||||
import { sendWebhookMessage } from "@/lib/webhookUtils";
|
||||
|
||||
export const webhook = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("webhook")
|
||||
.setDescription("Send a message via webhook using a JSON payload")
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageWebhooks)
|
||||
.addStringOption(option =>
|
||||
option.setName("payload")
|
||||
.setDescription("The JSON payload for the webhook message")
|
||||
.setRequired(true)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const payloadString = interaction.options.getString("payload", true);
|
||||
let payload;
|
||||
|
||||
try {
|
||||
payload = JSON.parse(payloadString);
|
||||
} catch (error) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("The provided payload is not valid JSON.", "Invalid JSON")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = interaction.channel;
|
||||
|
||||
if (!channel || !('createWebhook' in channel)) {
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("This channel does not support webhooks.", "Unsupported Channel")]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendWebhookMessage(
|
||||
channel,
|
||||
payload,
|
||||
interaction.client.user,
|
||||
`Proxy message requested by ${interaction.user.tag}`
|
||||
);
|
||||
|
||||
await interaction.editReply({ content: "Message sent successfully!" });
|
||||
} catch (error) {
|
||||
console.error("Webhook error:", error);
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed("Failed to send message via webhook. Ensure the bot has 'Manage Webhooks' permission and the payload is valid.", "Delivery Failed")]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
33
bot/commands/economy/balance.ts
Normal file
33
bot/commands/economy/balance.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { createBaseEmbed } from "@lib/embeds";
|
||||
|
||||
export const balance = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("balance")
|
||||
.setDescription("Check your or another user's balance")
|
||||
.addUserOption(option =>
|
||||
option.setName("user")
|
||||
.setDescription("The user to check")
|
||||
.setRequired(false)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
|
||||
const targetUser = interaction.options.getUser("user") || interaction.user;
|
||||
|
||||
if (targetUser.bot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
||||
|
||||
if (!user) throw new Error("Failed to retrieve user data.");
|
||||
|
||||
const embed = createBaseEmbed(undefined, `**Balance**: ${user.balance || 0n} AU`, "Yellow")
|
||||
.setAuthor({ name: targetUser.username, iconURL: targetUser.displayAvatarURL() });
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
});
|
||||
35
bot/commands/economy/daily.ts
Normal file
35
bot/commands/economy/daily.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
|
||||
export const daily = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("daily")
|
||||
.setDescription("Claim your daily reward"),
|
||||
execute: async (interaction) => {
|
||||
try {
|
||||
const result = await economyService.claimDaily(interaction.user.id);
|
||||
|
||||
const embed = createSuccessEmbed(`You claimed ** ${result.amount}** Astral Units!${result.isWeekly ? `\n🎉 **Weekly Bonus!** +${result.weeklyBonus} extra!` : ''}`, "💰 Daily Reward Claimed!")
|
||||
.addFields(
|
||||
{ name: "Streak", value: `🔥 ${result.streak} days`, inline: true },
|
||||
{ name: "Weekly Progress", value: `${"🟩".repeat(result.streak % 7 || 7)}${"⬜".repeat(7 - (result.streak % 7 || 7))} (${result.streak % 7 || 7}/7)`, inline: true },
|
||||
{ name: "Next Reward", value: `<t:${Math.floor(result.nextReadyAt.getTime() / 1000)}:R> `, inline: true }
|
||||
)
|
||||
.setColor("Gold");
|
||||
|
||||
await interaction.reply({ embeds: [embed] });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
||||
} else {
|
||||
console.error("Error claiming daily:", error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
205
bot/commands/economy/exam.ts
Normal file
205
bot/commands/economy/exam.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { userTimers, users } from "@db/schema";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { config } from "@lib/config";
|
||||
import { TimerType } from "@shared/lib/constants";
|
||||
|
||||
const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
|
||||
const EXAM_TIMER_KEY = 'default';
|
||||
|
||||
interface ExamMetadata {
|
||||
examDay: number;
|
||||
lastXp: string;
|
||||
}
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
export const exam = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("exam")
|
||||
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
if (!user) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to retrieve user data.")] });
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
const currentDay = now.getDay();
|
||||
|
||||
try {
|
||||
// 1. Fetch existing timer/exam data
|
||||
const timer = await DrizzleClient.query.userTimers.findFirst({
|
||||
where: and(
|
||||
eq(userTimers.userId, user.id),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
)
|
||||
});
|
||||
|
||||
// 2. First Run Logic
|
||||
if (!timer) {
|
||||
// Set exam day to today
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + 7);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||
|
||||
const metadata: ExamMetadata = {
|
||||
examDay: currentDay,
|
||||
lastXp: (user.xp ?? 0n).toString()
|
||||
};
|
||||
|
||||
await DrizzleClient.insert(userTimers).values({
|
||||
userId: user.id,
|
||||
type: EXAM_TIMER_TYPE,
|
||||
key: EXAM_TIMER_KEY,
|
||||
expiresAt: nextExamDate,
|
||||
metadata: metadata
|
||||
});
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}** (Server Time).\n` +
|
||||
`Come back on <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>) to take your first exam!`,
|
||||
"Exam Registration Successful"
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = timer.metadata as unknown as ExamMetadata;
|
||||
const examDay = metadata.examDay;
|
||||
|
||||
// 3. Cooldown Check
|
||||
const expiresAt = new Date(timer.expiresAt);
|
||||
expiresAt.setHours(0, 0, 0, 0);
|
||||
|
||||
if (now < expiresAt) {
|
||||
// Calculate time remaining
|
||||
const timestamp = Math.floor(expiresAt.getTime() / 1000);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
||||
`Next exam available: <t:${timestamp}:D> (<t:${timestamp}:R>)`
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Day Check
|
||||
if (currentDay !== examDay) {
|
||||
// Calculate next correct exam day to correct the schedule
|
||||
let daysUntil = (examDay - currentDay + 7) % 7;
|
||||
if (daysUntil === 0) daysUntil = 7;
|
||||
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + daysUntil);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||
|
||||
const newMetadata: ExamMetadata = {
|
||||
examDay: examDay,
|
||||
lastXp: (user.xp ?? 0n).toString()
|
||||
};
|
||||
|
||||
await DrizzleClient.update(userTimers)
|
||||
.set({
|
||||
expiresAt: nextExamDate,
|
||||
metadata: newMetadata
|
||||
})
|
||||
.where(and(
|
||||
eq(userTimers.userId, user.id),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
));
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createErrorEmbed(
|
||||
`You missed your exam day! Your exam day is **${DAYS[examDay]}** (Server Time).\n` +
|
||||
`You verify your attendance but score a **0**.\n` +
|
||||
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
|
||||
"Exam Failed"
|
||||
)]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Reward Calculation
|
||||
const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case
|
||||
const currentXp = user.xp ?? 0n;
|
||||
const diff = currentXp - lastXp;
|
||||
|
||||
// Calculate Reward
|
||||
const multMin = config.economy.exam.multMin;
|
||||
const multMax = config.economy.exam.multMax;
|
||||
const multiplier = Math.random() * (multMax - multMin) + multMin;
|
||||
|
||||
// Allow negative reward? existing description implies "difference", usually gain.
|
||||
// If diff is negative (lost XP?), reward might be 0.
|
||||
let reward = 0n;
|
||||
if (diff > 0n) {
|
||||
reward = BigInt(Math.floor(Number(diff) * multiplier));
|
||||
}
|
||||
|
||||
// 6. Update State
|
||||
const nextExamDate = new Date(now);
|
||||
nextExamDate.setDate(now.getDate() + 7);
|
||||
nextExamDate.setHours(0, 0, 0, 0);
|
||||
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
|
||||
|
||||
const newMetadata: ExamMetadata = {
|
||||
examDay: examDay,
|
||||
lastXp: currentXp.toString()
|
||||
};
|
||||
|
||||
await DrizzleClient.transaction(async (tx) => {
|
||||
// Update Timer
|
||||
await tx.update(userTimers)
|
||||
.set({
|
||||
expiresAt: nextExamDate,
|
||||
metadata: newMetadata
|
||||
})
|
||||
.where(and(
|
||||
eq(userTimers.userId, user.id),
|
||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||
));
|
||||
|
||||
// Add Currency
|
||||
if (reward > 0n) {
|
||||
await tx.update(users)
|
||||
.set({
|
||||
balance: sql`${users.balance} + ${reward}`
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
}
|
||||
});
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [createSuccessEmbed(
|
||||
`**XP Gained:** ${diff.toString()}\n` +
|
||||
`**Multiplier:** x${multiplier.toFixed(2)}\n` +
|
||||
`**Reward:** ${reward.toString()} Currency\n\n` +
|
||||
`See you next week: <t:${nextExamTimestamp}:D>`,
|
||||
"Exam Passed!"
|
||||
)]
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
|
||||
} else {
|
||||
console.error("Error in exam command:", error);
|
||||
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
69
bot/commands/economy/pay.ts
Normal file
69
bot/commands/economy/pay.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
||||
import { economyService } from "@shared/modules/economy/economy.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { config } from "@/lib/config";
|
||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||
import { UserError } from "@/lib/errors";
|
||||
|
||||
export const pay = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("pay")
|
||||
.setDescription("Transfer Astral Units to another user")
|
||||
.addUserOption(option =>
|
||||
option.setName("user")
|
||||
.setDescription("The user to pay")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addIntegerOption(option =>
|
||||
option.setName("amount")
|
||||
.setDescription("Amount to transfer")
|
||||
.setMinValue(1)
|
||||
.setRequired(true)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
const targetUser = await userService.getOrCreateUser(interaction.options.getUser("user", true).id, interaction.options.getUser("user", true).username);
|
||||
const discordUser = interaction.options.getUser("user", true);
|
||||
|
||||
if (discordUser.bot) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed("You cannot send money to bots.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
const amount = BigInt(interaction.options.getInteger("amount", true));
|
||||
const senderId = interaction.user.id;
|
||||
if (!targetUser) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed("User not found.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
const receiverId = targetUser.id;
|
||||
|
||||
if (amount < config.economy.transfers.minAmount) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed(`Amount must be at least ${config.economy.transfers.minAmount}.`)], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
if (senderId === receiverId.toString()) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed("You cannot pay yourself.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await interaction.deferReply();
|
||||
await economyService.transfer(senderId, receiverId.toString(), amount);
|
||||
|
||||
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
|
||||
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error sending payment:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
75
bot/commands/economy/trade.ts
Normal file
75
bot/commands/economy/trade.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags } from "discord.js";
|
||||
import { tradeService } from "@shared/modules/trade/trade.service";
|
||||
import { getTradeDashboard } from "@/modules/trade/trade.view";
|
||||
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
|
||||
|
||||
export const trade = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("trade")
|
||||
.setDescription("Start a trade with another player")
|
||||
.addUserOption(option =>
|
||||
option.setName("user")
|
||||
.setDescription("The user to trade with")
|
||||
.setRequired(true)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
const targetUser = interaction.options.getUser("user", true);
|
||||
|
||||
if (targetUser.id === interaction.user.id) {
|
||||
await interaction.reply({ embeds: [createWarningEmbed("You cannot trade with yourself.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetUser.bot) {
|
||||
await interaction.reply({ embeds: [createWarningEmbed("You cannot trade with bots.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create Thread
|
||||
const channel = interaction.channel;
|
||||
if (!channel || channel.type === ChannelType.DM) {
|
||||
await interaction.reply({ embeds: [createErrorEmbed("Cannot start trade in DMs.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we can create threads
|
||||
// Assuming permissions are fine.
|
||||
|
||||
await interaction.reply({ content: `🔄 Setting up trade with ${targetUser}...` });
|
||||
const message = await interaction.fetchReply();
|
||||
|
||||
let thread;
|
||||
try {
|
||||
thread = await message.startThread({
|
||||
name: `trade-${interaction.user.username}-${targetUser.username}`,
|
||||
autoArchiveDuration: ThreadAutoArchiveDuration.OneHour,
|
||||
reason: "Trading Session"
|
||||
});
|
||||
} catch (e) {
|
||||
// Fallback if message threads fail, try channel threads (private preferred)
|
||||
// But startThread on message is usually easiest.
|
||||
try {
|
||||
await message.delete();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete setup message", err);
|
||||
}
|
||||
await interaction.followUp({ embeds: [createErrorEmbed("Failed to create trade thread. Check permissions.")], flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup Session
|
||||
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 dashboard = getTradeDashboard(session);
|
||||
|
||||
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
bot/commands/feedback/feedback.ts
Normal file
29
bot/commands/feedback/feedback.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { config } from "@/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
|
||||
});
|
||||
}
|
||||
});
|
||||
44
bot/commands/inventory/inventory.ts
Normal file
44
bot/commands/inventory/inventory.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { createWarningEmbed } from "@lib/embeds";
|
||||
import { getInventoryEmbed } from "@/modules/inventory/inventory.view";
|
||||
|
||||
export const inventory = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("inventory")
|
||||
.setDescription("View your or another user's inventory")
|
||||
.addUserOption(option =>
|
||||
option.setName("user")
|
||||
.setDescription("User to view")
|
||||
.setRequired(false)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
|
||||
const targetUser = interaction.options.getUser("user") || interaction.user;
|
||||
|
||||
if (targetUser.bot) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("Bots do not have inventories.", "Inventory Check")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
||||
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 embed = getInventoryEmbed(items, user.username);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
});
|
||||
79
bot/commands/inventory/use.ts
Normal file
79
bot/commands/inventory/use.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { createErrorEmbed } from "@lib/embeds";
|
||||
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
||||
import type { ItemUsageData } from "@shared/lib/types";
|
||||
import { UserError } from "@/lib/errors";
|
||||
import { config } from "@/lib/config";
|
||||
|
||||
export const use = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("use")
|
||||
.setDescription("Use an item from your inventory")
|
||||
.addNumberOption(option =>
|
||||
option.setName("item")
|
||||
.setDescription("The item to use")
|
||||
.setRequired(true)
|
||||
.setAutocomplete(true)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
|
||||
const itemId = interaction.options.getNumber("item", true);
|
||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||
if (!user) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await inventoryService.useItem(user.id.toString(), itemId);
|
||||
|
||||
const usageData = result.usageData;
|
||||
if (usageData) {
|
||||
for (const effect of usageData.effects) {
|
||||
if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') {
|
||||
try {
|
||||
const member = await interaction.guild?.members.fetch(user.id.toString());
|
||||
if (member) {
|
||||
if (effect.type === 'TEMP_ROLE') {
|
||||
await member.roles.add(effect.roleId);
|
||||
} else if (effect.type === 'COLOR_ROLE') {
|
||||
// Remove existing color roles
|
||||
const rolesToRemove = config.colorRoles.filter(r => member.roles.cache.has(r));
|
||||
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
|
||||
await member.roles.add(effect.roleId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to assign role in /use command:", e);
|
||||
result.results.push("⚠️ Failed to assign role (Check bot permissions)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const embed = getItemUseResultEmbed(result.results, result.item);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
|
||||
} catch (error: any) {
|
||||
if (error instanceof UserError) {
|
||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||
} else {
|
||||
console.error("Error using item:", error);
|
||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred while using the item.")] });
|
||||
}
|
||||
}
|
||||
},
|
||||
autocomplete: async (interaction) => {
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
const userId = interaction.user.id;
|
||||
|
||||
const results = await inventoryService.getAutocompleteItems(userId, focusedValue);
|
||||
|
||||
await interaction.respond(results);
|
||||
}
|
||||
});
|
||||
61
bot/commands/leveling/leaderboard.ts
Normal file
61
bot/commands/leveling/leaderboard.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { users, items, inventory } from "@db/schema";
|
||||
import { desc, sql, eq } from "drizzle-orm";
|
||||
import { createWarningEmbed } from "@lib/embeds";
|
||||
import { getLeaderboardEmbed } from "@/modules/leveling/leveling.view";
|
||||
|
||||
export const leaderboard = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("leaderboard")
|
||||
.setDescription("View the top players")
|
||||
.addStringOption(option =>
|
||||
option.setName("type")
|
||||
.setDescription("Sort by XP, Balance, or Net Worth")
|
||||
.setRequired(true)
|
||||
.addChoices(
|
||||
{ name: "Level / XP", value: "xp" },
|
||||
{ name: "Balance", value: "balance" },
|
||||
{ name: "Net Worth", value: "networth" }
|
||||
)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
|
||||
const type = interaction.options.getString("type", true);
|
||||
|
||||
let leaders;
|
||||
|
||||
if (type === 'networth') {
|
||||
leaders = await DrizzleClient.select({
|
||||
username: users.username,
|
||||
level: users.level,
|
||||
xp: users.xp,
|
||||
balance: users.balance,
|
||||
netWorth: sql<bigint>`${users.balance} + COALESCE(SUM(${items.price} * ${inventory.quantity}), 0)`.as('net_worth')
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(inventory, eq(users.id, inventory.userId))
|
||||
.leftJoin(items, eq(inventory.itemId, items.id))
|
||||
.groupBy(users.id)
|
||||
.orderBy(desc(sql`net_worth`))
|
||||
.limit(10);
|
||||
} else {
|
||||
const isXp = type === "xp";
|
||||
leaders = await DrizzleClient.query.users.findMany({
|
||||
orderBy: isXp ? desc(users.xp) : desc(users.balance),
|
||||
limit: 10
|
||||
});
|
||||
}
|
||||
|
||||
if (leaders.length === 0) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("No users found.", "Leaderboard")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = getLeaderboardEmbed(leaders, type as 'xp' | 'balance' | 'networth');
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
});
|
||||
25
bot/commands/quest/quests.ts
Normal file
25
bot/commands/quest/quests.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
||||
import { questService } from "@shared/modules/quest/quest.service";
|
||||
import { createWarningEmbed } from "@lib/embeds";
|
||||
import { getQuestListEmbed } from "@/modules/quest/quest.view";
|
||||
|
||||
export const quests = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("quests")
|
||||
.setDescription("View your active quests"),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const userQuests = await questService.getUserQuests(interaction.user.id);
|
||||
|
||||
if (!userQuests || userQuests.length === 0) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("You have no active quests.", "Quest Log")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = getQuestListEmbed(userQuests);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
});
|
||||
43
bot/commands/user/profile.ts
Normal file
43
bot/commands/user/profile.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, AttachmentBuilder } from "discord.js";
|
||||
import { userService } from "@shared/modules/user/user.service";
|
||||
import { generateStudentIdCard } from "@/graphics/studentID";
|
||||
import { createWarningEmbed } from "@/lib/embeds";
|
||||
|
||||
export const profile = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("profile")
|
||||
.setDescription("View your or another user's profile")
|
||||
.addUserOption(option =>
|
||||
option.setName("user")
|
||||
.setDescription("The user to view")
|
||||
.setRequired(false)
|
||||
),
|
||||
execute: async (interaction) => {
|
||||
await interaction.deferReply();
|
||||
|
||||
const targetUser = interaction.options.getUser("user") || interaction.user;
|
||||
|
||||
if (targetUser.bot) {
|
||||
await interaction.editReply({ embeds: [createWarningEmbed("Bots do not have profiles.", "Profile Check")] });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
||||
|
||||
const cardBuffer = await generateStudentIdCard({
|
||||
username: targetUser.username,
|
||||
avatarUrl: targetUser.displayAvatarURL({ extension: 'png', size: 256 }),
|
||||
id: targetUser.id,
|
||||
level: user!.level || 1,
|
||||
xp: user!.xp || 0n,
|
||||
au: user!.balance || 0n,
|
||||
className: user!.class?.name || "D"
|
||||
});
|
||||
|
||||
const attachment = new AttachmentBuilder(cardBuffer, { name: 'student-id.png' });
|
||||
|
||||
await interaction.editReply({ files: [attachment] });
|
||||
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user