all repos — stealth-developers @ a3e5c2d613f5af7a35cca701005377844609fcc4

bot: streamlined error handling aaaand check if we can DM users
vi did:web:vt3e.cat
Wed, 24 Jun 2026 03:52:57 +0100
commit

a3e5c2d613f5af7a35cca701005377844609fcc4

parent

adf2a1f4e94a5e420bad1b1b4b48ffdae274a4f9

M apps/bot/src/client.tsapps/bot/src/client.ts

@@ -1,6 +1,6 @@

import { Client, GatewayIntentBits } from "discord.js"; import { Kitten } from "@purrkit/router"; -import { KLogger, logger } from "$/logger"; +import { KLogger } from "$/logger"; export const client = new Client({ intents: [
A apps/bot/src/feats/tickets/README.md

@@ -0,0 +1,28 @@

+- [ ] /ticket + - [x] new + - [x] for + - [ ] view [ticketId] + - [ ] actions + - [ ] _close_ + - [ ] claim + - [ ] unclaim + - [ ] reopen + - [ ] comment [ticketId?] + - [ ] edit [ticketId?] + - [ ] clean - /ticket delete + - [ ] participants + - [ ] add + - [ ] remove + - [ ] list + - [ ] manage + - [ ] list + - [ ] export [ticketId] - defaults to all + - [ ] delete [ticketId] + - [ ] purge + - [x] settings + - [x] transcript-channel + - [x] entrypoint-channel + - [x] category + - [x] prompt + - [x] greeting + - [x] send-prompt
M apps/bot/src/feats/tickets/actions.tsapps/bot/src/feats/tickets/actions.ts

@@ -6,6 +6,7 @@ type Guild,

ContainerBuilder, TextDisplayBuilder, ActionRowBuilder, + DiscordAPIError, } from "discord.js"; import { Result } from "@sapphire/framework"; import { ButtonBuilder } from "discord.js";

@@ -21,7 +22,7 @@ type Actor,

} from "@stealth-developers/db"; import { upsertUser, getCategory, PublicError, logger } from "@/lib"; -import type { AnyUser } from "@/types"; +import type { AnyUser, MessageSendPayload } from "@/types"; import { TicketButtonPresets } from "./buttons"; import { TicketModalPresets } from "./modals";

@@ -60,6 +61,8 @@ );

const ticketCategory = await getCategory(dbGuild.ticketCategory); let createdChannel: TextChannel | undefined = undefined; + const canDm = await canDmUser(subject); + const txResult = await Result.fromAsync(async () => { return await db.transaction(async (tx) => { const [maxTicket] = await tx

@@ -84,7 +87,7 @@ subject: subjectUser.id,

}) .returning(); - if (!ticket) throw new PublicError("failed to add ticket to database, please try again."); + 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");

@@ -95,26 +98,47 @@ type: ChannelType.GuildText,

parent: ticketCategory, }); - if (!channel) throw new PublicError("failed to create Discord channel."); + if (!channel) throw new PublicError("Failed to create Discord channel."); createdChannel = channel; const greeting = dbGuild.ticketGreeting; - if (greeting) { - const greetingContainer = new ContainerBuilder() - .setAccentColor(0x3a9027) - .addTextDisplayComponents( - new TextDisplayBuilder({ - content: greeting.replaceAll("{user}", `<@${subjectUser.discordId}>`), - }), - ) - .addActionRowComponents( - new ActionRowBuilder<ButtonBuilder>().addComponents( - TicketButtonPresets.claim(publicId), - TicketButtonPresets.close(publicId), + const components = []; + + if (greeting) + components.push( + new ContainerBuilder() + .setAccentColor(0x3a9027) + .addTextDisplayComponents( + new TextDisplayBuilder({ + content: greeting.replaceAll("{user}", `<@${subjectUser.discordId}>`), + }), + ) + .addActionRowComponents( + new ActionRowBuilder<ButtonBuilder>().addComponents( + TicketButtonPresets.claim(publicId), + TicketButtonPresets.close(publicId), + ), ), - ); + ); - const uploadContainer = new ContainerBuilder() + if (!canDm) + components.push( + new ContainerBuilder() + .setAccentColor(0xdf8e1d) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent("## I can't DM you!"), + new TextDisplayBuilder().setContent( + [ + "You won't receive a notification once your ticket has been handled.", + `To check the outcome, you can use the \`/ticket view\` command with the ID \`${ticket.localId}\``, + "or, see all of your tickets with `/ticket manage list`.", + ].join(" "), + ), + ), + ); + + components.push( + new ContainerBuilder() .setAccentColor(0x3a9027) .addTextDisplayComponents( new TextDisplayBuilder({

@@ -130,13 +154,13 @@ .addActionRowComponents(

new ActionRowBuilder<ButtonBuilder>().addComponents( TicketButtonPresets.upload(publicId), ), - ); + ), + ); - channel.send({ - components: [greetingContainer, uploadContainer], - flags: ["IsComponentsV2"], - }); - } + await channel.send({ + components, + flags: ["IsComponentsV2"], + }); await tx.update(tickets).set({ channelId: channel.id }).where(eq(tickets.id, ticket.id)); return channel;

@@ -190,3 +214,31 @@

if (!result) throw new PublicError("Failed to close ticket: Ticket not found in database"); return result; } + +export async function canDmUser(user: AnyUser): Promise<boolean> { + if (!("send" in user)) return false; + + return user + .send({ + content: "Hi! I'm just checking if your DMs are open.", + flags: ["SuppressNotifications"], + }) + .then(() => true) + .catch(() => false); +} + +export async function messageUser(user: AnyUser, message: MessageSendPayload) { + if (!("send" in user)) + throw new PublicError("Failed to message user: User is not a Discord user"); + + const result = await Result.fromAsync(user.send(message)); + if (result.isErr()) { + const error = result.unwrapErr(); + + if (error instanceof DiscordAPIError && [50007, 50278].includes(error.code as number)) + throw new PublicError("Failed to message user: User has DMs disabled"); + throw error; + } + + return result.unwrap(); +}
M apps/bot/src/feats/tickets/modals.tsapps/bot/src/feats/tickets/modals.ts

@@ -1,3 +1,4 @@

+import { Result } from "@sapphire/framework"; import { LabelBuilder, MessageFlags,

@@ -9,7 +10,7 @@ import { option } from "@purrkit/router";

import db, { eq, getGuildByDiscordId, getTicket, guilds } from "@stealth-developers/db"; import { kitten } from "@/client"; -import { errorMessage, upsertUser } from "@/lib"; +import { errorMessage, handleError, upsertUser } from "@/lib"; import * as actions from "./actions"; export const configTextModal = kitten.modal("config-text", {

@@ -61,7 +62,10 @@ public: interaction.fields.getTextInputValue("reason:public"),

private: user.moderator ? interaction.fields.getTextInputValue("reason:private") : undefined, }; - await actions.closeTicket(user, ticket.id, reasons); + const result = await Result.fromAsync(actions.closeTicket(user, ticket.id, reasons)); + if (result.isErr()) + return handleError(interaction, result.unwrapErr(), "Failed to close ticket."); + return interaction.followUp({ content: "Ticket closed successfully" }); }, });
M apps/bot/src/index.tsapps/bot/src/index.ts

@@ -22,6 +22,9 @@ });

await kitten.sync(); logger.info(`logged in as ${client.user.tag}!`); + + const guilds = await client.guilds.fetch(); + logger.info(`serving ${guilds.size} guilds!`); }); void main();
M apps/bot/src/lib/error.tsapps/bot/src/lib/error.ts

@@ -1,6 +1,63 @@

+import type { Interaction } from "discord.js"; +import { errorMessage } from "./interactions"; +import { logger } from "./logger"; + export class PublicError extends Error { constructor(message: string) { super(message); this.name = "PublicError"; } } + +function getInteractionContext(interaction: Interaction) { + const context: Record<string, unknown> = { + userId: interaction.user?.id, + username: interaction.user?.username, + guildId: interaction.guildId, + channelId: interaction.channelId, + interactionType: interaction.type, + }; + + if (interaction.isCommand()) { + context.commandName = interaction.commandName; + } else if (interaction.isMessageComponent()) { + context.customId = interaction.customId; + context.componentType = interaction.componentType; + } else if (interaction.isModalSubmit()) { + context.customId = interaction.customId; + } + + return context; +} + +export async function handleError( + interaction: Interaction, + error: unknown, + fallbackMessage?: string, +) { + const context = getInteractionContext(interaction); + + if (error instanceof PublicError) { + logger.warn( + { + error: error instanceof Error ? { message: error.message, stack: error.stack } : error, + ...context, + }, + "A public error occurred during interaction execution", + ); + return await errorMessage(interaction, error.message); + } + + logger.error( + { + error: error instanceof Error ? { message: error.message, stack: error.stack } : error, + ...context, + }, + "An unexpected error occurred during interaction execution", + ); + + return await errorMessage( + interaction, + fallbackMessage ?? "An unexpected error occurred, please try again later.", + ); +}
M apps/bot/src/types.tsapps/bot/src/types.ts

@@ -1,3 +1,10 @@

-import type { APIInteractionGuildMember, GuildMember, User } from "discord.js"; +import type { + APIInteractionGuildMember, + GuildMember, + MessageCreateOptions, + MessagePayload, + User, +} from "discord.js"; export type AnyUser = APIInteractionGuildMember | GuildMember | User; +export type MessageSendPayload = string | MessagePayload | MessageCreateOptions;