feat(discord): add Zod schema validation for booster-role command inputs

This commit is contained in:
MythEclipse
2026-06-06 03:07:47 +07:00
parent 66d3b5e997
commit 917714f237
2 changed files with 46 additions and 8 deletions
+24 -8
View File
@@ -1,6 +1,8 @@
import { MessageFlags, PermissionFlagsBits } from "discord.js"; import { MessageFlags, PermissionFlagsBits } from "discord.js";
import { z } from "zod";
import { logger } from "../logger"; import { logger } from "../logger";
import { ValidationError, NotFoundError, PermissionError } from "../domain/errors"; import { ValidationError, NotFoundError, PermissionError } from "../domain/errors";
import { claimOptions, renameOptions, recolorOptions } from "./schemas";
import type { BoosterRoleRecord, RoleIcon } from "../services/boosterRoleService"; import type { BoosterRoleRecord, RoleIcon } from "../services/boosterRoleService";
export type ChatInputInteractionLike = { export type ChatInputInteractionLike = {
@@ -62,12 +64,17 @@ export async function handleInteraction(
if (subcommand === "claim") { if (subcommand === "claim") {
logger.info("Handling booster-role command", { guildId, userId, subcommand }); logger.info("Handling booster-role command", { guildId, userId, subcommand });
const role = await service.claimRole({ const parsed = claimOptions.parse({
guildId,
userId,
name: requireString(interaction, "name"), name: requireString(interaction, "name"),
color: interaction.options.getString("color"), color: interaction.options.getString("color"),
color2: interaction.options.getString("color2"), color2: interaction.options.getString("color2"),
});
const role = await service.claimRole({
guildId,
userId,
name: parsed.name,
color: parsed.color ?? null,
color2: parsed.color2 ?? null,
icon: optionalIcon(interaction, "icon"), icon: optionalIcon(interaction, "icon"),
isBoosting: await deps.isBoosting(guildId, userId) isBoosting: await deps.isBoosting(guildId, userId)
}); });
@@ -77,14 +84,19 @@ export async function handleInteraction(
if (subcommand === "rename") { if (subcommand === "rename") {
logger.info("Handling booster-role command", { guildId, userId, subcommand }); logger.info("Handling booster-role command", { guildId, userId, subcommand });
await service.renameRole({ guildId, userId, name: requireString(interaction, "name") }); const parsed = renameOptions.parse({ name: requireString(interaction, "name") });
await service.renameRole({ guildId, userId, name: parsed.name });
await interaction.reply({ content: "Booster role renamed." }); await interaction.reply({ content: "Booster role renamed." });
return; return;
} }
if (subcommand === "recolor") { if (subcommand === "recolor") {
logger.info("Handling booster-role command", { guildId, userId, subcommand }); logger.info("Handling booster-role command", { guildId, userId, subcommand });
await service.recolorRole({ guildId, userId, color: requireString(interaction, "color"), color2: interaction.options.getString("color2") }); const parsed = recolorOptions.parse({
color: requireString(interaction, "color"),
color2: interaction.options.getString("color2"),
});
await service.recolorRole({ guildId, userId, color: parsed.color, color2: parsed.color2 ?? null });
await interaction.reply({ content: "Booster role color updated." }); await interaction.reply({ content: "Booster role color updated." });
return; return;
} }
@@ -114,12 +126,16 @@ export async function handleInteraction(
throw new Error("Unknown booster-role subcommand"); throw new Error("Unknown booster-role subcommand");
} catch (error) { } catch (error) {
logger.warn("Booster-role command failed", { error }); // Normalize Zod validation errors to our ValidationError type
const normalized = error instanceof z.ZodError
? new ValidationError(error.errors.map(e => e.message).join("; "))
: error;
logger.warn("Booster-role command failed", { error: normalized instanceof Error ? normalized.message : String(normalized) });
// If already deferred, edit the reply instead of replying anew // If already deferred, edit the reply instead of replying anew
if (interaction.deferred) { if (interaction.deferred) {
await interaction.editReply({ content: toUserErrorMessage(error) }); await interaction.editReply({ content: toUserErrorMessage(normalized) });
} else { } else {
await interaction.reply({ content: toUserErrorMessage(error) }); await interaction.reply({ content: toUserErrorMessage(normalized) });
} }
} }
} }
+22
View File
@@ -0,0 +1,22 @@
import { z } from "zod";
export const hexColorRegex = /^#[0-9A-F]{6}$/i;
export const claimOptions = z.object({
name: z.string().min(3, "Role name must be 3-32 characters").max(32, "Role name must be 3-32 characters"),
color: z.string().regex(hexColorRegex, "Color must be a hex value like #AABBCC").nullable().optional(),
color2: z.string().regex(hexColorRegex, "Color must be a hex value like #AABBCC").nullable().optional(),
});
export const renameOptions = z.object({
name: z.string().min(3, "Role name must be 3-32 characters").max(32, "Role name must be 3-32 characters"),
});
export const recolorOptions = z.object({
color: z.string().regex(hexColorRegex, "Color must be a hex value like #AABBCC"),
color2: z.string().regex(hexColorRegex, "Color must be a hex value like #AABBCC").nullable().optional(),
});
export type ClaimInput = z.infer<typeof claimOptions>;
export type RenameInput = z.infer<typeof renameOptions>;
export type RecolorInput = z.infer<typeof recolorOptions>;