all repos — stealth-developers @ e9cebecae99c7f4cd8ba9e4c42d7b1fb84782a53

bot!: refactor to use @purrkit/router
vi did:web:vt3e.cat
Mon, 22 Jun 2026 01:13:56 +0100
commit

e9cebecae99c7f4cd8ba9e4c42d7b1fb84782a53

parent

c44d4e7df8b225bf9d3c614395ffc7870cd96075

M apps/bot/package.jsonapps/bot/package.json

@@ -3,10 +3,11 @@ "name": "@stealth-developers/bot",

"version": "1.0.0", "type": "module", "scripts": { - "sapphire": "sapphire", - "generate": "sapphire generate" + "dev": "bun src/index.ts | pino-pretty", + "run": "bun src/index.ts" }, "dependencies": { + "@purrkit/router": "^1.1.0", "@sapphire/decorators": "^6.1.1", "@sapphire/discord.js-utilities": "7.3.2", "@sapphire/framework": "^5.3.2",
A apps/bot/src/client.ts

@@ -0,0 +1,16 @@

+import { Client, GatewayIntentBits } from "discord.js"; +import { Kitten } from "@purrkit/router"; +import { KLogger, logger } from "$/logger"; + +export const client = new Client({ + intents: [ + GatewayIntentBits.DirectMessages, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.Guilds, + GatewayIntentBits.MessageContent, + ], +}); + +export const kitten = new Kitten(client, { + logger: new KLogger(), +});
M apps/bot/src/commands/index.tsapps/bot/src/commands/index.ts

@@ -1,2 +1,7 @@

-export * from "./meow.ts"; -export * from "./tickets"; +import type { Command, SubcommandGroupBuilder } from "@purrkit/router"; + +import ticketCommand from "./tickets/command"; +import meowCommand from "./meow"; + +export const commands: (SubcommandGroupBuilder | Command)[] = [ticketCommand, meowCommand]; +export default commands;
M apps/bot/src/commands/meow.tsapps/bot/src/commands/meow.ts

@@ -1,31 +1,8 @@

-import { ApplyOptions } from "@sapphire/decorators"; -import { Command, container } from "@sapphire/framework"; -import { ApplicationIntegrationType, InteractionContextType } from "discord.js"; +import { kitten } from "@/client"; -@ApplyOptions<Command.Options>({ +const meowCommand = kitten.command("meow", { description: "mrrrp mew", -}) -export class MeowCommand extends Command { - public override registerApplicationCommands(registry: Command.Registry) { - const integrationTypes: ApplicationIntegrationType[] = [ - ApplicationIntegrationType.GuildInstall, - ApplicationIntegrationType.UserInstall, - ]; - const contexts: InteractionContextType[] = [ - InteractionContextType.BotDM, - InteractionContextType.Guild, - InteractionContextType.PrivateChannel, - ]; - - registry.registerChatInputCommand({ - name: this.name, - description: this.description, - integrationTypes, - contexts, - }); - } - - public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) { + async run(interaction) { const meowVariants = [ "meow", "mew",

@@ -117,12 +94,8 @@ const content =

words.join(" ") + (Math.random() > 0.4 ? ` ${emoticons[Math.floor(Math.random() * emoticons.length)]}` : ""); - return interaction.reply({ content }); - } -} - -container.stores.loadPiece({ - piece: MeowCommand, - name: "meow", - store: "commands", + interaction.reply({ content }); + }, }); + +export default meowCommand;
M apps/bot/src/commands/tickets/actions.tsapps/bot/src/commands/tickets/actions.ts

@@ -1,40 +1,47 @@

import { randomUUID } from "node:crypto"; -import { TextChannel, ChannelType } from "discord.js"; +import { TextChannel, ChannelType, type Guild } from "discord.js"; import { container, Result } from "@sapphire/framework"; -import type { Subcommand } from "@sapphire/plugin-subcommands"; -import db, { desc, eq, tickets } from "@stealth-developers/db"; -import config from "@stealth-developers/config"; +import db, { desc, eq, getGuildByDiscordId, tickets } from "@stealth-developers/db"; export * from "./config"; -import { errorMessage, upsertUser, getCategory, PublicError } from "@/lib"; +import { upsertUser, getCategory, PublicError } from "@/lib"; +import type { AnyUser } from "@/types"; -export async function createTicket(interaction: Subcommand.ChatInputCommandInteraction) { - if (!interaction.guild) - return errorMessage(interaction, "Failed to create ticket: Guild not found"); +type CreateTicketArgs = + | undefined + | { + for: AnyUser; + reason?: string; + }; - const isOnBehalf = interaction.options.getSubcommand(true) === "for"; +export async function createTicket( + opener: AnyUser, + guild: Guild, + args?: CreateTicketArgs, +): Promise<{ channelId: string; publicId: string }> { + const isOnBehalf = !!args?.for; const publicId = randomUUID(); - if (!interaction.member) - return errorMessage(interaction, "Failed to create ticket: Member not found"); + const dbGuild = await getGuildByDiscordId(guild.id); + if (!dbGuild) throw new PublicError("Failed to create ticket: Guild not found in database"); - const creator = await upsertUser(interaction.member, interaction.guild); - if (!creator) return errorMessage(interaction, "Failed to create ticket: Creator not found"); + const creator = await upsertUser(opener, guild); + if (!creator) throw new PublicError("Failed to create ticket: Creator user not found"); - const subject = isOnBehalf ? interaction.options.getUser("user") : interaction.user; - const subjectUser = subject ? await upsertUser(subject, interaction.guild!) : creator; - if (!subjectUser) - return errorMessage(interaction, "Failed to create ticket: Subject user not found"); + const subject = isOnBehalf ? args?.for : opener; + const subjectUser = subject ? await upsertUser(subject, guild) : creator; + if (!subjectUser) throw new PublicError("Failed to create ticket: Subject user not found"); if (isOnBehalf && !creator.moderator) - return errorMessage( - interaction, - "You don't have permission to create a ticket for another user.", + throw new PublicError("You don't have permission to create a ticket for another user."); + + if (!dbGuild.ticketCategory) + throw new PublicError( + "Ticket category is not set up in this guild. Please contact an administrator.", ); - - const ticketCategory = await getCategory(config.discord.server.ticket_category); + const ticketCategory = await getCategory(dbGuild.ticketCategory); let createdChannel: TextChannel | undefined = undefined; const txResult = await Result.fromAsync(async () => {

@@ -61,12 +68,12 @@ subject: subjectUser.id,

}) .returning(); - if (!ticket) throw new PublicError("failed to add ticket to database."); + if (!ticket) throw new PublicError("failed to add ticket to database, please try again."); const ticketNumber = ticket.localId; const paddedTicketNumber = String(ticketNumber).padStart(4, "0"); - const channel = await interaction.guild?.channels.create({ + const channel = await guild.channels.create({ name: `ticket-${paddedTicketNumber}`, type: ChannelType.GuildText, parent: ticketCategory,

@@ -93,12 +100,9 @@ : "There was an error creating the ticket.";

if (error instanceof Error) container.logger.error(error.message, error.stack); - return errorMessage(interaction, message); + throw new PublicError(message); } const channelId = txResult.unwrap(); - interaction.reply({ - content: `Ticket created! See <#${channelId}>`, - flags: ["Ephemeral"], - }); + return { channelId, publicId }; }
A apps/bot/src/commands/tickets/command.ts

@@ -0,0 +1,195 @@

+import { kitten } from "@/client"; +import { option } from "@purrkit/router"; +import { Result } from "@sapphire/result"; + +import * as actions from "./actions"; +import { errorMessage, getTextChannel, PublicError } from "@/lib"; +import { getGuildByDiscordId } from "@stealth-developers/db"; +import { newTicketButton } from "@/components"; +import { + ButtonBuilder, + ButtonStyle, + ContainerBuilder, + TextDisplayBuilder, + ActionRowBuilder, +} from "discord.js"; + +const ticketCommand = kitten.command("ticket", { + description: "Ticket-related commands", +}); + +ticketCommand.subcommand("new", { + description: "Open a new ticket.", + async run(interaction) { + if (!interaction.guild) + return errorMessage(interaction, "You must run this command in a guild"); + + const func = actions.createTicket(interaction.member ?? interaction.user, interaction.guild); + + const ticketResult = await Result.fromAsync(async () => func); + if (ticketResult.isErr()) { + const error = ticketResult.unwrapErr(); + + if (error instanceof PublicError) { + return errorMessage(interaction, error.message); + } else { + console.error(error); + return errorMessage(interaction, "An unexpected error occurred while creating the ticket."); + } + } + + const { channelId } = ticketResult.unwrap(); + return interaction.reply({ + content: `Opened ticket <#${channelId}>.`, + flags: ["Ephemeral"], + }); + }, +}); + +ticketCommand.subcommand("for", { + description: "Open a new ticket on behalf of someone else", + options: { + user: option.user("The user to open the ticket for", { required: true }), + reason: option.string("The reason for opening the ticket"), + }, + async run(interaction, args) { + if (!interaction.guild) + return errorMessage(interaction, "You must run this command in a guild"); + + const func = actions.createTicket(interaction.member ?? interaction.user, interaction.guild, { + for: args.user, + reason: args.reason, + }); + + const ticketResult = await Result.fromAsync(async () => func); + if (ticketResult.isErr()) { + const error = ticketResult.unwrapErr(); + + if (error instanceof PublicError) { + return errorMessage(interaction, error.message); + } else { + console.error(error); + return errorMessage(interaction, "An unexpected error occurred while creating the ticket."); + } + } + + const { channelId } = ticketResult.unwrap(); + return interaction.reply({ + content: `Opened ticket <#${channelId}>.`, + flags: ["Ephemeral"], + }); + }, +}); + +ticketCommand.group( + "manage", + { + description: "Manage ticket settings", + }, + (group) => { + // channels & categories + group.subcommand("transcript-channel", { + description: "Set the channel where transcripts will be sent", + options: { + channel: option.channel("The channel to send transcripts to", { required: true }), + }, + async run(interaction) { + await actions.manageConfig(interaction); + }, + }); + + group.subcommand("entrypoint-channel", { + description: "Set the channel where the ticket prompt will be sent", + options: { + channel: option.channel("The channel to send the ticket prompt to", { required: true }), + }, + async run(interaction) { + await actions.manageConfig(interaction); + }, + }); + + group.subcommand("category", { + description: "Sets the category for new tickets", + options: { + category: option.channel("The category to create new tickets in", { required: true }), + }, + async run(interaction) { + await actions.manageConfig(interaction); + }, + }); + + // text + group.subcommand("prompt", { + description: "Set the message sent in the entry point channel", + async run(interaction) { + await actions.manageConfig(interaction); + }, + }); + + group.subcommand("send-prompt", { + description: "Send the prompt to the entry point channel", + async run(interaction) { + if (!interaction.guild) + return errorMessage(interaction, "You must run this command in a guild"); + + const guild = await getGuildByDiscordId(interaction.guild.id); + if (!guild) return errorMessage(interaction, "Failed to send prompt: Guild not found."); + + const commandString = (sub: string) => `</ticket manage ${sub}:${interaction.commandId}>`; + const channelId = guild.ticketPromptChannel; + const promptText = guild.ticketPrompt; + + if (!channelId) + return errorMessage( + interaction, + `Prompt chnanel not configured. Use ${commandString("entrypoint-channel")} to set it up.`, + ); + + if (!promptText) + return errorMessage( + interaction, + `Ticket prompt not configured. Use ${commandString("prompt")} to set it up.`, + ); + + const channelRes = await Result.fromAsync(() => getTextChannel(channelId)); + if (channelRes.isErr()) { + const err = channelRes.unwrapErr(); + const errorMessageText = err instanceof Error ? err.message : "Unknown error"; + return errorMessage(interaction, `Failed to fetch prompt channel: ${errorMessageText}`); + } + + const channel = channelRes.unwrap(); + if (!channel.isSendable()) { + return errorMessage( + interaction, + "I don't have permission to send messages in the prompt channel.", + ); + } + + const button = new ButtonBuilder() + .setLabel("Open a Ticket") + .setStyle(ButtonStyle.Success) + .setCustomId(newTicketButton.id()); + + const container = new ContainerBuilder() + .addTextDisplayComponents(new TextDisplayBuilder().setContent(promptText)) + .addActionRowComponents(new ActionRowBuilder<ButtonBuilder>().addComponents(button)); + + await channel.send({ flags: ["IsComponentsV2"], components: [container] }); + return interaction.reply({ + content: "Prompt sent!", + flags: ["Ephemeral"], + }); + }, + }); + + group.subcommand("greeting", { + description: "Set the message sent in new tickets", + async run(interaction) { + await actions.manageConfig(interaction); + }, + }); + }, +); + +export default ticketCommand;
M apps/bot/src/commands/tickets/config.tsapps/bot/src/commands/tickets/config.ts

@@ -3,7 +3,7 @@ import type { Subcommand } from "@sapphire/plugin-subcommands";

import db, { eq, getGuildByDiscordId, guilds } from "@stealth-developers/db"; import { errorMessage } from "@/lib"; -import { ConfigTextModal } from "@/components/ConfigModal"; +import { configTextModal } from "@/components/modals/configModal"; type GuildKeys = keyof typeof guilds.$inferSelect; type ConfigOptionBase = {

@@ -18,7 +18,7 @@ type ConfigOption = ChannelConfigOption | CategoryConfigOption | TextConfigOption;

const configOptions = { category: { type: "category", key: "ticketCategory" }, - transcripts: { type: "channel", key: "ticketLogChannel" }, + "transcript-channel": { type: "channel", key: "ticketLogChannel" }, "entrypoint-channel": { type: "channel", key: "ticketPromptChannel" }, prompt: { type: "text",

@@ -53,7 +53,7 @@

if (option.type === "text") { const targetKey = option.key; - const customId = ConfigTextModal.create(targetKey); + const customId = configTextModal.id({ key: targetKey }); const currentValue = guild[targetKey] ?? ""; const modal = new ModalBuilder().setCustomId(customId).setTitle(option.title);
M apps/bot/src/commands/tickets/index.tsapps/bot/src/commands/tickets/_index.ts

@@ -14,7 +14,7 @@ import { getGuildByDiscordId } from "@stealth-developers/db";

import { errorMessage, getTextChannel } from "@/lib"; import { Result } from "@sapphire/framework"; import { ActionRowBuilder } from "discord.js"; -import { NewTicketButton } from "@/components/NewTicketButton"; +import { newTicketButton } from "@/components/NewTicketButton"; @ApplyOptions<Subcommand.Options>({ description: "ticket-related commands",

@@ -246,7 +246,7 @@

const button = new ButtonBuilder() .setLabel("Open a Ticket") .setStyle(ButtonStyle.Success) - .setCustomId(NewTicketButton.create()); + .setCustomId(newTicketButton.id()); const container = new ContainerBuilder() .addTextDisplayComponents(new TextDisplayBuilder().setContent(promptText))
D apps/bot/src/components/ConfigModal.ts

@@ -1,33 +0,0 @@

-import { errorMessage } from "@/lib"; -import { modalRegistry } from "@/lib/registries"; -import { ComponentDefinition } from "@/lib/structures/ComponentDefinition"; -import db, { eq, getGuildByDiscordId, guilds } from "@stealth-developers/db"; -import { MessageFlags } from "discord.js"; - -export const ConfigTextModal = new ComponentDefinition< - "config-text", - [key: "ticketPrompt" | "ticketGreeting"] ->("config-text"); - -modalRegistry.register(ConfigTextModal, async (interaction, [key]) => { - const value = interaction.fields.getTextInputValue("text-value"); - - if (!interaction.guildId) - return errorMessage(interaction, "This interaction can only be used in a server."); - - const guild = await getGuildByDiscordId(interaction.guildId); - if (!guild) return errorMessage(interaction, "Failed to update configuration: Guild not found."); - - const [res] = await db - .update(guilds) - .set({ [key]: value }) - .where(eq(guilds.id, guild.id)) - .returning(); - - if (!res) return errorMessage(interaction, "Failed to update configuration."); - - return interaction.reply({ - content: "Configuration updated successfully!", - flags: [MessageFlags.Ephemeral], - }); -});
D apps/bot/src/components/NewTicketButton.ts

@@ -1,17 +0,0 @@

-import { getGuildByDiscordId } from "@stealth-developers/db"; - -import { errorMessage } from "@/lib"; -import { buttonRegistry } from "@/lib/registries"; -import { ComponentDefinition } from "@/lib/structures/ComponentDefinition"; - -export const NewTicketButton = new ComponentDefinition<"open-ticket", []>("open-ticket"); - -buttonRegistry.register(NewTicketButton, async (interaction) => { - if (!interaction.guildId) - return errorMessage(interaction, "This interaction can only be used in a server."); - - const guild = await getGuildByDiscordId(interaction.guildId); - if (!guild) return errorMessage(interaction, "Failed to update configuration: Guild not found."); - - await interaction.reply("ok"); -});
A apps/bot/src/components/buttons/newTicket.ts

@@ -0,0 +1,32 @@

+import { Result } from "@sapphire/result"; + +import * as actions from "@/commands/tickets/actions"; +import { errorMessage, PublicError } from "@/lib"; +import { kitten } from "@/client"; + +export const newTicketButton = kitten.button("open-ticket", { + run: async (interaction) => { + if (!interaction.guild) + return errorMessage(interaction, "You must run this command in a guild"); + + const func = actions.createTicket(interaction.member ?? interaction.user, interaction.guild); + + const ticketResult = await Result.fromAsync(async () => func); + if (ticketResult.isErr()) { + const error = ticketResult.unwrapErr(); + + if (error instanceof PublicError) { + return errorMessage(interaction, error.message); + } else { + console.error(error); + return errorMessage(interaction, "An unexpected error occurred while creating the ticket."); + } + } + + const { channelId } = ticketResult.unwrap(); + return interaction.reply({ + content: `Opened ticket <#${channelId}>.`, + flags: ["Ephemeral"], + }); + }, +});
M apps/bot/src/components/index.tsapps/bot/src/components/index.ts

@@ -1,2 +1,2 @@

-export * from "./ConfigModal"; -export * from "./NewTicketButton"; +export { configTextModal } from "./modals/configModal"; +export { newTicketButton } from "./buttons/newTicket";
A apps/bot/src/components/modals/configModal.ts

@@ -0,0 +1,35 @@

+import { MessageFlags } from "discord.js"; +import { option } from "@purrkit/router"; + +import db, { eq, getGuildByDiscordId, guilds } from "@stealth-developers/db"; +import { kitten } from "@/client"; +import { errorMessage } from "@/lib"; + +export const configTextModal = kitten.modal("config-text", { + options: { + key: option.string({ required: true }), + }, + run: async (interaction, { key }) => { + const value = interaction.fields.getTextInputValue("text-value"); + + if (!interaction.guildId) + return errorMessage(interaction, "This interaction can only be used in a server."); + + const guild = await getGuildByDiscordId(interaction.guildId); + if (!guild) + return errorMessage(interaction, "Failed to update configuration: Guild not found."); + + const [res] = await db + .update(guilds) + .set({ [key]: value }) + .where(eq(guilds.id, guild.id)) + .returning(); + + if (!res) return errorMessage(interaction, "Failed to update configuration."); + + return interaction.reply({ + content: "Configuration updated successfully!", + flags: [MessageFlags.Ephemeral], + }); + }, +});
D apps/bot/src/handlers/buttons.ts

@@ -1,43 +0,0 @@

-import { container, InteractionHandler, InteractionHandlerTypes } from "@sapphire/framework"; -import type { ButtonInteraction } from "discord.js"; -import { buttonRegistry } from "../lib/registries"; - -export class ButtonHandler extends InteractionHandler { - public constructor(ctx: InteractionHandler.LoaderContext, options: InteractionHandler.Options) { - super(ctx, { - ...options, - interactionHandlerType: InteractionHandlerTypes.Button, - }); - } - - public override parse(interaction: ButtonInteraction) { - const [id] = interaction.customId.split(":"); - if (!id) return this.none(); - - const registered = buttonRegistry.get(id); - if (!registered) return this.none(); - - const args = registered.definition.parse(interaction.customId); - if (!args) return this.none(); - - return this.some({ registered, args }); - } - - public async run( - interaction: ButtonInteraction, - { registered, args }: { registered: any; args: string[] }, - ) { - console.log("Running button interaction with ID:", registered.definition.id); - try { - await registered.run(interaction, args); - } catch (error) { - this.container.logger.error("Failed to execute button interaction:", error); - } - } -} - -container.stores.loadPiece({ - name: "ButtonHandler", - piece: ButtonHandler, - store: "interaction-handlers", -});
D apps/bot/src/handlers/index.ts

@@ -1,2 +0,0 @@

-export * from "./modals"; -export * from "./buttons";
D apps/bot/src/handlers/modals.ts

@@ -1,43 +0,0 @@

-import { InteractionHandler, InteractionHandlerTypes, container } from "@sapphire/framework"; -import type { ModalSubmitInteraction } from "discord.js"; -import { modalRegistry } from "$/registries"; - -export class ModalHandler extends InteractionHandler { - public constructor(ctx: InteractionHandler.LoaderContext, options: InteractionHandler.Options) { - super(ctx, { - ...options, - interactionHandlerType: InteractionHandlerTypes.ModalSubmit, - }); - } - - public override parse(interaction: ModalSubmitInteraction) { - const [id] = interaction.customId.split(":"); - if (!id) return this.none(); - const registered = modalRegistry.get(id); - - if (!registered) return this.none(); - - const args = registered.definition.parse(interaction.customId); - if (!args) return this.none(); - - return this.some({ registered, args }); - } - - public async run( - interaction: ModalSubmitInteraction, - { registered, args }: { registered: any; args: string[] }, - ) { - console.log("Running modal interaction with ID:", registered.definition.id); - try { - await registered.run(interaction, args); - } catch (error) { - this.container.logger.error("Failed to run modal interaction:", error); - } - } -} - -container.stores.loadPiece({ - name: "ModalHandler", - piece: ModalHandler, - store: "interaction-handlers", -});
M apps/bot/src/index.tsapps/bot/src/index.ts

@@ -1,38 +1,28 @@

-import { GatewayIntentBits } from "discord.js"; -import { LogLevel, SapphireClient } from "@sapphire/framework"; import config from "@stealth-developers/config"; -import { PinoSapphireLogger } from "$/logger"; -import "./preconditions"; -import "./listeners"; -import "./commands"; -import "./handlers"; - -const client = new SapphireClient({ - logger: { - instance: new PinoSapphireLogger( - { level: "debug" }, - config.isProd ? LogLevel.Info : LogLevel.Debug, - ), - }, - intents: [ - GatewayIntentBits.DirectMessages, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.Guilds, - GatewayIntentBits.MessageContent, - ], - baseUserDirectory: null, -}); +import { logger } from "$/logger"; +import { client, kitten } from "./client"; +import { configTextModal, newTicketButton } from "./components"; +import commands from "./commands"; const main = async () => { try { await client.login(config.discord.token); } catch (error) { - client.logger.fatal(error); - await client.destroy(); + logger.error(error); process.exit(1); } }; + +client.once("clientReady", async (client) => { + kitten.register({ + commands: commands, + components: [configTextModal, newTicketButton], + }); + + await kitten.sync(); + logger.info(`logged in as ${client.user.tag}!`); +}); void main();
M apps/bot/src/lib/channels.tsapps/bot/src/lib/channels.ts

@@ -1,8 +1,8 @@

-import { container } from "@sapphire/framework"; +import { client } from "@/client"; import { ChannelType } from "discord.js"; export async function getTextChannel(id: string, mustBeSendable = true) { - const channel = await container.client.channels.fetch(id); + const channel = await client.channels.fetch(id); if (!channel) throw new Error(`Channel not found: ${id}`); if (!channel.isTextBased()) throw new Error(`Channel is not text-based: ${id}`);

@@ -13,7 +13,7 @@ return channel;

} export async function getCategory(id: string) { - const channel = await container.client.channels.fetch(id); + const channel = await client.channels.fetch(id); if (!channel) throw new Error(`Category not found: ${id}`); if (channel.type !== ChannelType.GuildCategory) throw new Error(`Channel is not a category: ${id}`);
M apps/bot/src/lib/db.tsapps/bot/src/lib/db.ts

@@ -1,10 +1,9 @@

-import { User, type APIInteractionGuildMember, GuildMember, Guild } from "discord.js"; +import { User, GuildMember, Guild } from "discord.js"; import { upsertActor } from "@stealth-developers/db"; +import type { AnyUser } from "@/types"; -export async function upsertUser( - input: APIInteractionGuildMember | GuildMember | User, - guild: Guild, -) { +/** adds or updates a user in the database, also adds or updates the guild actor */ +export async function upsertUser(input: AnyUser, guild: Guild) { let discordId: string; let username: string; let displayName: string | null = null;
M apps/bot/src/lib/index.tsapps/bot/src/lib/index.ts

@@ -4,6 +4,4 @@ export * from "./error";

export * from "./logger"; export * from "./permissions"; export * from "./interactions"; -export * from "./setup"; -export * from "./utils"; export * from "./db";
M apps/bot/src/lib/logger.tsapps/bot/src/lib/logger.ts

@@ -1,111 +1,52 @@

-import { type ILogger, LogLevel } from "@sapphire/framework"; import pino, { type Logger as PinoInstance } from "pino"; -import config from "@stealth-developers/config"; + +import { type KittenLogger, type LogLevel } from "@purrkit/router"; + +const isProd = process.env.NODE_ENV === "production"; +const logLevel = process.env.LOG_LEVEL ? process.env.LOG_LEVEL : isProd ? "info" : "debug"; -export class PinoSapphireLogger implements ILogger { - private pino: PinoInstance; - private minLevel: LogLevel; +export const logger = pino({ + base: { + pid: false, + hostname: false, + }, + level: logLevel.toLowerCase(), +}); - constructor(options?: pino.LoggerOptions, minLevel: LogLevel = LogLevel.Info) { - this.minLevel = minLevel; +export class KLogger implements KittenLogger { + constructor(private readonly pinoInstance: PinoInstance = logger) {} - this.pino = pino({ - transport: config.isProd ? undefined : { target: "pino-pretty", options: { colorize: true } }, - base: { - pid: false, - }, - level: "trace", - ...options, - }); + private log(level: LogLevel, msg: string, data?: Record<string, unknown>): void { + if (data) this.pinoInstance[level](data, msg); + else this.pinoInstance[level](msg); } - public has(level: LogLevel): boolean { - return level >= this.minLevel && level !== LogLevel.None; + trace(msg: string, data?: Record<string, unknown>): void { + this.log("trace", msg, data); } - public write(level: LogLevel, ...values: readonly unknown[]): void { - if (!this.has(level)) return; + debug(msg: string, data?: Record<string, unknown>): void { + this.log("debug", msg, data); + } - const { name, msg, obj } = this.extractModuleAndPayload(values); - const payload = { name, ...obj }; + info(msg: string, data?: Record<string, unknown>): void { + this.log("info", msg, data); + } - switch (level) { - case LogLevel.Trace: - this.pino.trace(payload, msg); - break; - case LogLevel.Debug: - this.pino.debug(payload, msg); - break; - case LogLevel.Info: - this.pino.info(payload, msg); - break; - case LogLevel.Warn: - this.pino.warn(payload, msg); - break; - case LogLevel.Error: - this.pino.error(payload, msg); - break; - case LogLevel.Fatal: - this.pino.fatal(payload, msg); - break; - default: - break; - } + warn(msg: string, data?: Record<string, unknown>): void { + this.log("warn", msg, data); } - public trace(...values: readonly unknown[]): void { - this.write(LogLevel.Trace, ...values); - } - public debug(...values: readonly unknown[]): void { - this.write(LogLevel.Debug, ...values); - } - public info(...values: readonly unknown[]): void { - this.write(LogLevel.Info, ...values); - } - public warn(...values: readonly unknown[]): void { - this.write(LogLevel.Warn, ...values); - } - public error(...values: readonly unknown[]): void { - this.write(LogLevel.Error, ...values); - } - public fatal(...values: readonly unknown[]): void { - this.write(LogLevel.Fatal, ...values); + error(msg: string, data?: Record<string, unknown>): void { + this.log("error", msg, data); } - private extractModuleAndPayload(values: readonly unknown[]): { - name: string; - msg: string; - obj?: Record<string, unknown>; - } { - let name = "sapphire"; - let msg = ""; - let err: Error | undefined = undefined; - const extra: unknown[] = []; - const args = [...values]; - - if (args.length > 0 && typeof args[0] === "string") { - const firstArg = args[0]; - const colonIndex = firstArg.indexOf(":"); - - if (colonIndex !== -1) { - name = firstArg.substring(0, colonIndex).trim(); - const remainingMessage = firstArg.substring(colonIndex + 1).trim(); - - if (remainingMessage) args[0] = remainingMessage; - else args.shift(); - } - } - - for (const val of args) { - if (val instanceof Error) err = val; - else if (typeof val === "object" && val !== null) extra.push(val); - else msg += (msg ? " " : "") + String(val); - } - - const obj: Record<string, any> = {}; - if (err) obj.err = err; - if (extra.length > 0) obj.extra = extra; + fatal(msg: string, data?: Record<string, unknown>): void { + this.log("fatal", msg, data); + } - return { name, msg, obj }; + child(bindings: Record<string, unknown>): KittenLogger { + const childPino = this.pinoInstance.child(bindings); + return new KLogger(childPino); } }
D apps/bot/src/lib/registries.ts

@@ -1,10 +0,0 @@

-import type { - ButtonInteraction, - ModalSubmitInteraction, - AnySelectMenuInteraction, -} from "discord.js"; -import { ComponentRegistry } from "./structures/ComponentRegistry"; - -export const buttonRegistry = new ComponentRegistry<ButtonInteraction>(); -export const modalRegistry = new ComponentRegistry<ModalSubmitInteraction>(); -export const selectMenuRegistry = new ComponentRegistry<AnySelectMenuInteraction>();
D apps/bot/src/lib/setup.ts

@@ -1,4 +0,0 @@

-import { ApplicationCommandRegistries, RegisterBehavior } from "@sapphire/framework"; -import "@sapphire/plugin-logger/register"; - -ApplicationCommandRegistries.setDefaultBehaviorWhenNotIdentical(RegisterBehavior.BulkOverwrite);
D apps/bot/src/lib/structures/ComponentDefinition.ts

@@ -1,18 +0,0 @@

-export class ComponentDefinition<Id extends string, Args extends string[]> { - public readonly id: Id; - - public constructor(id: Id) { - this.id = id; - } - - public create(...args: Args): string { - return args.length > 0 ? `${this.id}:${args.join(":")}` : this.id; - } - - public parse(customId: string): Args | null { - const parts = customId.split(":"); - if (parts[0] !== this.id) return null; - - return parts.slice(1) as unknown as Args; - } -}
D apps/bot/src/lib/structures/ComponentRegistry.ts

@@ -1,22 +0,0 @@

-import type { Interaction } from "discord.js"; -import type { ComponentDefinition } from "./ComponentDefinition"; - -export interface RegisteredComponent<Int extends Interaction, Args extends string[]> { - definition: ComponentDefinition<any, Args>; - run(interaction: Int, args: Args): unknown | Promise<unknown>; -} - -export class ComponentRegistry<Int extends Interaction> { - private registry = new Map<string, RegisteredComponent<Int, any>>(); - - public register<Args extends string[]>( - definition: ComponentDefinition<any, Args>, - run: (interaction: Int, args: Args) => unknown | Promise<unknown>, - ) { - this.registry.set(definition.id, { definition, run }); - } - - public get(id: string) { - return this.registry.get(id); - } -}
D apps/bot/src/lib/utils.ts

@@ -1,44 +0,0 @@

-import { - container, - type ChatInputCommandSuccessPayload, - type Command, - type ContextMenuCommandSuccessPayload, - type MessageCommandSuccessPayload, -} from "@sapphire/framework"; -import type { Guild, User } from "discord.js"; - -export function logSuccessCommand( - payload: - | ContextMenuCommandSuccessPayload - | ChatInputCommandSuccessPayload - | MessageCommandSuccessPayload, -): void { - let successLoggerData: ReturnType<typeof getSuccessLoggerData>; - - if ("interaction" in payload) { - successLoggerData = getSuccessLoggerData( - payload.interaction.guild, - payload.interaction.user, - payload.command, - ); - } else { - successLoggerData = getSuccessLoggerData( - payload.message.guild, - payload.message.author, - payload.command, - ); - } - - container.logger.debug( - `${successLoggerData.shard} - ${successLoggerData.commandName} ${successLoggerData.author} ${successLoggerData.sentAt}`, - ); -} - -export function getSuccessLoggerData(guild: Guild | null, user: User, command: Command) { - const shard = guild?.shardId; - const commandName = command.name; - const author = `${user.username}[${user.id}]`; - const sentAt = `${guild?.name}[${guild?.id}]`; - - return { shard, commandName, author, sentAt }; -}
D apps/bot/src/listeners/commands/chatInputCommands/chatInputCommandDenied.ts

@@ -1,32 +0,0 @@

-import type { ChatInputCommandDeniedPayload, Events } from "@sapphire/framework"; -import { container, Listener, UserError } from "@sapphire/framework"; - -export class UserEvent extends Listener<typeof Events.ChatInputCommandDenied> { - public override async run( - { context, message: content }: UserError, - { interaction }: ChatInputCommandDeniedPayload, - ) { - // `context: { silent: true }` should make UserError silent: - // Use cases for this are for example permissions error when running the `eval` command. - if (Reflect.get(Object(context), "silent")) return; - - if (interaction.deferred || interaction.replied) { - return interaction.editReply({ - content, - allowedMentions: { users: [interaction.user.id], roles: [] }, - }); - } - - return interaction.reply({ - content, - allowedMentions: { users: [interaction.user.id], roles: [] }, - flags: ["Ephemeral"], - }); - } -} - -container.stores.loadPiece({ - piece: UserEvent, - name: "chatInputCommandDenied", - store: "listeners", -});
D apps/bot/src/listeners/commands/chatInputCommands/chatInputCommandSuccess.ts

@@ -1,25 +0,0 @@

-import { - container, - Listener, - LogLevel, - type ChatInputCommandSuccessPayload, -} from "@sapphire/framework"; -import type { Logger } from "@sapphire/plugin-logger"; -import { logSuccessCommand } from "$/utils"; - -export class UserListener extends Listener { - public override run(payload: ChatInputCommandSuccessPayload) { - logSuccessCommand(payload); - } - - public override onLoad() { - this.enabled = (this.container.logger as Logger).level <= LogLevel.Debug; - return super.onLoad(); - } -} - -container.stores.loadPiece({ - name: "chatInputCommandSuccess", - piece: UserListener, - store: "listeners", -});
D apps/bot/src/listeners/commands/contextMenuCommands/contextMenuCommandDenied.ts

@@ -1,32 +0,0 @@

-import type { ContextMenuCommandDeniedPayload, Events } from "@sapphire/framework"; -import { container, Listener, UserError } from "@sapphire/framework"; - -export class UserEvent extends Listener<typeof Events.ContextMenuCommandDenied> { - public override async run( - { context, message: content }: UserError, - { interaction }: ContextMenuCommandDeniedPayload, - ) { - // `context: { silent: true }` should make UserError silent: - // Use cases for this are for example permissions error when running the `eval` command. - if (Reflect.get(Object(context), "silent")) return; - - if (interaction.deferred || interaction.replied) { - return interaction.editReply({ - content, - allowedMentions: { users: [interaction.user.id], roles: [] }, - }); - } - - return interaction.reply({ - content, - allowedMentions: { users: [interaction.user.id], roles: [] }, - flags: ["Ephemeral"], - }); - } -} - -container.stores.loadPiece({ - name: "contextMenuCommandDenied", - piece: UserEvent, - store: "listeners", -});
D apps/bot/src/listeners/commands/contextMenuCommands/contextMenuCommandSuccess.ts

@@ -1,25 +0,0 @@

-import { - container, - Listener, - LogLevel, - type ContextMenuCommandSuccessPayload, -} from "@sapphire/framework"; -import type { Logger } from "@sapphire/plugin-logger"; -import { logSuccessCommand } from "../../../lib/utils"; - -export class UserListener extends Listener { - public override run(payload: ContextMenuCommandSuccessPayload) { - logSuccessCommand(payload); - } - - public override onLoad() { - this.enabled = (this.container.logger as Logger).level <= LogLevel.Debug; - return super.onLoad(); - } -} - -container.stores.loadPiece({ - name: "contextMenuCommandSuccess", - piece: UserListener, - store: "listeners", -});
D apps/bot/src/listeners/commands/index.ts

@@ -1,5 +0,0 @@

-export * as ContextMenuCommandDenied from "./contextMenuCommands/contextMenuCommandDenied"; -export * as ContextMenuCommandSuccess from "./contextMenuCommands/contextMenuCommandSuccess"; - -export * as ChatInputCommandDenied from "./chatInputCommands/chatInputCommandDenied"; -export * as ChatInputCommandSuccess from "./chatInputCommands/chatInputCommandSuccess";
D apps/bot/src/listeners/index.ts

@@ -1,4 +0,0 @@

-export * from "./commands"; - -export * from "./ready"; -export * from "./interactionCreate";
D apps/bot/src/listeners/interactionCreate.ts

@@ -1,45 +0,0 @@

-import { container, Listener, Events } from "@sapphire/framework"; -import type { Interaction } from "discord.js"; - -export class InteractionCreateListener extends Listener<typeof Events.InteractionCreate> { - public constructor(context: Listener.LoaderContext, options: Listener.Options) { - super(context, { - ...options, - event: Events.InteractionCreate, - }); - } - - public run(interaction: Interaction) { - const user = interaction.user.tag; - const guild = interaction.guild ? interaction.guild.name : "Direct Messages"; - let details = ""; - - if (interaction.isChatInputCommand()) { - const commandString = [interaction.commandName]; - const sub = interaction.options.getSubcommand(false); - if (sub) commandString.push(sub); - - details = `slash command [${commandString.join(" / ")}]`; - } else if (interaction.isButton()) { - details = `button with ID [${interaction.customId}]`; - } else if (interaction.isAnySelectMenu()) { - details = `select menu with ID [${interaction.customId}]`; - } else if (interaction.isModalSubmit()) { - details = `modal with ID [${interaction.customId}]`; - } else if (interaction.isAutocomplete()) { - details = `autocomplete for [${interaction.commandName}]`; - } else if (interaction.isContextMenuCommand()) { - details = `context menu [${interaction.commandName}]`; - } else { - details = `Unknown interaction [${interaction.type}]`; - } - - this.container.logger.info(`interactions: ${user} in "${guild}" triggered: ${details}`); - } -} - -container.stores.loadPiece({ - name: "interactionCreate", - piece: InteractionCreateListener, - store: "listeners", -});
D apps/bot/src/listeners/ready.ts

@@ -1,29 +0,0 @@

-import { ApplyOptions } from "@sapphire/decorators"; -import { container, Listener } from "@sapphire/framework"; -import type { StoreRegistryValue } from "@sapphire/pieces"; - -@ApplyOptions<Listener.Options>({ once: true }) -export class UserEvent extends Listener { - public override run() { - this.printStoreDebugInformation(); - } - - private printStoreDebugInformation() { - const { client, logger } = this.container; - const stores = [...client.stores.values()]; - const last = stores.pop()!; - - for (const store of stores) logger.info(this.styleStore(store, false)); - logger.info(this.styleStore(last, true)); - } - - private styleStore(store: StoreRegistryValue, last: boolean) { - return `${last ? "└─" : "├─"} Loaded ${store.size.toString().padEnd(3, " ")} ${store.name}.`; - } -} - -container.stores.loadPiece({ - piece: UserEvent, - name: "clientReady", - store: "listeners", -});
A apps/bot/src/middleware/index.ts

@@ -0,0 +1,15 @@

+import { kitten } from "@/client"; +import { upsertUser } from "@/lib"; +import { HaltExecution } from "@purrkit/router"; +import { getActorByDiscordIdAndGuildDiscordId, upsertActor } from "@stealth-developers/db"; + +const base = kitten.builder(); + +export const getDbActor = base.use(async (interaction) => { + if (!interaction.guild) throw new HaltExecution("This command can only be run in a guild."); + + const actor = await upsertUser(interaction.user, interaction.guild) + if (!actor) throw new HaltExecution("Failed to add you to the database. Please try again."); + + return actor; +}
A apps/bot/src/types.ts

@@ -0,0 +1,3 @@

+import type { APIInteractionGuildMember, GuildMember, User } from "discord.js"; + +export type AnyUser = APIInteractionGuildMember | GuildMember | User;
M bun.lockbun.lock

@@ -5,6 +5,7 @@ "workspaces": {

"": { "name": "stealth-developers", "dependencies": { + "@purrkit/router": "^1.2.0", "discord-api-types": "^0.38.48", "discord.js": "^14.26.4", },

@@ -34,6 +35,7 @@ "apps/bot": {

"name": "@stealth-developers/bot", "version": "1.0.0", "dependencies": { + "@purrkit/router": "^1.1.0", "@sapphire/decorators": "^6.1.1", "@sapphire/discord.js-utilities": "7.3.2", "@sapphire/framework": "^5.3.2",

@@ -261,6 +263,8 @@

"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.68.0", "", { "os": "win32", "cpu": "x64" }, "sha512-zg5pA+84AlU6XHJ3ruiRxziO71QTrz8nLsk6u01JGS5+tL9/bnlakFiklFrcy4R1/V7ktWtaNitN3JZWmKnf6g=="], "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + + "@purrkit/router": ["@purrkit/router@1.2.0", "", { "peerDependencies": { "discord.js": "^14.26.4", "typescript": "^5" } }, "sha512-s6vMy0cr2UohizQ4IdE2M+zjXKnRGO7OBTmvJNGqaYTMaOCIct+kPAq15l0KSr0OxE/dHvz7BpbfuaifMj+RDA=="], "@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
M package.jsonpackage.json

@@ -20,6 +20,7 @@ "peerDependencies": {

"typescript": "^5.9.3" }, "dependencies": { + "@purrkit/router": "^1.2.0", "discord-api-types": "^0.38.48", "discord.js": "^14.26.4" }