feat: add color2 gradient color support and defer interactions for slow commands
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user