feat: migrate from SQLite to PostgreSQL and update database handling
This commit is contained in:
+4
-5
@@ -1,16 +1,15 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import type { Client } from "discord.js";
|
||||
import type { AppConfig } from "../config";
|
||||
import { prepareSqlitePath } from "../db/sqlitePath";
|
||||
import { createDb } from "../db";
|
||||
import { BoosterRoleService } from "../services/boosterRoleService";
|
||||
import { BunSqliteBoosterRoleStore } from "../services/bunSqliteBoosterRoleStore";
|
||||
import { DrizzleBoosterRoleStore } from "../services/drizzleBoosterRoleStore";
|
||||
import { DiscordRoleRepository } from "../services/discordRoleRepository";
|
||||
import { handleGuildMemberUpdate } from "./events/guildMemberUpdate";
|
||||
import { handleInteraction } from "./interactionHandler";
|
||||
|
||||
export function attachBotHandlers(client: Client, config: AppConfig): void {
|
||||
const db = new Database(prepareSqlitePath(config.databaseUrl));
|
||||
const store = new BunSqliteBoosterRoleStore(db);
|
||||
const db = createDb(config.databaseUrl);
|
||||
const store = new DrizzleBoosterRoleStore(db);
|
||||
|
||||
client.on("interactionCreate", async (interaction) => {
|
||||
if (!interaction.isChatInputCommand() || !interaction.guild) return;
|
||||
|
||||
@@ -29,4 +29,10 @@ export const boosterRoleCommand = new SlashCommandBuilder()
|
||||
.setDescription("Set an optional icon on your bot-managed booster role")
|
||||
.addAttachmentOption((option) => option.setName("image").setDescription("Role icon image").setRequired(true))
|
||||
)
|
||||
.addSubcommand((command) => command.setName("delete").setDescription("Delete your bot-managed booster role"));
|
||||
.addSubcommand((command) => command.setName("delete").setDescription("Delete your bot-managed booster role"))
|
||||
.addSubcommand((command) =>
|
||||
command
|
||||
.setName("admin-delete")
|
||||
.setDescription("Delete another user's bot-managed booster role")
|
||||
.addUserOption((option) => option.setName("user").setDescription("User whose booster role should be deleted").setRequired(true))
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MessageFlags } from "discord.js";
|
||||
import { MessageFlags, PermissionFlagsBits } from "discord.js";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { handleInteraction, type BoosterRoleCommandService, type ChatInputInteractionLike } from "./interactionHandler";
|
||||
|
||||
@@ -8,6 +8,7 @@ class FakeInteraction implements ChatInputInteractionLike {
|
||||
commandName = "booster-role";
|
||||
user = { id: "user" };
|
||||
guildId: string | null = "guild";
|
||||
memberPermissions: { has(permission: bigint): boolean } | null = null;
|
||||
replies: Reply[] = [];
|
||||
|
||||
constructor(private readonly subcommand: string, private readonly values: Record<string, unknown> = {}) {}
|
||||
@@ -23,6 +24,7 @@ class FakeInteraction implements ChatInputInteractionLike {
|
||||
options = {
|
||||
getSubcommand: () => this.subcommand,
|
||||
getString: (name: string) => (this.values[name] as string | null) ?? null,
|
||||
getUser: (name: string) => (this.values[name] as { id: string } | null) ?? null,
|
||||
getAttachment: (name: string) => (this.values[name] as { contentType: string | null; size: number; url: string } | null) ?? null
|
||||
};
|
||||
}
|
||||
@@ -47,8 +49,8 @@ class FakeService implements BoosterRoleCommandService {
|
||||
this.calls.push("icon");
|
||||
}
|
||||
|
||||
async deleteRole(): Promise<void> {
|
||||
this.calls.push("delete");
|
||||
async deleteRole(input?: { userId: string }): Promise<void> {
|
||||
this.calls.push(input?.userId ? `delete:${input.userId}` : "delete");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +66,7 @@ describe("handleInteraction", () => {
|
||||
});
|
||||
|
||||
test("routes update subcommands and replies", async () => {
|
||||
for (const subcommand of ["rename", "recolor", "icon", "delete"]) {
|
||||
for (const subcommand of ["rename", "recolor", "icon"]) {
|
||||
const interaction = new FakeInteraction(subcommand, { name: "Test", color: "#AABBCC", image: { contentType: "image/png", size: 100, url: "https://cdn.discordapp.com/icon.png" } });
|
||||
const service = new FakeService();
|
||||
|
||||
@@ -75,6 +77,38 @@ describe("handleInteraction", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("routes own delete command", async () => {
|
||||
const interaction = new FakeInteraction("delete");
|
||||
const service = new FakeService();
|
||||
|
||||
await handleInteraction(interaction, service, { isBoosting: async () => true });
|
||||
|
||||
expect(service.calls).toEqual(["delete:user"]);
|
||||
expect(interaction.replies[0]).toEqual({ content: "Booster role deleted.", flags: MessageFlags.Ephemeral });
|
||||
});
|
||||
|
||||
test("routes admin delete command for target user", async () => {
|
||||
const interaction = new FakeInteraction("admin-delete", { user: { id: "target-user" } });
|
||||
interaction.memberPermissions = { has: (permission) => permission === PermissionFlagsBits.Administrator };
|
||||
const service = new FakeService();
|
||||
|
||||
await handleInteraction(interaction, service, { isBoosting: async () => true });
|
||||
|
||||
expect(service.calls).toEqual(["delete:target-user"]);
|
||||
expect(interaction.replies[0]).toEqual({ content: "Booster role deleted by admin.", flags: MessageFlags.Ephemeral });
|
||||
});
|
||||
|
||||
test("rejects admin delete without Administrator permission", async () => {
|
||||
const interaction = new FakeInteraction("admin-delete", { user: { id: "target-user" } });
|
||||
interaction.memberPermissions = { has: () => false };
|
||||
const service = new FakeService();
|
||||
|
||||
await handleInteraction(interaction, service, { isBoosting: async () => true });
|
||||
|
||||
expect(service.calls).toEqual([]);
|
||||
expect(interaction.replies[0]).toEqual({ content: "Administrator permission is required", flags: MessageFlags.Ephemeral });
|
||||
});
|
||||
|
||||
test("turns service errors into private replies", async () => {
|
||||
const interaction = new FakeInteraction("claim", { name: "VIP" });
|
||||
const service = new FakeService();
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { MessageFlags } from "discord.js";
|
||||
import { MessageFlags, PermissionFlagsBits } from "discord.js";
|
||||
import type { BoosterRoleRecord, RoleIcon } from "../services/boosterRoleService";
|
||||
|
||||
export type ChatInputInteractionLike = {
|
||||
commandName: string;
|
||||
guildId: string | null;
|
||||
user: { id: string };
|
||||
memberPermissions: { has(permission: bigint): boolean } | null;
|
||||
isChatInputCommand(): boolean;
|
||||
reply(input: { content: string; flags: MessageFlags.Ephemeral }): Promise<unknown>;
|
||||
options: {
|
||||
getSubcommand(): string;
|
||||
getString(name: string): string | null;
|
||||
getUser(name: string): { id: string } | null;
|
||||
getAttachment(name: string): { contentType: string | null; size: number; url: string } | null;
|
||||
};
|
||||
};
|
||||
@@ -75,6 +77,13 @@ export async function handleInteraction(
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "admin-delete") {
|
||||
requireAdministrator(interaction);
|
||||
await service.deleteRole({ guildId, userId: requireUser(interaction, "user").id });
|
||||
await interaction.reply({ content: "Booster role deleted by admin.", flags: MessageFlags.Ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Unknown booster-role subcommand");
|
||||
} catch (error) {
|
||||
await interaction.reply({ content: error instanceof Error ? error.message : "Command failed", flags: MessageFlags.Ephemeral });
|
||||
@@ -92,6 +101,18 @@ function requireString(interaction: ChatInputInteractionLike, name: string): str
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireUser(interaction: ChatInputInteractionLike, name: string): { id: string } {
|
||||
const value = interaction.options.getUser(name);
|
||||
if (!value) throw new Error(`${name} is required`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireAdministrator(interaction: ChatInputInteractionLike): void {
|
||||
if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) {
|
||||
throw new Error("Administrator permission is required");
|
||||
}
|
||||
}
|
||||
|
||||
function requireIcon(interaction: ChatInputInteractionLike, name: string): RoleIcon {
|
||||
const icon = optionalIcon(interaction, name);
|
||||
if (!icon) throw new Error("Role icon image is required");
|
||||
|
||||
@@ -20,7 +20,11 @@ describe("registerGuildCommands", () => {
|
||||
|
||||
expect(rest.route).toBe("/applications/client/guilds/guild/commands");
|
||||
expect(rest.body).toEqual([
|
||||
expect.objectContaining({ name: "booster-role", description: "Manage your cosmetic booster role" })
|
||||
expect.objectContaining({
|
||||
name: "booster-role",
|
||||
description: "Manage your cosmetic booster role",
|
||||
options: expect.arrayContaining([expect.objectContaining({ name: "admin-delete" })])
|
||||
})
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user