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
@@ -0,0 +1 @@
ALTER TABLE "booster_roles" ADD COLUMN "color2" text;
+129
View File
@@ -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": {}
}
}
+7
View File
@@ -15,6 +15,13 @@
"when": 1779352840357, "when": 1779352840357,
"tag": "0001_misty_wraith", "tag": "0001_misty_wraith",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1780681661765,
"tag": "0002_regular_green_goblin",
"breakpoints": true
} }
] ]
} }
+4 -2
View File
@@ -8,7 +8,8 @@ export const boosterRoleCommand = new SlashCommandBuilder()
.setName("claim") .setName("claim")
.setDescription("Claim a new custom cosmetic booster role") .setDescription("Claim a new custom cosmetic booster role")
.addStringOption((option) => option.setName("name").setDescription("Role name").setRequired(true)) .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")) .addAttachmentOption((option) => option.setName("icon").setDescription("Optional role icon image"))
) )
.addSubcommand((command) => .addSubcommand((command) =>
@@ -21,7 +22,8 @@ export const boosterRoleCommand = new SlashCommandBuilder()
command command
.setName("recolor") .setName("recolor")
.setDescription("Recolor your bot-managed booster role") .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) => .addSubcommand((command) =>
command command
+31 -3
View File
@@ -10,6 +10,8 @@ class FakeInteraction implements ChatInputInteractionLike {
guildId: string | null = "guild"; guildId: string | null = "guild";
memberPermissions: { has(permission: bigint): boolean } | null = null; memberPermissions: { has(permission: bigint): boolean } | null = null;
replies: Reply[] = []; replies: Reply[] = [];
deferred = false;
replied = false;
constructor(private readonly subcommand: string, private readonly values: Record<string, unknown> = {}) {} 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> { async reply(reply: Reply): Promise<void> {
this.replies.push(reply); 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 = { options = {
@@ -55,13 +66,14 @@ class FakeService implements BoosterRoleCommandService {
} }
describe("handleInteraction", () => { 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 interaction = new FakeInteraction("claim", { name: "Test", color: "#AABBCC" });
const service = new FakeService(); const service = new FakeService();
await handleInteraction(interaction, service, { isBoosting: async () => true }); await handleInteraction(interaction, service, { isBoosting: async () => true });
expect(service.calls).toEqual(["claim"]); expect(service.calls).toEqual(["claim"]);
expect(interaction.deferred).toBe(true);
expect(interaction.replies[0]).toEqual({ content: "Booster role created: <@&role-1>", flags: MessageFlags.Ephemeral }); 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 }); 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 interaction = new FakeInteraction("claim", { name: "VIP" });
const service = new FakeService(); const service = new FakeService();
service.claimRole = async () => { service.claimRole = async () => {
@@ -118,10 +130,12 @@ describe("handleInteraction", () => {
await handleInteraction(interaction, service, { isBoosting: async () => true }); 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 }); 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 interaction = new FakeInteraction("claim", { name: "VIP" });
const service = new FakeService(); const service = new FakeService();
service.claimRole = async () => { service.claimRole = async () => {
@@ -130,6 +144,20 @@ describe("handleInteraction", () => {
await handleInteraction(interaction, service, { isBoosting: async () => true }); 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 }); 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 }; user: { id: string };
memberPermissions: { has(permission: bigint): boolean } | null; memberPermissions: { has(permission: bigint): boolean } | null;
isChatInputCommand(): boolean; isChatInputCommand(): boolean;
deferred: boolean;
replied: boolean;
reply(input: { content: string; flags: MessageFlags.Ephemeral }): Promise<unknown>; reply(input: { content: string; flags: MessageFlags.Ephemeral }): Promise<unknown>;
deferReply(input: { flags: MessageFlags.Ephemeral }): Promise<unknown>;
editReply(input: { content: string }): Promise<unknown>;
options: { options: {
getSubcommand(): string; getSubcommand(): string;
getString(name: string): string | null; getString(name: string): string | null;
@@ -18,9 +22,17 @@ export type ChatInputInteractionLike = {
}; };
export type BoosterRoleCommandService = { 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>; 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>; setRoleIcon(input: { guildId: string; userId: string; icon: RoleIcon }): Promise<void>;
deleteRole(input: { guildId: string; userId: string }): Promise<void>; deleteRole(input: { guildId: string; userId: string }): Promise<void>;
}; };
@@ -41,6 +53,12 @@ export async function handleInteraction(
const userId = interaction.user.id; const userId = interaction.user.id;
const subcommand = interaction.options.getSubcommand(); 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") { 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 role = await service.claimRole({
@@ -48,10 +66,11 @@ export async function handleInteraction(
userId, userId,
name: requireString(interaction, "name"), name: requireString(interaction, "name"),
color: interaction.options.getString("color"), color: interaction.options.getString("color"),
color2: interaction.options.getString("color2"),
icon: optionalIcon(interaction, "icon"), icon: optionalIcon(interaction, "icon"),
isBoosting: await deps.isBoosting(guildId, userId) 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; return;
} }
@@ -64,7 +83,7 @@ export async function handleInteraction(
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") }); 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 }); await interaction.reply({ content: "Booster role color updated.", flags: MessageFlags.Ephemeral });
return; return;
} }
@@ -72,7 +91,7 @@ export async function handleInteraction(
if (subcommand === "icon") { if (subcommand === "icon") {
logger.info("Handling booster-role command", { guildId, userId, subcommand }); logger.info("Handling booster-role command", { guildId, userId, subcommand });
await service.setRoleIcon({ guildId, userId, icon: requireIcon(interaction, "image") }); 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; return;
} }
@@ -95,7 +114,12 @@ 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 }); 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 });
}
} }
} }
+2 -2
View File
@@ -39,13 +39,13 @@ class FakeRoleRepository implements RoleRepository {
return [...this.roles.values()]; 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}`; const id = `created-${this.roles.size + 1}`;
this.roles.set(id, { id, ...input }); this.roles.set(id, { id, ...input });
return { id }; 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); const role = this.roles.get(roleId);
if (!role) throw new Error("Role does not exist"); if (!role) throw new Error("Role does not exist");
this.roles.set(roleId, { ...role, ...input }); this.roles.set(roleId, { ...role, ...input });
+24 -7
View File
@@ -3,6 +3,7 @@ import {
assertRoleNameIsAvailable, assertRoleNameIsAvailable,
assertRolePositionIsSafe, assertRolePositionIsSafe,
normalizeHexColor, normalizeHexColor,
normalizeOptionalHexColor,
validateRoleName, validateRoleName,
type ExistingRole type ExistingRole
} from "../domain/roleGuards"; } from "../domain/roleGuards";
@@ -13,6 +14,7 @@ export type BoosterRoleRecord = {
roleId: string; roleId: string;
name: string; name: string;
color: string | null; color: string | null;
color2: string | null;
icon: string | null; icon: string | null;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
@@ -26,8 +28,8 @@ export type BoosterRoleStore = {
export type RoleRepository = { export type RoleRepository = {
listRoles(): Promise<ExistingRole[]>; listRoles(): Promise<ExistingRole[]>;
createRole(input: { name: string; color: string | null; permissions: string[]; position: number }): Promise<{ id: string }>; 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; icon?: string | null }): Promise<void>; updateRole(roleId: string, input: { name?: string; color?: string | null; colors?: { primaryColor: string; secondaryColor?: string; tertiaryColor?: string } | null; icon?: string | null }): Promise<void>;
assignRole(userId: string, roleId: string): Promise<void>; assignRole(userId: string, roleId: string): Promise<void>;
removeRole(userId: string, roleId: string): Promise<void>; removeRole(userId: string, roleId: string): Promise<void>;
deleteRole(roleId: string): Promise<void>; deleteRole(roleId: string): Promise<void>;
@@ -61,6 +63,7 @@ export class BoosterRoleService {
userId: string; userId: string;
name: string; name: string;
color: string | null; color: string | null;
color2?: string | null;
icon?: RoleIcon | null; icon?: RoleIcon | null;
isBoosting: boolean; isBoosting: boolean;
}): Promise<BoosterRoleRecord> { }): Promise<BoosterRoleRecord> {
@@ -73,13 +76,15 @@ export class BoosterRoleService {
} }
const name = validateRoleName(input.name); 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()); assertRoleNameIsAvailable(name, await this.roles.listRoles());
const position = this.options.anchorPosition - 1; const position = this.options.anchorPosition - 1;
assertRolePositionIsSafe(position, this.options.anchorPosition); 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; let assigned = false;
try { try {
@@ -98,6 +103,7 @@ export class BoosterRoleService {
roleId: role.id, roleId: role.id,
name, name,
color, color,
color2,
icon: input.icon?.dataUri ?? null, icon: input.icon?.dataUri ?? null,
createdAt: timestamp, createdAt: timestamp,
updatedAt: timestamp updatedAt: timestamp
@@ -119,10 +125,13 @@ export class BoosterRoleService {
await this.roles.updateRole(record.roleId, { name }); await this.roles.updateRole(record.roleId, { name });
} }
async recolorRole(input: { guildId: string; userId: string; color: string }): Promise<void> { async recolorRole(input: { guildId: string; userId: string; color: string; color2?: string | null }): Promise<void> {
const { guildId, userId, color } = input; const { guildId, userId, color, color2 } = input;
const record = await this.getUserRecord(guildId, userId); 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<void> { async setRoleIcon(input: { guildId: string; userId: string; icon: RoleIcon }): Promise<void> {
@@ -182,3 +191,11 @@ async function ignoreRollbackError(action: () => Promise<void>): Promise<void> {
} catch { } 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 };
}
+6 -9
View File
@@ -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"; import type { RoleRepository } from "./boosterRoleService";
export class DiscordRoleRepository implements RoleRepository { 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 })); 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({ const role = await this.guild.roles.create({
name: input.name, name: input.name,
colors: toDiscordColors(input.color), colors: colors as RoleColorsResolvable | undefined,
permissions: 0n permissions: 0n
}); });
@@ -22,11 +23,11 @@ export class DiscordRoleRepository implements RoleRepository {
return { id: role.id }; return { id: role.id };
} }
async updateRole(roleId: string, input: { name?: string; color?: string | null; icon?: string | null }): Promise<void> { async updateRole(roleId: string, input: { name?: string; color?: string | null; colors?: { primaryColor: string; secondaryColor?: string; tertiaryColor?: string } | null; icon?: string | null }): Promise<void> {
const role = await this.fetchRole(roleId); const role = await this.fetchRole(roleId);
await role.edit({ await role.edit({
name: input.name, 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 icon: input.icon === undefined ? undefined : input.icon
}); });
} }
@@ -56,7 +57,3 @@ export class DiscordRoleRepository implements RoleRepository {
return role; return role;
} }
} }
function toDiscordColors(color: string | null): { primaryColor: ColorResolvable } | undefined {
return color === null ? undefined : { primaryColor: color as ColorResolvable };
}