feat: standardize command error handling (Sprint 4)

- Create withCommandErrorHandling utility in bot/lib/commandUtils.ts
- Migrate economy commands: daily, exam, pay, trivia
- Migrate inventory command: use
- Migrate admin/moderation commands: warn, case, cases, clearwarning,
  warnings, note, notes, create_color, listing, webhook, refresh,
  terminal, featureflags, settings, prune
- Add 9 unit tests for the utility
- Update AGENTS.md with new recommended error handling pattern
This commit is contained in:
syntaxbullet
2026-02-13 14:23:37 +01:00
parent 0c67a8754f
commit 141c3098f8
23 changed files with 990 additions and 834 deletions

View File

@@ -141,22 +141,36 @@ throw new UserError("You don't have enough coins!");
throw new SystemError("Database connection failed"); throw new SystemError("Database connection failed");
``` ```
### Standard Error Pattern ### Recommended: `withCommandErrorHandling`
Use the `withCommandErrorHandling` utility from `@lib/commandUtils` to standardize
error handling across all commands. It handles `deferReply`, `UserError` display,
and unexpected error logging automatically.
```typescript ```typescript
try { import { withCommandErrorHandling } from "@lib/commandUtils";
const result = await service.method();
await interaction.editReply({ embeds: [createSuccessEmbed(result)] }); export const myCommand = createCommand({
} catch (error) { data: new SlashCommandBuilder()
if (error instanceof UserError) { .setName("mycommand")
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] }); .setDescription("Does something"),
} else { execute: async (interaction) => {
console.error("Unexpected error:", error); await withCommandErrorHandling(
await interaction.editReply({ interaction,
embeds: [createErrorEmbed("An unexpected error occurred.")], async () => {
}); const result = await service.method();
} await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
} },
{ ephemeral: true } // optional: makes the deferred reply ephemeral
);
},
});
```
Options:
- `ephemeral` — whether `deferReply` should be ephemeral
- `successMessage` — a simple string to send on success
- `onSuccess` — a callback invoked with the operation result
``` ```
## Database Patterns ## Database Patterns
@@ -240,3 +254,4 @@ describe("serviceName", () => {
| Environment | `shared/lib/env.ts` | | Environment | `shared/lib/env.ts` |
| Embed helpers | `bot/lib/embeds.ts` | | Embed helpers | `bot/lib/embeds.ts` |
| Command utils | `shared/lib/utils.ts` | | Command utils | `shared/lib/utils.ts` |
| Error handler | `bot/lib/commandUtils.ts` |

View File

@@ -2,6 +2,7 @@ import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { moderationService } from "@shared/modules/moderation/moderation.service"; import { moderationService } from "@shared/modules/moderation/moderation.service";
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const moderationCase = createCommand({ export const moderationCase = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -16,39 +17,35 @@ export const moderationCase = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await withCommandErrorHandling(
interaction,
async () => {
const caseId = interaction.options.getString("case_id", true).toUpperCase();
try { // Validate case ID format
const caseId = interaction.options.getString("case_id", true).toUpperCase(); if (!caseId.match(/^CASE-\d+$/)) {
await interaction.editReply({
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
});
return;
}
// Validate case ID format // Get the case
if (!caseId.match(/^CASE-\d+$/)) { const moderationCase = await moderationService.getCaseById(caseId);
if (!moderationCase) {
await interaction.editReply({
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
});
return;
}
// Display the case
await interaction.editReply({ await interaction.editReply({
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")] embeds: [getCaseEmbed(moderationCase)]
}); });
return; },
} { ephemeral: true }
);
// 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.")]
});
}
} }
}); });

View File

@@ -1,7 +1,8 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
import { moderationService } from "@shared/modules/moderation/moderation.service"; import { moderationService } from "@shared/modules/moderation/moderation.service";
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getCasesListEmbed } from "@/modules/moderation/moderation.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const cases = createCommand({ export const cases = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -22,33 +23,29 @@ export const cases = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await withCommandErrorHandling(
interaction,
async () => {
const targetUser = interaction.options.getUser("user", true);
const activeOnly = interaction.options.getBoolean("active_only") || false;
try { // Get cases for the user
const targetUser = interaction.options.getUser("user", true); const userCases = await moderationService.getUserCases(targetUser.id, activeOnly);
const activeOnly = interaction.options.getBoolean("active_only") || false;
// Get cases for the user const title = activeOnly
const userCases = await moderationService.getUserCases(targetUser.id, activeOnly); ? `⚠️ Active Cases for ${targetUser.username}`
: `📋 All Cases for ${targetUser.username}`;
const title = activeOnly const description = userCases.length === 0
? `⚠️ Active Cases for ${targetUser.username}` ? undefined
: `📋 All Cases for ${targetUser.username}`; : `Total cases: **${userCases.length}**`;
const description = userCases.length === 0 // Display the cases
? undefined await interaction.editReply({
: `Total cases: **${userCases.length}**`; embeds: [getCasesListEmbed(userCases, title, description)]
});
// Display the cases },
await interaction.editReply({ { ephemeral: true }
embeds: [getCasesListEmbed(userCases, title, description)] );
});
} catch (error) {
console.error("Cases command error:", error);
await interaction.editReply({
embeds: [getModerationErrorEmbed("An error occurred while fetching cases.")]
});
}
} }
}); });

View File

@@ -1,7 +1,8 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
import { moderationService } from "@shared/modules/moderation/moderation.service"; import { moderationService } from "@shared/modules/moderation/moderation.service";
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const clearwarning = createCommand({ export const clearwarning = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -23,62 +24,58 @@ export const clearwarning = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await withCommandErrorHandling(
interaction,
async () => {
const caseId = interaction.options.getString("case_id", true).toUpperCase();
const reason = interaction.options.getString("reason") || "Cleared by moderator";
try { // Validate case ID format
const caseId = interaction.options.getString("case_id", true).toUpperCase(); if (!caseId.match(/^CASE-\d+$/)) {
const reason = interaction.options.getString("reason") || "Cleared by moderator"; await interaction.editReply({
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
});
return;
}
// Validate case ID format // Check if case exists and is active
if (!caseId.match(/^CASE-\d+$/)) { const existingCase = await moderationService.getCaseById(caseId);
await interaction.editReply({
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")] 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
}); });
return;
}
// Check if case exists and is active // Send success message
const existingCase = await moderationService.getCaseById(caseId);
if (!existingCase) {
await interaction.editReply({ await interaction.editReply({
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)] embeds: [getClearSuccessEmbed(caseId)]
}); });
return; },
} { ephemeral: true }
);
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.")]
});
}
} }
}); });

View File

@@ -1,10 +1,11 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config"; import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service"; import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
import { DrizzleClient } from "@shared/db/DrizzleClient"; import { DrizzleClient } from "@shared/db/DrizzleClient";
import { items } from "@db/schema"; import { items } from "@db/schema";
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds"; import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const createColor = createCommand({ export const createColor = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -32,62 +33,60 @@ export const createColor = createCommand({
) )
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply(); await withCommandErrorHandling(
interaction,
async () => {
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";
const name = interaction.options.getString("name", true); // 1. Validate Color
const colorInput = interaction.options.getString("color", true); const colorRegex = /^#([0-9A-F]{3}){1,2}$/i;
const price = interaction.options.getNumber("price") || 500; if (!colorRegex.test(colorInput)) {
const imageUrl = interaction.options.getString("image") || "https://cdn.discordapp.com/attachments/1450061247365124199/1453122950822760559/Main_Chip_1.png"; await interaction.editReply({ embeds: [createErrorEmbed("Invalid Hex Color code. Format: #RRGGBB")] });
return;
}
// 1. Validate Color // 2. Create Role
const colorRegex = /^#([0-9A-F]{3}){1,2}$/i; const role = await interaction.guild?.roles.create({
if (!colorRegex.test(colorInput)) { name: name,
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Hex Color code. Format: #RRGGBB")] }); color: colorInput as any,
return; reason: `Created via /createcolor by ${interaction.user.tag}`
} });
try { if (!role) {
// 2. Create Role throw new Error("Failed to create role.");
const role = await interaction.guild?.roles.create({ }
name: name,
color: colorInput as any,
reason: `Created via /createcolor by ${interaction.user.tag}`
});
if (!role) { // 3. Add to guild settings
throw new Error("Failed to create role."); await guildSettingsService.addColorRole(interaction.guildId!, role.id);
} invalidateGuildConfigCache(interaction.guildId!);
// 3. Add to guild settings // 4. Create Item
await guildSettingsService.addColorRole(interaction.guildId!, role.id); await DrizzleClient.insert(items).values({
invalidateGuildConfigCache(interaction.guildId!); 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
});
// 4. Create Item // 5. Success
await DrizzleClient.insert(items).values({ await interaction.editReply({
name: `Color Role - ${name}`, embeds: [createSuccessEmbed(
description: `Use this item to apply the ${name} color to your name.`, `**Role:** <@&${role.id}> (${colorInput})\n**Item:** Color Role - ${name}\n**Price:** ${price} 🪙`,
type: "CONSUMABLE", "✅ Color Role & Item Created"
rarity: "Common", )]
price: BigInt(price), });
iconUrl: "", },
imageUrl: imageUrl, { ephemeral: true }
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}`)] });
}
} }
}); });

View File

@@ -2,7 +2,7 @@ import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, Colors, userMention, roleMention, ChatInputCommandInteraction } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, Colors, userMention, roleMention, ChatInputCommandInteraction } from "discord.js";
import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service"; import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service";
import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors"; import { withCommandErrorHandling } from "@lib/commandUtils";
export const featureflags = createCommand({ export const featureflags = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -98,57 +98,53 @@ export const featureflags = createCommand({
), ),
autocomplete: async (interaction) => { autocomplete: async (interaction) => {
const focused = interaction.options.getFocused(true); const focused = interaction.options.getFocused(true);
if (focused.name === "name") { if (focused.name === "name") {
const flags = await featureFlagsService.listFlags(); const flags = await featureFlagsService.listFlags();
const filtered = flags const filtered = flags
.filter(f => f.name.toLowerCase().includes(focused.value.toLowerCase())) .filter(f => f.name.toLowerCase().includes(focused.value.toLowerCase()))
.slice(0, 25); .slice(0, 25);
await interaction.respond( await interaction.respond(
filtered.map(f => ({ name: `${f.name} (${f.enabled ? "enabled" : "disabled"})`, value: f.name })) filtered.map(f => ({ name: `${f.name} (${f.enabled ? "enabled" : "disabled"})`, value: f.name }))
); );
} }
}, },
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply(); await withCommandErrorHandling(
interaction,
async () => {
const subcommand = interaction.options.getSubcommand();
const subcommand = interaction.options.getSubcommand(); switch (subcommand) {
case "list":
try { await handleList(interaction);
switch (subcommand) { break;
case "list": case "create":
await handleList(interaction); await handleCreate(interaction);
break; break;
case "create": case "delete":
await handleCreate(interaction); await handleDelete(interaction);
break; break;
case "delete": case "enable":
await handleDelete(interaction); await handleEnable(interaction);
break; break;
case "enable": case "disable":
await handleEnable(interaction); await handleDisable(interaction);
break; break;
case "disable": case "grant":
await handleDisable(interaction); await handleGrant(interaction);
break; break;
case "grant": case "revoke":
await handleGrant(interaction); await handleRevoke(interaction);
break; break;
case "revoke": case "access":
await handleRevoke(interaction); await handleAccess(interaction);
break; break;
case "access": }
await handleAccess(interaction); },
break; { ephemeral: true }
} );
} catch (error) {
if (error instanceof UserError) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
throw error;
}
}
}, },
}); });
@@ -177,44 +173,44 @@ async function handleCreate(interaction: ChatInputCommandInteraction) {
const description = interaction.options.getString("description"); const description = interaction.options.getString("description");
const flag = await featureFlagsService.createFlag(name, description ?? undefined); const flag = await featureFlagsService.createFlag(name, description ?? undefined);
if (!flag) { if (!flag) {
await interaction.editReply({ embeds: [createErrorEmbed("Failed to create feature flag.")] }); await interaction.editReply({ embeds: [createErrorEmbed("Failed to create feature flag.")] });
return; return;
} }
await interaction.editReply({ await interaction.editReply({
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" created successfully. Use \`/featureflags enable\` to enable it.`)] embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" created successfully. Use \`/featureflags enable\` to enable it.`)]
}); });
} }
async function handleDelete(interaction: ChatInputCommandInteraction) { async function handleDelete(interaction: ChatInputCommandInteraction) {
const name = interaction.options.getString("name", true); const name = interaction.options.getString("name", true);
const flag = await featureFlagsService.deleteFlag(name); const flag = await featureFlagsService.deleteFlag(name);
await interaction.editReply({ await interaction.editReply({
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been deleted.`)] embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been deleted.`)]
}); });
} }
async function handleEnable(interaction: ChatInputCommandInteraction) { async function handleEnable(interaction: ChatInputCommandInteraction) {
const name = interaction.options.getString("name", true); const name = interaction.options.getString("name", true);
const flag = await featureFlagsService.setFlagEnabled(name, true); const flag = await featureFlagsService.setFlagEnabled(name, true);
await interaction.editReply({ await interaction.editReply({
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been enabled.`)] embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been enabled.`)]
}); });
} }
async function handleDisable(interaction: ChatInputCommandInteraction) { async function handleDisable(interaction: ChatInputCommandInteraction) {
const name = interaction.options.getString("name", true); const name = interaction.options.getString("name", true);
const flag = await featureFlagsService.setFlagEnabled(name, false); const flag = await featureFlagsService.setFlagEnabled(name, false);
await interaction.editReply({ await interaction.editReply({
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been disabled.`)] embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been disabled.`)]
}); });
} }
@@ -224,8 +220,8 @@ async function handleGrant(interaction: ChatInputCommandInteraction) {
const role = interaction.options.getRole("role"); const role = interaction.options.getRole("role");
if (!user && !role) { if (!user && !role) {
await interaction.editReply({ await interaction.editReply({
embeds: [createErrorEmbed("You must specify either a user or a role to grant access to.")] embeds: [createErrorEmbed("You must specify either a user or a role to grant access to.")]
}); });
return; return;
} }
@@ -250,29 +246,29 @@ async function handleGrant(interaction: ChatInputCommandInteraction) {
target = "Unknown"; target = "Unknown";
} }
await interaction.editReply({ await interaction.editReply({
embeds: [createSuccessEmbed(`Access to "**${name}**" granted to ${target} (ID: ${access.id})`)] embeds: [createSuccessEmbed(`Access to "**${name}**" granted to ${target} (ID: ${access.id})`)]
}); });
} }
async function handleRevoke(interaction: ChatInputCommandInteraction) { async function handleRevoke(interaction: ChatInputCommandInteraction) {
const id = interaction.options.getInteger("id", true); const id = interaction.options.getInteger("id", true);
const access = await featureFlagsService.revokeAccess(id); const access = await featureFlagsService.revokeAccess(id);
await interaction.editReply({ await interaction.editReply({
embeds: [createSuccessEmbed(`Access record #${access.id} has been revoked.`)] embeds: [createSuccessEmbed(`Access record #${access.id} has been revoked.`)]
}); });
} }
async function handleAccess(interaction: ChatInputCommandInteraction) { async function handleAccess(interaction: ChatInputCommandInteraction) {
const name = interaction.options.getString("name", true); const name = interaction.options.getString("name", true);
const accessRecords = await featureFlagsService.listAccess(name); const accessRecords = await featureFlagsService.listAccess(name);
if (accessRecords.length === 0) { if (accessRecords.length === 0) {
await interaction.editReply({ await interaction.editReply({
embeds: [createBaseEmbed("Feature Flag Access", `No access records for "**${name}**".`, Colors.Blue)] embeds: [createBaseEmbed("Feature Flag Access", `No access records for "**${name}**".`, Colors.Blue)]
}); });
return; return;
} }

View File

@@ -7,12 +7,12 @@ import {
} from "discord.js"; } from "discord.js";
import { inventoryService } from "@shared/modules/inventory/inventory.service"; import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { createErrorEmbed } from "@lib/embeds"; import { createErrorEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors";
import { items } from "@db/schema"; import { items } from "@db/schema";
import { ilike, isNotNull, and, inArray } from "drizzle-orm"; import { ilike, isNotNull, and, inArray } from "drizzle-orm";
import { DrizzleClient } from "@shared/db/DrizzleClient"; import { DrizzleClient } from "@shared/db/DrizzleClient";
import { getShopListingMessage } from "@/modules/economy/shop.view"; import { getShopListingMessage } from "@/modules/economy/shop.view";
import { EffectType, LootType } from "@shared/lib/constants"; import { EffectType, LootType } from "@shared/lib/constants";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const listing = createCommand({ export const listing = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -31,72 +31,67 @@ export const listing = createCommand({
) )
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await withCommandErrorHandling(
interaction,
async () => {
const itemId = interaction.options.getNumber("item", true);
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
const itemId = interaction.options.getNumber("item", true); if (!targetChannel || !targetChannel.isSendable()) {
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel; await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] });
return;
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;
}
// Prepare context for lootboxes
const context: { referencedItems: Map<number, { name: string; rarity: string }> } = { referencedItems: new Map() };
const usageData = item.usageData as any;
const lootboxEffect = usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX);
if (lootboxEffect && lootboxEffect.pool) {
const itemIds = lootboxEffect.pool
.filter((drop: any) => drop.type === LootType.ITEM && drop.itemId)
.map((drop: any) => drop.itemId);
if (itemIds.length > 0) {
// Remove duplicates
const uniqueIds = [...new Set(itemIds)] as number[];
const referencedItems = await DrizzleClient.select({
id: items.id,
name: items.name,
rarity: items.rarity
}).from(items).where(inArray(items.id, uniqueIds));
for (const ref of referencedItems) {
context.referencedItems.set(ref.id, { name: ref.name, rarity: ref.rarity || 'C' });
} }
}
}
const listingMessage = getShopListingMessage({ const item = await inventoryService.getItem(itemId);
...item, if (!item) {
rarity: item.rarity || undefined, await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] });
formattedPrice: `${item.price} 🪙`, return;
price: item.price }
}, context);
try { if (!item.price) {
await targetChannel.send(listingMessage as any); await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item "${item.name}" is not for sale (no price set).`)] });
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` }); return;
} catch (error: any) { }
if (error instanceof UserError) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] }); // Prepare context for lootboxes
} else { const context: { referencedItems: Map<number, { name: string; rarity: string }> } = { referencedItems: new Map() };
console.error("Error creating listing:", error);
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] }); const usageData = item.usageData as any;
} const lootboxEffect = usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX);
}
if (lootboxEffect && lootboxEffect.pool) {
const itemIds = lootboxEffect.pool
.filter((drop: any) => drop.type === LootType.ITEM && drop.itemId)
.map((drop: any) => drop.itemId);
if (itemIds.length > 0) {
// Remove duplicates
const uniqueIds = [...new Set(itemIds)] as number[];
const referencedItems = await DrizzleClient.select({
id: items.id,
name: items.name,
rarity: items.rarity
}).from(items).where(inArray(items.id, uniqueIds));
for (const ref of referencedItems) {
context.referencedItems.set(ref.id, { name: ref.name, rarity: ref.rarity || 'C' });
}
}
}
const listingMessage = getShopListingMessage({
...item,
rarity: item.rarity || undefined,
formattedPrice: `${item.price} 🪙`,
price: item.price
}, context);
await targetChannel.send(listingMessage as any);
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
},
{ ephemeral: true }
);
}, },
autocomplete: async (interaction) => { autocomplete: async (interaction) => {
const focusedValue = interaction.options.getFocused(); const focusedValue = interaction.options.getFocused();

View File

@@ -1,8 +1,9 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
import { moderationService } from "@shared/modules/moderation/moderation.service"; import { moderationService } from "@shared/modules/moderation/moderation.service";
import { CaseType } from "@shared/lib/constants"; import { CaseType } from "@shared/lib/constants";
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const note = createCommand({ export const note = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -24,39 +25,35 @@ export const note = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await withCommandErrorHandling(
interaction,
async () => {
const targetUser = interaction.options.getUser("user", true);
const noteText = interaction.options.getString("note", true);
try { // Create the note case
const targetUser = interaction.options.getUser("user", true); const moderationCase = await moderationService.createCase({
const noteText = interaction.options.getString("note", true); type: CaseType.NOTE,
userId: targetUser.id,
// Create the note case username: targetUser.username,
const moderationCase = await moderationService.createCase({ moderatorId: interaction.user.id,
type: CaseType.NOTE, moderatorName: interaction.user.username,
userId: targetUser.id, reason: noteText,
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 if (!moderationCase) {
await interaction.editReply({ await interaction.editReply({
embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)] embeds: [getModerationErrorEmbed("Failed to create note.")]
}); });
return;
}
} catch (error) { // Send success message
console.error("Note command error:", error); await interaction.editReply({
await interaction.editReply({ embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)]
embeds: [getModerationErrorEmbed("An error occurred while adding the note.")] });
}); },
} { ephemeral: true }
);
} }
}); });

View File

@@ -1,7 +1,8 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
import { moderationService } from "@shared/modules/moderation/moderation.service"; import { moderationService } from "@shared/modules/moderation/moderation.service";
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getCasesListEmbed } from "@/modules/moderation/moderation.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const notes = createCommand({ export const notes = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -16,28 +17,24 @@ export const notes = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await withCommandErrorHandling(
interaction,
async () => {
const targetUser = interaction.options.getUser("user", true);
try { // Get all notes for the user
const targetUser = interaction.options.getUser("user", true); const userNotes = await moderationService.getUserNotes(targetUser.id);
// Get all notes for the user // Display the notes
const userNotes = await moderationService.getUserNotes(targetUser.id); await interaction.editReply({
embeds: [getCasesListEmbed(
// Display the notes userNotes,
await interaction.editReply({ `📝 Staff Notes for ${targetUser.username}`,
embeds: [getCasesListEmbed( userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**`
userNotes, )]
`📝 Staff Notes for ${targetUser.username}`, });
userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**` },
)] { ephemeral: true }
}); );
} catch (error) {
console.error("Notes command error:", error);
await interaction.editReply({
embeds: [getModerationErrorEmbed("An error occurred while fetching notes.")]
});
}
} }
}); });

View File

@@ -10,6 +10,7 @@ import {
getPruneWarningEmbed, getPruneWarningEmbed,
getCancelledEmbed getCancelledEmbed
} from "@/modules/moderation/prune.view"; } from "@/modules/moderation/prune.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const prune = createCommand({ export const prune = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -38,142 +39,126 @@ export const prune = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await withCommandErrorHandling(
interaction,
async () => {
const amount = interaction.options.getInteger("amount");
const user = interaction.options.getUser("user");
const all = interaction.options.getBoolean("all") || false;
try { // Validate inputs
const amount = interaction.options.getInteger("amount"); if (!amount && !all) {
const user = interaction.options.getUser("user"); // Default to 10 messages
const all = interaction.options.getBoolean("all") || false; } else if (amount && all) {
// 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({ await interaction.editReply({
embeds: [getSuccessEmbed(result)], embeds: [getPruneErrorEmbed("Cannot specify both `amount` and `all`. Please use one or the other.")]
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; return;
} }
await interaction.editReply({ const finalAmount = all ? 'all' : (amount || 10);
embeds: [getSuccessEmbed(result)] const confirmThreshold = config.moderation.prune.confirmThreshold;
});
}
} catch (error) { // Check if confirmation is needed
console.error("Prune command error:", error); const needsConfirmation = all || (typeof finalAmount === 'number' && finalAmount > confirmThreshold);
let errorMessage = "An unexpected error occurred while trying to delete messages."; if (needsConfirmation) {
if (error instanceof Error) { // Estimate message count for confirmation
if (error.message.includes("permission")) { let estimatedCount: number | undefined;
errorMessage = "I don't have permission to delete messages in this channel."; if (all) {
} else if (error.message.includes("channel type")) { try {
errorMessage = "This command cannot be used in this type of channel."; 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 { } else {
errorMessage = error.message; // No confirmation needed, proceed directly
} const result = await pruneService.deleteMessages(
} interaction.channel!,
{
amount: finalAmount as number,
userId: user?.id,
all: false
}
);
await interaction.editReply({ // Check if no messages were found
embeds: [getPruneErrorEmbed(errorMessage)] 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)]
});
}
},
{ ephemeral: true }
);
} }
}); });

View File

@@ -1,7 +1,8 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { AuroraClient } from "@/lib/BotClient"; import { AuroraClient } from "@/lib/BotClient";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; import { createSuccessEmbed } from "@lib/embeds";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const refresh = createCommand({ export const refresh = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -9,25 +10,24 @@ export const refresh = createCommand({
.setDescription("Reloads all commands and config without restarting") .setDescription("Reloads all commands and config without restarting")
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await withCommandErrorHandling(
interaction,
async () => {
const start = Date.now();
await AuroraClient.loadCommands(true);
const duration = Date.now() - start;
try { // Deploy commands
const start = Date.now(); await AuroraClient.deployCommands();
await AuroraClient.loadCommands(true);
const duration = Date.now() - start;
// Deploy commands const embed = createSuccessEmbed(
await AuroraClient.deployCommands(); `Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`,
"System Refreshed"
);
const embed = createSuccessEmbed( await interaction.editReply({ embeds: [embed] });
`Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`, },
"System Refreshed" { ephemeral: true }
); );
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")] });
}
} }
}); });

View File

@@ -3,7 +3,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, Colors, ChatInputCommandInter
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service"; import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config"; import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
import { UserError } from "@shared/lib/errors"; import { withCommandErrorHandling } from "@lib/commandUtils";
export const settings = createCommand({ export const settings = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -84,33 +84,29 @@ export const settings = createCommand({
.setRequired(false))), .setRequired(false))),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ ephemeral: true }); await withCommandErrorHandling(
interaction,
async () => {
const subcommand = interaction.options.getSubcommand();
const guildId = interaction.guildId!;
const subcommand = interaction.options.getSubcommand(); switch (subcommand) {
const guildId = interaction.guildId!; case "show":
await handleShow(interaction, guildId);
try { break;
switch (subcommand) { case "set":
case "show": await handleSet(interaction, guildId);
await handleShow(interaction, guildId); break;
break; case "reset":
case "set": await handleReset(interaction, guildId);
await handleSet(interaction, guildId); break;
break; case "colors":
case "reset": await handleColors(interaction, guildId);
await handleReset(interaction, guildId); break;
break; }
case "colors": },
await handleColors(interaction, guildId); { ephemeral: true }
break; );
}
} catch (error) {
if (error instanceof UserError) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
throw error;
}
}
}, },
}); });

View File

@@ -2,7 +2,8 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
import { terminalService } from "@shared/modules/terminal/terminal.service"; import { terminalService } from "@shared/modules/terminal/terminal.service";
import { createBaseEmbed, createErrorEmbed } from "@/lib/embeds"; import { createErrorEmbed } from "@/lib/embeds";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const terminal = createCommand({ export const terminal = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -23,15 +24,14 @@ export const terminal = createCommand({
return; return;
} }
await interaction.reply({ ephemeral: true, content: "Initializing terminal..." }); await withCommandErrorHandling(
interaction,
try { async () => {
await terminalService.init(channel as TextChannel); await terminalService.init(channel as TextChannel);
await interaction.editReply({ content: "✅ Terminal initialized!" }); await interaction.editReply({ content: "✅ Terminal initialized!" });
} catch (error) { },
console.error(error); { ephemeral: true }
await interaction.editReply({ content: "❌ Failed to initialize terminal." }); );
}
} }
} }
}); });

View File

@@ -4,9 +4,9 @@ import { moderationService } from "@shared/modules/moderation/moderation.service
import { import {
getWarnSuccessEmbed, getWarnSuccessEmbed,
getModerationErrorEmbed, getModerationErrorEmbed,
getUserWarningEmbed
} from "@/modules/moderation/moderation.view"; } from "@/modules/moderation/moderation.view";
import { getGuildConfig } from "@shared/lib/config"; import { getGuildConfig } from "@shared/lib/config";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const warn = createCommand({ export const warn = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -28,67 +28,63 @@ export const warn = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await withCommandErrorHandling(
interaction,
async () => {
const targetUser = interaction.options.getUser("user", true);
const reason = interaction.options.getString("reason", true);
try { // Don't allow warning bots
const targetUser = interaction.options.getUser("user", true); if (targetUser.bot) {
const reason = interaction.options.getString("reason", true); await interaction.editReply({
embeds: [getModerationErrorEmbed("You cannot warn bots.")]
});
return;
}
// Don't allow warning bots // Don't allow self-warnings
if (targetUser.bot) { if (targetUser.id === interaction.user.id) {
await interaction.editReply({
embeds: [getModerationErrorEmbed("You cannot warn yourself.")]
});
return;
}
// Fetch guild config for moderation settings
const guildConfig = await getGuildConfig(interaction.guildId!);
// 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),
config: {
dmOnWarn: guildConfig.moderation?.cases?.dmOnWarn,
autoTimeoutThreshold: guildConfig.moderation?.cases?.autoTimeoutThreshold,
},
});
// Send success message to moderator
await interaction.editReply({ await interaction.editReply({
embeds: [getModerationErrorEmbed("You cannot warn bots.")] embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
}); });
return;
}
// Don't allow self-warnings // Follow up if auto-timeout was issued
if (targetUser.id === interaction.user.id) { if (autoTimeoutIssued) {
await interaction.editReply({ await interaction.followUp({
embeds: [getModerationErrorEmbed("You cannot warn yourself.")] embeds: [getModerationErrorEmbed(
}); `⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.`
return; )],
} flags: MessageFlags.Ephemeral
});
// Fetch guild config for moderation settings }
const guildConfig = await getGuildConfig(interaction.guildId!); },
{ ephemeral: true }
// 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),
config: {
dmOnWarn: guildConfig.moderation?.cases?.dmOnWarn,
autoTimeoutThreshold: guildConfig.moderation?.cases?.autoTimeoutThreshold,
},
});
// 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.")]
});
}
} }
}); });

View File

@@ -1,7 +1,8 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
import { moderationService } from "@shared/modules/moderation/moderation.service"; import { moderationService } from "@shared/modules/moderation/moderation.service";
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getWarningsEmbed } from "@/modules/moderation/moderation.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const warnings = createCommand({ export const warnings = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -16,24 +17,20 @@ export const warnings = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await withCommandErrorHandling(
interaction,
async () => {
const targetUser = interaction.options.getUser("user", true);
try { // Get active warnings for the user
const targetUser = interaction.options.getUser("user", true); const activeWarnings = await moderationService.getUserWarnings(targetUser.id);
// Get active warnings for the user // Display the warnings
const activeWarnings = await moderationService.getUserWarnings(targetUser.id); await interaction.editReply({
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)]
// Display the warnings });
await interaction.editReply({ },
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)] { ephemeral: true }
}); );
} catch (error) {
console.error("Warnings command error:", error);
await interaction.editReply({
embeds: [getModerationErrorEmbed("An error occurred while fetching warnings.")]
});
}
} }
}); });

View File

@@ -2,6 +2,7 @@ import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { createErrorEmbed } from "@/lib/embeds"; import { createErrorEmbed } from "@/lib/embeds";
import { sendWebhookMessage } from "@/lib/webhookUtils"; import { sendWebhookMessage } from "@/lib/webhookUtils";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const webhook = createCommand({ export const webhook = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -14,43 +15,40 @@ export const webhook = createCommand({
.setRequired(true) .setRequired(true)
), ),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); await withCommandErrorHandling(
interaction,
async () => {
const payloadString = interaction.options.getString("payload", true);
let payload;
const payloadString = interaction.options.getString("payload", true); try {
let payload; payload = JSON.parse(payloadString);
} catch (error) {
await interaction.editReply({
embeds: [createErrorEmbed("The provided payload is not valid JSON.", "Invalid JSON")]
});
return;
}
try { const channel = interaction.channel;
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;
}
if (!channel || !('createWebhook' in channel)) { await sendWebhookMessage(
await interaction.editReply({ channel,
embeds: [createErrorEmbed("This channel does not support webhooks.", "Unsupported Channel")] payload,
}); interaction.client.user,
return; `Proxy message requested by ${interaction.user.tag}`
} );
try { await interaction.editReply({ content: "Message sent successfully!" });
await sendWebhookMessage( },
channel, { ephemeral: true }
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")]
});
}
} }
}); });

View File

@@ -2,35 +2,29 @@
import { createCommand } from "@shared/lib/utils"; import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js"; import { SlashCommandBuilder } from "discord.js";
import { economyService } from "@shared/modules/economy/economy.service"; import { economyService } from "@shared/modules/economy/economy.service";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; import { createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors"; import { withCommandErrorHandling } from "@lib/commandUtils";
export const daily = createCommand({ export const daily = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("daily") .setName("daily")
.setDescription("Claim your daily reward"), .setDescription("Claim your daily reward"),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply(); await withCommandErrorHandling(
try { interaction,
const result = await economyService.claimDaily(interaction.user.id); async () => {
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!") const embed = createSuccessEmbed(`You claimed ** ${result.amount}** Astral Units!${result.isWeekly ? `\n🎉 **Weekly Bonus!** +${result.weeklyBonus} extra!` : ''}`, "💰 Daily Reward Claimed!")
.addFields( .addFields(
{ name: "Streak", value: `🔥 ${result.streak} days`, inline: true }, { 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: "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 } { name: "Next Reward", value: `<t:${Math.floor(result.nextReadyAt.getTime() / 1000)}:R> `, inline: true }
) )
.setColor("Gold"); .setColor("Gold");
await interaction.editReply({ embeds: [embed] }); await interaction.editReply({ embeds: [embed] });
} catch (error: any) {
if (error instanceof UserError) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
console.error("Error claiming daily:", error);
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
} }
} );
} }
}); });

View File

@@ -2,6 +2,7 @@ import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js"; import { SlashCommandBuilder } from "discord.js";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { examService, ExamStatus } from "@shared/modules/economy/exam.service"; import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
import { withCommandErrorHandling } from "@lib/commandUtils";
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
@@ -10,66 +11,62 @@ export const exam = createCommand({
.setName("exam") .setName("exam")
.setDescription("Take your weekly exam to earn rewards based on your XP progress."), .setDescription("Take your weekly exam to earn rewards based on your XP progress."),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply(); await withCommandErrorHandling(
interaction,
async () => {
// First, try to take the exam or check status
const result = await examService.takeExam(interaction.user.id);
try { if (result.status === ExamStatus.NOT_REGISTERED) {
// First, try to take the exam or check status // Register the user
const result = await examService.takeExam(interaction.user.id); const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username);
const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000);
if (result.status === ExamStatus.NOT_REGISTERED) { await interaction.editReply({
// Register the user embeds: [createSuccessEmbed(
const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username); `You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` +
const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000); `Come back on <t:${nextRegTimestamp}:D> (<t:${nextRegTimestamp}:R>) to take your first exam!`,
"Exam Registration Successful"
)]
});
return;
}
const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000);
if (result.status === ExamStatus.COOLDOWN) {
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:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`
)]
});
return;
}
if (result.status === ExamStatus.MISSED) {
await interaction.editReply({
embeds: [createErrorEmbed(
`You missed your exam day! Your exam day is **${DAYS[result.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;
}
// If it reached here with AVAILABLE, it means they passed
await interaction.editReply({ await interaction.editReply({
embeds: [createSuccessEmbed( embeds: [createSuccessEmbed(
`You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` + `**XP Gained:** ${result.xpDiff?.toString()}\n` +
`Come back on <t:${nextRegTimestamp}:D> (<t:${nextRegTimestamp}:R>) to take your first exam!`, `**Multiplier:** x${result.multiplier?.toFixed(2)}\n` +
"Exam Registration Successful" `**Reward:** ${result.reward?.toString()} Currency\n\n` +
`See you next week: <t:${nextExamTimestamp}:D>`,
"Exam Passed!"
)] )]
}); });
return;
} }
);
const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000);
if (result.status === ExamStatus.COOLDOWN) {
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:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`
)]
});
return;
}
if (result.status === ExamStatus.MISSED) {
await interaction.editReply({
embeds: [createErrorEmbed(
`You missed your exam day! Your exam day is **${DAYS[result.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;
}
// If it reached here with AVAILABLE, it means they passed
await interaction.editReply({
embeds: [createSuccessEmbed(
`**XP Gained:** ${result.xpDiff?.toString()}\n` +
`**Multiplier:** x${result.multiplier?.toFixed(2)}\n` +
`**Reward:** ${result.reward?.toString()} Currency\n\n` +
`See you next week: <t:${nextExamTimestamp}:D>`,
"Exam Passed!"
)]
});
} catch (error: any) {
console.error("Error in exam command:", error);
await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An unexpected error occurred.")] });
}
} }
}); });

View File

@@ -5,7 +5,7 @@ import { economyService } from "@shared/modules/economy/economy.service";
import { userService } from "@shared/modules/user/user.service"; import { userService } from "@shared/modules/user/user.service";
import { config } from "@shared/lib/config"; import { config } from "@shared/lib/config";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors"; import { withCommandErrorHandling } from "@lib/commandUtils";
export const pay = createCommand({ export const pay = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -50,20 +50,14 @@ export const pay = createCommand({
return; return;
} }
try { await withCommandErrorHandling(
await interaction.deferReply(); interaction,
await economyService.transfer(senderId, receiverId.toString(), amount); async () => {
await economyService.transfer(senderId, receiverId.toString(), amount);
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful"); const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` }); await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
} 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.")] });
} }
} );
} }
}); });

View File

@@ -6,6 +6,7 @@ import { createErrorEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors"; import { UserError } from "@shared/lib/errors";
import { config } from "@shared/lib/config"; import { config } from "@shared/lib/config";
import { TriviaCategory } from "@shared/lib/constants"; import { TriviaCategory } from "@shared/lib/constants";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const trivia = createCommand({ export const trivia = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -53,64 +54,54 @@ export const trivia = createCommand({
return; return;
} }
// User can play - defer publicly for trivia question // User can play - use standardized error handling for the main operation
await interaction.deferReply(); await withCommandErrorHandling(
interaction,
async () => {
// Start trivia session (deducts entry fee)
const session = await triviaService.startTrivia(
interaction.user.id,
interaction.user.username,
categoryId ? parseInt(categoryId) : undefined
);
// Start trivia session (deducts entry fee) // Generate Components v2 message
const session = await triviaService.startTrivia( const { components, flags } = getTriviaQuestionView(session, interaction.user.username);
interaction.user.id,
interaction.user.username, // Reply with Components v2 question
categoryId ? parseInt(categoryId) : undefined await interaction.editReply({
components,
flags
});
// Set up automatic timeout cleanup
setTimeout(async () => {
const stillActive = triviaService.getSession(session.sessionId);
if (stillActive) {
// User didn't answer - clean up session with no reward
try {
await triviaService.submitAnswer(session.sessionId, interaction.user.id, false);
} catch (error) {
// Session already cleaned up, ignore
}
}
}, config.trivia.timeoutSeconds * 1000 + 5000); // 5 seconds grace period
}
); );
// Generate Components v2 message
const { components, flags } = getTriviaQuestionView(session, interaction.user.username);
// Reply with Components v2 question
await interaction.editReply({
components,
flags
});
// Set up automatic timeout cleanup
setTimeout(async () => {
const stillActive = triviaService.getSession(session.sessionId);
if (stillActive) {
// User didn't answer - clean up session with no reward
try {
await triviaService.submitAnswer(session.sessionId, interaction.user.id, false);
} catch (error) {
// Session already cleaned up, ignore
}
}
}, config.trivia.timeoutSeconds * 1000 + 5000); // 5 seconds grace period
} catch (error: any) { } catch (error: any) {
// Handle errors from the pre-defer canPlayTrivia check
if (error instanceof UserError) { if (error instanceof UserError) {
// Check if we've already deferred await interaction.reply({
if (interaction.deferred) { embeds: [createErrorEmbed(error.message)],
await interaction.editReply({ ephemeral: true
embeds: [createErrorEmbed(error.message)] });
});
} else {
await interaction.reply({
embeds: [createErrorEmbed(error.message)],
ephemeral: true
});
}
} else { } else {
console.error("Error in trivia command:", error); console.error("Error in trivia command:", error);
// Check if we've already deferred await interaction.reply({
if (interaction.deferred) { embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
await interaction.editReply({ ephemeral: true
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")] });
});
} else {
await interaction.reply({
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
ephemeral: true
});
}
} }
} }
} }

View File

@@ -4,8 +4,7 @@ import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { userService } from "@shared/modules/user/user.service"; import { userService } from "@shared/modules/user/user.service";
import { createErrorEmbed } from "@lib/embeds"; import { createErrorEmbed } from "@lib/embeds";
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view"; import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
import type { ItemUsageData } from "@shared/lib/types"; import { withCommandErrorHandling } from "@lib/commandUtils";
import { UserError } from "@shared/lib/errors";
import { getGuildConfig } from "@shared/lib/config"; import { getGuildConfig } from "@shared/lib/config";
export const use = createCommand({ export const use = createCommand({
@@ -19,57 +18,50 @@ export const use = createCommand({
.setAutocomplete(true) .setAutocomplete(true)
), ),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.deferReply(); await withCommandErrorHandling(
interaction,
async () => {
const guildConfig = await getGuildConfig(interaction.guildId!);
const colorRoles = guildConfig.colorRoles ?? [];
const guildConfig = await getGuildConfig(interaction.guildId!); const itemId = interaction.options.getNumber("item", true);
const colorRoles = guildConfig.colorRoles ?? []; const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
if (!user) {
await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] });
return;
}
const itemId = interaction.options.getNumber("item", true); const result = await inventoryService.useItem(user.id.toString(), itemId);
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 usageData = result.usageData;
const result = await inventoryService.useItem(user.id.toString(), itemId); if (usageData) {
for (const effect of usageData.effects) {
const usageData = result.usageData; if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') {
if (usageData) { try {
for (const effect of usageData.effects) { const member = await interaction.guild?.members.fetch(user.id.toString());
if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') { if (member) {
try { if (effect.type === 'TEMP_ROLE') {
const member = await interaction.guild?.members.fetch(user.id.toString()); await member.roles.add(effect.roleId);
if (member) { } else if (effect.type === 'COLOR_ROLE') {
if (effect.type === 'TEMP_ROLE') { // Remove existing color roles
await member.roles.add(effect.roleId); const rolesToRemove = colorRoles.filter(r => member.roles.cache.has(r));
} else if (effect.type === 'COLOR_ROLE') { if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
// Remove existing color roles await member.roles.add(effect.roleId);
const rolesToRemove = 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)");
} }
} catch (e) {
console.error("Failed to assign role in /use command:", e);
result.results.push("⚠️ Failed to assign role (Check bot permissions)");
} }
} }
} }
const { embed, files } = getItemUseResultEmbed(result.results, result.item);
await interaction.editReply({ embeds: [embed], files });
} }
);
const { embed, files } = getItemUseResultEmbed(result.results, result.item);
await interaction.editReply({ embeds: [embed], files });
} 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) => { autocomplete: async (interaction) => {
const focusedValue = interaction.options.getFocused(); const focusedValue = interaction.options.getFocused();

View File

@@ -0,0 +1,147 @@
import { describe, it, expect, mock, beforeEach, spyOn } from "bun:test";
import { UserError } from "@shared/lib/errors";
// --- Mocks ---
const mockDeferReply = mock(() => Promise.resolve());
const mockEditReply = mock(() => Promise.resolve());
const mockInteraction = {
deferReply: mockDeferReply,
editReply: mockEditReply,
} as any;
const mockCreateErrorEmbed = mock((msg: string) => ({ description: msg, type: "error" }));
mock.module("./embeds", () => ({
createErrorEmbed: mockCreateErrorEmbed,
}));
// Import AFTER mocking
const { withCommandErrorHandling } = await import("./commandUtils");
// --- Tests ---
describe("withCommandErrorHandling", () => {
let consoleErrorSpy: ReturnType<typeof spyOn>;
beforeEach(() => {
mockDeferReply.mockClear();
mockEditReply.mockClear();
mockCreateErrorEmbed.mockClear();
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => { });
});
it("should always call deferReply", async () => {
await withCommandErrorHandling(
mockInteraction,
async () => "result"
);
expect(mockDeferReply).toHaveBeenCalledTimes(1);
});
it("should pass ephemeral option to deferReply", async () => {
await withCommandErrorHandling(
mockInteraction,
async () => "result",
{ ephemeral: true }
);
expect(mockDeferReply).toHaveBeenCalledWith({ ephemeral: true });
});
it("should return the operation result on success", async () => {
const result = await withCommandErrorHandling(
mockInteraction,
async () => ({ data: "test" })
);
expect(result).toEqual({ data: "test" });
});
it("should call onSuccess with the result", async () => {
const onSuccess = mock(async (_result: string) => { });
await withCommandErrorHandling(
mockInteraction,
async () => "hello",
{ onSuccess }
);
expect(onSuccess).toHaveBeenCalledWith("hello");
});
it("should send successMessage when no onSuccess is provided", async () => {
await withCommandErrorHandling(
mockInteraction,
async () => "result",
{ successMessage: "It worked!" }
);
expect(mockEditReply).toHaveBeenCalledWith({
content: "It worked!",
});
});
it("should prefer onSuccess over successMessage", async () => {
const onSuccess = mock(async (_result: string) => { });
await withCommandErrorHandling(
mockInteraction,
async () => "result",
{ successMessage: "This should not be sent", onSuccess }
);
expect(onSuccess).toHaveBeenCalledTimes(1);
// editReply should NOT have been called with the successMessage
expect(mockEditReply).not.toHaveBeenCalledWith({
content: "This should not be sent",
});
});
it("should show error embed for UserError", async () => {
const result = await withCommandErrorHandling(
mockInteraction,
async () => {
throw new UserError("You can't do that!");
}
);
expect(result).toBeUndefined();
expect(mockCreateErrorEmbed).toHaveBeenCalledWith("You can't do that!");
expect(mockEditReply).toHaveBeenCalledTimes(1);
});
it("should show generic error and log for unexpected errors", async () => {
const unexpectedError = new Error("Database exploded");
const result = await withCommandErrorHandling(
mockInteraction,
async () => {
throw unexpectedError;
}
);
expect(result).toBeUndefined();
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Unexpected error in command:",
unexpectedError
);
expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
"An unexpected error occurred."
);
expect(mockEditReply).toHaveBeenCalledTimes(1);
});
it("should return undefined on error", async () => {
const result = await withCommandErrorHandling(
mockInteraction,
async () => {
throw new Error("fail");
}
);
expect(result).toBeUndefined();
});
});

79
bot/lib/commandUtils.ts Normal file
View File

@@ -0,0 +1,79 @@
import type { ChatInputCommandInteraction } from "discord.js";
import { UserError } from "@shared/lib/errors";
import { createErrorEmbed } from "./embeds";
/**
* Wraps a command's core logic with standardized error handling.
*
* - Calls `interaction.deferReply()` automatically
* - On success, invokes `onSuccess` callback or sends `successMessage`
* - On `UserError`, shows the error message in an error embed
* - On unexpected errors, logs to console and shows a generic error embed
*
* @example
* ```typescript
* export const myCommand = createCommand({
* execute: async (interaction) => {
* await withCommandErrorHandling(
* interaction,
* async () => {
* const result = await doSomething();
* await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
* }
* );
* }
* });
* ```
*
* @example
* ```typescript
* // With deferReply options (e.g. ephemeral)
* await withCommandErrorHandling(
* interaction,
* async () => doSomething(),
* {
* ephemeral: true,
* successMessage: "Done!",
* }
* );
* ```
*/
export async function withCommandErrorHandling<T>(
interaction: ChatInputCommandInteraction,
operation: () => Promise<T>,
options?: {
/** Message to send on success (if no onSuccess callback is provided) */
successMessage?: string;
/** Callback invoked with the operation result on success */
onSuccess?: (result: T) => Promise<void>;
/** Whether the deferred reply should be ephemeral */
ephemeral?: boolean;
}
): Promise<T | undefined> {
try {
await interaction.deferReply({ ephemeral: options?.ephemeral });
const result = await operation();
if (options?.onSuccess) {
await options.onSuccess(result);
} else if (options?.successMessage) {
await interaction.editReply({
content: options.successMessage,
});
}
return result;
} catch (error) {
if (error instanceof UserError) {
await interaction.editReply({
embeds: [createErrorEmbed(error.message)],
});
} else {
console.error("Unexpected error in command:", error);
await interaction.editReply({
embeds: [createErrorEmbed("An unexpected error occurred.")],
});
}
return undefined;
}
}