From bf03af53affb280583ce407e41a419cab72c8a23 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Sat, 6 Jun 2026 00:48:41 +0700 Subject: [PATCH] feat: add color2 gradient color support and defer interactions for slow commands --- .../migrations/0002_regular_green_goblin.sql | 1 + src/db/migrations/meta/0002_snapshot.json | 129 ++++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + src/discord/commands/booster-role.ts | 6 +- src/discord/interactionHandler.test.ts | 34 ++++- src/discord/interactionHandler.ts | 36 ++++- src/services/boosterRoleService.test.ts | 4 +- src/services/boosterRoleService.ts | 31 ++++- src/services/discordRoleRepository.ts | 15 +- 9 files changed, 234 insertions(+), 29 deletions(-) create mode 100644 src/db/migrations/0002_regular_green_goblin.sql create mode 100644 src/db/migrations/meta/0002_snapshot.json diff --git a/src/db/migrations/0002_regular_green_goblin.sql b/src/db/migrations/0002_regular_green_goblin.sql new file mode 100644 index 0000000..30d2aa9 --- /dev/null +++ b/src/db/migrations/0002_regular_green_goblin.sql @@ -0,0 +1 @@ +ALTER TABLE "booster_roles" ADD COLUMN "color2" text; \ No newline at end of file diff --git a/src/db/migrations/meta/0002_snapshot.json b/src/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..b3d058a --- /dev/null +++ b/src/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,129 @@ +{ + "id": "619da77d-9ef1-42fe-9563-5858b7039de2", + "prevId": "66a1346c-4510-4ec6-a055-a3d0bcf803c3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.booster_roles": { + "name": "booster_roles", + "schema": "", + "columns": { + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color2": { + "name": "color2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "booster_roles_guild_user_idx": { + "name": "booster_roles_guild_user_idx", + "columns": [ + { + "expression": "guild_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "booster_roles_guild_role_idx": { + "name": "booster_roles_guild_role_idx", + "columns": [ + { + "expression": "guild_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index d0c0655..3fa353b 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1779352840357, "tag": "0001_misty_wraith", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1780681661765, + "tag": "0002_regular_green_goblin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/discord/commands/booster-role.ts b/src/discord/commands/booster-role.ts index 6118b95..ae395b4 100644 --- a/src/discord/commands/booster-role.ts +++ b/src/discord/commands/booster-role.ts @@ -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 diff --git a/src/discord/interactionHandler.test.ts b/src/discord/interactionHandler.test.ts index 52e2ff1..37f3ffb 100644 --- a/src/discord/interactionHandler.test.ts +++ b/src/discord/interactionHandler.test.ts @@ -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 = {}) {} @@ -19,6 +21,15 @@ class FakeInteraction implements ChatInputInteractionLike { async reply(reply: Reply): Promise { this.replies.push(reply); + this.replied = true; + } + + async deferReply(_input: { flags: MessageFlags.Ephemeral }): Promise { + this.deferred = true; + } + + async editReply(input: { content: string }): Promise { + 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 }); + }); }); diff --git a/src/discord/interactionHandler.ts b/src/discord/interactionHandler.ts index 59a2ed7..1c6039e 100644 --- a/src/discord/interactionHandler.ts +++ b/src/discord/interactionHandler.ts @@ -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; + deferReply(input: { flags: MessageFlags.Ephemeral }): Promise; + editReply(input: { content: string }): Promise; 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; + claimRole(input: { + guildId: string; + userId: string; + name: string; + color: string | null; + color2?: string | null; + icon?: RoleIcon | null; + isBoosting: boolean; + }): Promise; renameRole(input: { guildId: string; userId: string; name: string }): Promise; - recolorRole(input: { guildId: string; userId: string; color: string }): Promise; + recolorRole(input: { guildId: string; userId: string; color: string; color2?: string | null }): Promise; setRoleIcon(input: { guildId: string; userId: string; icon: RoleIcon }): Promise; deleteRole(input: { guildId: string; userId: string }): Promise; }; @@ -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 }); + } } } diff --git a/src/services/boosterRoleService.test.ts b/src/services/boosterRoleService.test.ts index c362e8e..413ef62 100644 --- a/src/services/boosterRoleService.test.ts +++ b/src/services/boosterRoleService.test.ts @@ -39,13 +39,13 @@ class FakeRoleRepository implements RoleRepository { return [...this.roles.values()]; } - async createRole(input: { name: string; color: string | null; permissions: string[]; position: number }) { + async createRole(input: { name: string; color: string | null; colors?: { primaryColor: string; secondaryColor?: string; tertiaryColor?: string } | null; permissions: string[]; position: number }) { const id = `created-${this.roles.size + 1}`; this.roles.set(id, { id, ...input }); return { id }; } - async updateRole(roleId: string, input: { name?: string; color?: string | null; icon?: string | null }) { + async updateRole(roleId: string, input: { name?: string; color?: string | null; colors?: { primaryColor: string; secondaryColor?: string; tertiaryColor?: string } | null; icon?: string | null }) { const role = this.roles.get(roleId); if (!role) throw new Error("Role does not exist"); this.roles.set(roleId, { ...role, ...input }); diff --git a/src/services/boosterRoleService.ts b/src/services/boosterRoleService.ts index bd7a1a7..9ae9d68 100644 --- a/src/services/boosterRoleService.ts +++ b/src/services/boosterRoleService.ts @@ -3,6 +3,7 @@ import { assertRoleNameIsAvailable, assertRolePositionIsSafe, normalizeHexColor, + normalizeOptionalHexColor, validateRoleName, type ExistingRole } from "../domain/roleGuards"; @@ -13,6 +14,7 @@ export type BoosterRoleRecord = { roleId: string; name: string; color: string | null; + color2: string | null; icon: string | null; createdAt: number; updatedAt: number; @@ -26,8 +28,8 @@ export type BoosterRoleStore = { export type RoleRepository = { listRoles(): Promise; - createRole(input: { name: string; color: string | null; permissions: string[]; position: number }): Promise<{ id: string }>; - updateRole(roleId: string, input: { name?: string; color?: string | null; icon?: string | null }): Promise; + createRole(input: { name: string; color: string | null; colors?: { primaryColor: string; secondaryColor?: string; tertiaryColor?: string } | null; permissions: string[]; position: number }): Promise<{ id: string }>; + updateRole(roleId: string, input: { name?: string; color?: string | null; colors?: { primaryColor: string; secondaryColor?: string; tertiaryColor?: string } | null; icon?: string | null }): Promise; assignRole(userId: string, roleId: string): Promise; removeRole(userId: string, roleId: string): Promise; deleteRole(roleId: string): Promise; @@ -61,6 +63,7 @@ export class BoosterRoleService { userId: string; name: string; color: string | null; + color2?: string | null; icon?: RoleIcon | null; isBoosting: boolean; }): Promise { @@ -73,13 +76,15 @@ export class BoosterRoleService { } const name = validateRoleName(input.name); - const color = input.color ? normalizeHexColor(input.color) : null; + const color = normalizeOptionalHexColor(input.color); + const color2 = normalizeOptionalHexColor(input.color2 ?? null); + const colors = resolveGradientColors(color, color2); assertRoleNameIsAvailable(name, await this.roles.listRoles()); const position = this.options.anchorPosition - 1; assertRolePositionIsSafe(position, this.options.anchorPosition); - const role = await this.roles.createRole({ name, color, permissions: [], position }); + const role = await this.roles.createRole({ name, color, colors, permissions: [], position }); let assigned = false; try { @@ -98,6 +103,7 @@ export class BoosterRoleService { roleId: role.id, name, color, + color2, icon: input.icon?.dataUri ?? null, createdAt: timestamp, updatedAt: timestamp @@ -119,10 +125,13 @@ export class BoosterRoleService { await this.roles.updateRole(record.roleId, { name }); } - async recolorRole(input: { guildId: string; userId: string; color: string }): Promise { - const { guildId, userId, color } = input; + async recolorRole(input: { guildId: string; userId: string; color: string; color2?: string | null }): Promise { + const { guildId, userId, color, color2 } = input; const record = await this.getUserRecord(guildId, userId); - await this.roles.updateRole(record.roleId, { color: normalizeHexColor(color) }); + const primaryColor = normalizeHexColor(color); + const secondaryColor = normalizeOptionalHexColor(color2 ?? null); + const colors = resolveGradientColors(primaryColor, secondaryColor); + await this.roles.updateRole(record.roleId, { colors }); } async setRoleIcon(input: { guildId: string; userId: string; icon: RoleIcon }): Promise { @@ -182,3 +191,11 @@ async function ignoreRollbackError(action: () => Promise): Promise { } catch { } } + +function resolveGradientColors(color: string | null, color2: string | null): { primaryColor: string; secondaryColor?: string } | null { + if (!color) return null; + if (color2) { + return { primaryColor: color, secondaryColor: color2 }; + } + return { primaryColor: color }; +} diff --git a/src/services/discordRoleRepository.ts b/src/services/discordRoleRepository.ts index 9c58ff8..d36dba3 100644 --- a/src/services/discordRoleRepository.ts +++ b/src/services/discordRoleRepository.ts @@ -1,4 +1,4 @@ -import type { ColorResolvable, Guild, Role } from "discord.js"; +import type { ColorResolvable, Guild, Role, RoleColorsResolvable } from "discord.js"; import type { RoleRepository } from "./boosterRoleService"; export class DiscordRoleRepository implements RoleRepository { @@ -11,10 +11,11 @@ export class DiscordRoleRepository implements RoleRepository { return this.guild.roles.cache.map((role) => ({ id: role.id, name: role.name })); } - async createRole(input: { name: string; color: string | null; permissions: string[]; position: number }): Promise<{ id: string }> { + async createRole(input: { name: string; color: string | null; colors?: { primaryColor: string; secondaryColor?: string; tertiaryColor?: string } | null; permissions: string[]; position: number }): Promise<{ id: string }> { + const colors = input.colors ?? (input.color ? { primaryColor: input.color as ColorResolvable } : undefined); const role = await this.guild.roles.create({ name: input.name, - colors: toDiscordColors(input.color), + colors: colors as RoleColorsResolvable | undefined, permissions: 0n }); @@ -22,11 +23,11 @@ export class DiscordRoleRepository implements RoleRepository { return { id: role.id }; } - async updateRole(roleId: string, input: { name?: string; color?: string | null; icon?: string | null }): Promise { + async updateRole(roleId: string, input: { name?: string; color?: string | null; colors?: { primaryColor: string; secondaryColor?: string; tertiaryColor?: string } | null; icon?: string | null }): Promise { const role = await this.fetchRole(roleId); await role.edit({ name: input.name, - colors: input.color === undefined ? undefined : toDiscordColors(input.color), + colors: input.colors === undefined ? undefined : input.colors as RoleColorsResolvable, icon: input.icon === undefined ? undefined : input.icon }); } @@ -56,7 +57,3 @@ export class DiscordRoleRepository implements RoleRepository { return role; } } - -function toDiscordColors(color: string | null): { primaryColor: ColorResolvable } | undefined { - return color === null ? undefined : { primaryColor: color as ColorResolvable }; -}