feat: add color2 gradient color support and defer interactions for slow commands
This commit is contained in:
@@ -0,0 +1 @@
|
||||
ALTER TABLE "booster_roles" ADD COLUMN "color2" text;
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,8 +114,13 @@ export async function handleInteraction(
|
||||
throw new Error("Unknown booster-role subcommand");
|
||||
} catch (error) {
|
||||
logger.warn("Booster-role command failed", { error });
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toUserErrorMessage(error: unknown): string {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<ExistingRole[]>;
|
||||
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<void>;
|
||||
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<void>;
|
||||
assignRole(userId: string, roleId: string): Promise<void>;
|
||||
removeRole(userId: string, roleId: string): Promise<void>;
|
||||
deleteRole(roleId: string): Promise<void>;
|
||||
@@ -61,6 +63,7 @@ export class BoosterRoleService {
|
||||
userId: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
color2?: string | null;
|
||||
icon?: RoleIcon | null;
|
||||
isBoosting: boolean;
|
||||
}): Promise<BoosterRoleRecord> {
|
||||
@@ -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<void> {
|
||||
const { guildId, userId, color } = input;
|
||||
async recolorRole(input: { guildId: string; userId: string; color: string; color2?: string | null }): Promise<void> {
|
||||
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<void> {
|
||||
@@ -182,3 +191,11 @@ async function ignoreRollbackError(action: () => Promise<void>): Promise<void> {
|
||||
} 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 };
|
||||
}
|
||||
|
||||
@@ -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<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);
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user