feat: add color2 gradient color support and defer interactions for slow commands

This commit is contained in:
MythEclipse
2026-06-06 00:48:41 +07:00
parent 0dbd2b22c7
commit bf03af53af
9 changed files with 234 additions and 29 deletions
+4 -2
View File
@@ -8,7 +8,8 @@ export const boosterRoleCommand = new SlashCommandBuilder()
.setName("claim")
.setDescription("Claim a new custom cosmetic booster role")
.addStringOption((option) => option.setName("name").setDescription("Role name").setRequired(true))
.addStringOption((option) => option.setName("color").setDescription("Hex color like #AABBCC"))
.addStringOption((option) => option.setName("color").setDescription("Primary hex color like #AABBCC"))
.addStringOption((option) => option.setName("color2").setDescription("Secondary gradient hex color like #CCDDEE"))
.addAttachmentOption((option) => option.setName("icon").setDescription("Optional role icon image"))
)
.addSubcommand((command) =>
@@ -21,7 +22,8 @@ export const boosterRoleCommand = new SlashCommandBuilder()
command
.setName("recolor")
.setDescription("Recolor your bot-managed booster role")
.addStringOption((option) => option.setName("color").setDescription("Hex color like #AABBCC").setRequired(true))
.addStringOption((option) => option.setName("color").setDescription("Primary hex color like #AABBCC").setRequired(true))
.addStringOption((option) => option.setName("color2").setDescription("Secondary gradient hex color like #CCDDEE"))
)
.addSubcommand((command) =>
command
+31 -3
View File
@@ -10,6 +10,8 @@ class FakeInteraction implements ChatInputInteractionLike {
guildId: string | null = "guild";
memberPermissions: { has(permission: bigint): boolean } | null = null;
replies: Reply[] = [];
deferred = false;
replied = false;
constructor(private readonly subcommand: string, private readonly values: Record<string, unknown> = {}) {}
@@ -19,6 +21,15 @@ class FakeInteraction implements ChatInputInteractionLike {
async reply(reply: Reply): Promise<void> {
this.replies.push(reply);
this.replied = true;
}
async deferReply(_input: { flags: MessageFlags.Ephemeral }): Promise<void> {
this.deferred = true;
}
async editReply(input: { content: string }): Promise<void> {
this.replies.push({ content: input.content, flags: MessageFlags.Ephemeral });
}
options = {
@@ -55,13 +66,14 @@ class FakeService implements BoosterRoleCommandService {
}
describe("handleInteraction", () => {
test("replies to claim command", async () => {
test("replies to claim command (deferred)", async () => {
const interaction = new FakeInteraction("claim", { name: "Test", color: "#AABBCC" });
const service = new FakeService();
await handleInteraction(interaction, service, { isBoosting: async () => true });
expect(service.calls).toEqual(["claim"]);
expect(interaction.deferred).toBe(true);
expect(interaction.replies[0]).toEqual({ content: "Booster role created: <@&role-1>", flags: MessageFlags.Ephemeral });
});
@@ -109,7 +121,7 @@ describe("handleInteraction", () => {
expect(interaction.replies[0]).toEqual({ content: "Administrator permission is required", flags: MessageFlags.Ephemeral });
});
test("turns service errors into private replies", async () => {
test("turns service errors into private replies (uses editReply when deferred)", async () => {
const interaction = new FakeInteraction("claim", { name: "VIP" });
const service = new FakeService();
service.claimRole = async () => {
@@ -118,10 +130,12 @@ describe("handleInteraction", () => {
await handleInteraction(interaction, service, { isBoosting: async () => true });
// claim is deferred, so errors use editReply
expect(interaction.deferred).toBe(true);
expect(interaction.replies[0]).toEqual({ content: "Role name is already used", flags: MessageFlags.Ephemeral });
});
test("hides failed query details from user replies", async () => {
test("hides failed query details from user replies (deferred)", async () => {
const interaction = new FakeInteraction("claim", { name: "VIP" });
const service = new FakeService();
service.claimRole = async () => {
@@ -130,6 +144,20 @@ describe("handleInteraction", () => {
await handleInteraction(interaction, service, { isBoosting: async () => true });
expect(interaction.deferred).toBe(true);
expect(interaction.replies[0]).toEqual({ content: "Failed to save booster role. Any created role was cleaned up. Try again.", flags: MessageFlags.Ephemeral });
});
test("non-deferred commands still use reply for errors", async () => {
const interaction = new FakeInteraction("rename", { name: "New Name" });
const service = new FakeService();
service.renameRole = async () => {
throw new Error("Some error");
};
await handleInteraction(interaction, service, { isBoosting: async () => true });
expect(interaction.deferred).toBe(false);
expect(interaction.replies[0]).toEqual({ content: "Some error", flags: MessageFlags.Ephemeral });
});
});
+30 -6
View File
@@ -8,7 +8,11 @@ export type ChatInputInteractionLike = {
user: { id: string };
memberPermissions: { has(permission: bigint): boolean } | null;
isChatInputCommand(): boolean;
deferred: boolean;
replied: boolean;
reply(input: { content: string; flags: MessageFlags.Ephemeral }): Promise<unknown>;
deferReply(input: { flags: MessageFlags.Ephemeral }): Promise<unknown>;
editReply(input: { content: string }): Promise<unknown>;
options: {
getSubcommand(): string;
getString(name: string): string | null;
@@ -18,9 +22,17 @@ export type ChatInputInteractionLike = {
};
export type BoosterRoleCommandService = {
claimRole(input: { guildId: string; userId: string; name: string; color: string | null; icon?: RoleIcon | null; isBoosting: boolean }): Promise<BoosterRoleRecord | { roleId: string }>;
claimRole(input: {
guildId: string;
userId: string;
name: string;
color: string | null;
color2?: string | null;
icon?: RoleIcon | null;
isBoosting: boolean;
}): Promise<BoosterRoleRecord | { roleId: string }>;
renameRole(input: { guildId: string; userId: string; name: string }): Promise<void>;
recolorRole(input: { guildId: string; userId: string; color: string }): Promise<void>;
recolorRole(input: { guildId: string; userId: string; color: string; color2?: string | null }): Promise<void>;
setRoleIcon(input: { guildId: string; userId: string; icon: RoleIcon }): Promise<void>;
deleteRole(input: { guildId: string; userId: string }): Promise<void>;
};
@@ -41,6 +53,12 @@ export async function handleInteraction(
const userId = interaction.user.id;
const subcommand = interaction.options.getSubcommand();
// Defer reply for potentially slow commands (claim, icon) to avoid 10062 "Unknown interaction"
const shouldDefer = subcommand === "claim" || subcommand === "icon";
if (shouldDefer) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
}
if (subcommand === "claim") {
logger.info("Handling booster-role command", { guildId, userId, subcommand });
const role = await service.claimRole({
@@ -48,10 +66,11 @@ export async function handleInteraction(
userId,
name: requireString(interaction, "name"),
color: interaction.options.getString("color"),
color2: interaction.options.getString("color2"),
icon: optionalIcon(interaction, "icon"),
isBoosting: await deps.isBoosting(guildId, userId)
});
await interaction.reply({ content: `Booster role created: <@&${role.roleId}>`, flags: MessageFlags.Ephemeral });
await interaction.editReply({ content: `Booster role created: <@&${role.roleId}>` });
return;
}
@@ -64,7 +83,7 @@ export async function handleInteraction(
if (subcommand === "recolor") {
logger.info("Handling booster-role command", { guildId, userId, subcommand });
await service.recolorRole({ guildId, userId, color: requireString(interaction, "color") });
await service.recolorRole({ guildId, userId, color: requireString(interaction, "color"), color2: interaction.options.getString("color2") });
await interaction.reply({ content: "Booster role color updated.", flags: MessageFlags.Ephemeral });
return;
}
@@ -72,7 +91,7 @@ export async function handleInteraction(
if (subcommand === "icon") {
logger.info("Handling booster-role command", { guildId, userId, subcommand });
await service.setRoleIcon({ guildId, userId, icon: requireIcon(interaction, "image") });
await interaction.reply({ content: "Booster role icon updated.", flags: MessageFlags.Ephemeral });
await interaction.editReply({ content: "Booster role icon updated." });
return;
}
@@ -95,7 +114,12 @@ export async function handleInteraction(
throw new Error("Unknown booster-role subcommand");
} catch (error) {
logger.warn("Booster-role command failed", { error });
await interaction.reply({ content: toUserErrorMessage(error), flags: MessageFlags.Ephemeral });
// If already deferred, edit the reply instead of replying anew
if (interaction.deferred) {
await interaction.editReply({ content: toUserErrorMessage(error) });
} else {
await interaction.reply({ content: toUserErrorMessage(error), flags: MessageFlags.Ephemeral });
}
}
}