bot: streamlined error handling aaaand check if we can DM users
vi did:web:vt3e.cat
Wed, 24 Jun 2026 03:52:57 +0100
7 files changed,
178 insertions(+),
27 deletions(-)
M
apps/bot/src/client.ts
→
apps/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.ts
→
apps/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.ts
→
apps/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.ts
→
apps/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.ts
→
apps/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.ts
→
apps/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;