bot: break actions up into separate files
vi did:web:vt3e.cat
Sat, 27 Jun 2026 23:22:53 +0100
11 files changed,
844 insertions(+),
788 deletions(-)
jump to
D
apps/bot/src/feats/tickets/actions.ts
@@ -1,785 +0,0 @@
-import { Result } from "@sapphire/framework"; -import db, { - type Actor, - type TicketWithRelations, - addTicketComment, - addTicketParticipant, - desc, - editTicket, - eq, - getGuildByDiscordId, - getModerators, - getTicketByChannelId, - tickets, -} from "@stealth-developers/db"; -import { - ActionRowBuilder, - ButtonBuilder, - type Channel, - ChannelType, - ContainerBuilder, - DiscordAPIError, - type Guild, - Message, - OverwriteType, - PermissionFlagsBits, - TextChannel, - TextDisplayBuilder, -} from "discord.js"; -import { randomUUID } from "node:crypto"; - -import type { AnyUser, MessageSendPayload } from "@/types"; - -import { client } from "@/client"; -import { PublicError, getCategory, logger, upsertUser } from "@/lib"; -import { hasAccessToTicket } from "@/lib/tickets"; - -import type { TicketContext } from "./types"; - -import { getGuildActor, upsertGuildActor } from "../guild-actor"; -import { TicketButtonPresets } from "./buttons"; -import { TicketModalPresets } from "./modals"; - -type CreateTicketArgs = - | undefined - | { - for: AnyUser; - reason?: string; - }; - -const text = (content: string) => new TextDisplayBuilder().setContent(content); - -export async function addParticipant( - guildId: string, - data: { - actor: Actor; - ticketId: string; - role: "participant" | "moderator" | "subject"; - reason?: string; - }, -) { - const botActor = getGuildActor(guildId); - if (!botActor) throw new PublicError("Failed to close ticket: Bot actor not found."); - - await addTicketParticipant({ - actorId: botActor.id, - ticketId: data.ticketId, - role: data.role, - reason: data.reason, - }); - - await addTicketComment({ - actorId: botActor.id, - ticketId: data.ticketId, - content: `Added participant: ${data.actor.displayName || data.actor.username!} - ${data.reason ?? "no reason provided"}`, - }); -} - -export async function createTicket( - opener: AnyUser, - guild: Guild, - args?: CreateTicketArgs, -): Promise<{ channelId: string; publicId: string }> { - const isOnBehalf = !!args?.for; - const publicId = randomUUID(); - - const dbGuild = await getGuildByDiscordId(guild.id); - if (!dbGuild) throw new PublicError("Failed to create ticket: Guild not found in database"); - - const moderators = await getModerators(dbGuild.id); - if (!moderators) throw new PublicError("Failed to create ticket: No moderators found"); - - const creator = await upsertUser(opener, guild); - if (!creator) throw new PublicError("Failed to create ticket: Creator 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) - 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(dbGuild.ticketCategory); - let createdChannel: TextChannel | undefined = undefined; - - const canDm = await canDmUser(subject); - - let ticketId: string | undefined = undefined; - - const txResult = await Result.fromAsync(async () => { - return await db.transaction(async (tx) => { - const [maxTicket] = await tx - .select({ localId: tickets.localId }) - .from(tickets) - .where(eq(tickets.guildId, creator.guildId)) - .orderBy(desc(tickets.localId)) - .limit(1); - - const localId = (maxTicket?.localId ?? 0) + 1; - - const [ticket] = await tx - .insert(tickets) - .values({ - id: publicId, - guildId: creator.guildId, - localId: localId, - status: "provisioning", - openedAt: new Date(), - openedBy: creator.id, - subject: subjectUser.id, - }) - .returning(); - - if (!ticket) throw new PublicError("Failed to add ticket to database, please try again."); - return { ticket, localId }; - }); - }); - - if (txResult.isErr()) { - const error = txResult.unwrapErr(); - const message = - error instanceof PublicError - ? `Failed to create ticket: ${error.message}` - : "There was an error creating the ticket."; - - if (error instanceof Error) logger.error(error.stack, error.message); - throw new PublicError(message); - } - - const { ticket, localId } = txResult.unwrap(); - ticketId = ticket.id; - - const setupResult = await Result.fromAsync(async () => { - const channel = await guild.channels.create({ - name: channelName(localId, "provisioning"), - type: ChannelType.GuildText, - parent: ticketCategory, - permissionOverwrites: [ - { - id: guild.id, - type: OverwriteType.Role, - deny: [PermissionFlagsBits.ViewChannel], - }, - ...moderators.map((moderator) => ({ - id: moderator.discordId!, - type: OverwriteType.Member, - allow: [ - PermissionFlagsBits.ViewChannel, - PermissionFlagsBits.SendMessages, - PermissionFlagsBits.AttachFiles, - ], - })), - { - id: client.user!.id, - type: OverwriteType.Member, - allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ManageChannels], - }, - { - id: subjectUser.discordId!, - type: OverwriteType.Member, - allow: [ - PermissionFlagsBits.ViewChannel, - PermissionFlagsBits.SendMessages, - PermissionFlagsBits.AttachFiles, - ], - }, - ], - }); - - if (!channel) throw new PublicError("Failed to create Discord channel."); - createdChannel = channel; - - const greeting = dbGuild.ticketGreeting; - 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.claimTicketButton(publicId), - TicketButtonPresets.closeTicketButton(publicId), - ), - ), - ); - } - - 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({ - content: [ - "# Uploading Videos\n", - "You can either upload your video directly to this channel, or", - "you can use the button below to upload it directly to us, which", - "allows you to upload videos up to 2GB in size.", - ].join(" "), - }), - ) - .addActionRowComponents( - new ActionRowBuilder<ButtonBuilder>().addComponents( - TicketButtonPresets.uploadLink(publicId), - ), - ), - ); - - await channel.send({ - components, - flags: ["IsComponentsV2"], - }); - - await db - .update(tickets) - .set({ channelId: channel.id, status: "open" }) - .where(eq(tickets.id, ticket.id)); - - channel - .edit({ - name: channelName(localId, "open"), - }) - .catch((err) => { - logger.error(err, `failed to update channel name to open for ticket ${ticket.id}`); - }); - - return channel; - }); - - if (setupResult.isErr()) { - if (createdChannel) { - await Result.fromAsync(() => (createdChannel as TextChannel).delete()); - } - - await Result.fromAsync(() => db.delete(tickets).where(eq(tickets.id, ticketId!))); - - const error = setupResult.unwrapErr(); - const message = - error instanceof PublicError - ? `Failed to create ticket: ${error.message}` - : "There was an error creating the ticket."; - - if (error instanceof Error) logger.error(error.stack, error.message); - - throw new PublicError(message); - } - - await addParticipant(guild.id, { - actor: subjectUser, - ticketId: ticketId!, - role: "subject", - reason: "subject of ticket", - }); - - const channel = setupResult.unwrap(); - return { channelId: channel.id, publicId }; -} - -export async function triggerCloseTicket(user: AnyUser, guild: Guild, channelId: string) { - const dbGuild = await getGuildByDiscordId(guild.id); - if (!dbGuild) throw new PublicError("Failed to create ticket: Guild not found in database"); - - const dbUser = await upsertUser(user, guild); - if (!dbUser) throw new PublicError("Failed to create ticket: User not found in database"); - - const ticket = await getTicketByChannelId(channelId); - if (!ticket) throw new PublicError("Failed to close ticket: Ticket not found in database"); - - return TicketModalPresets.close(ticket.id, Boolean(dbUser.moderator)); -} - -/** - * @param ticket the ticket to get the container for - * @param limitedDetails whether to exclude comments & other internal details - */ -export function getTicketContainer(ticket: TicketWithRelations, limitedDetails = false) { - const container = new ContainerBuilder(); - container.addTextDisplayComponents(text(`# Ticket #${ticket.localId} - ${ticket.status}`)); - - let closedBySubject = true; - if (ticket.status !== "open" && ticket.closedByActor && ticket.subjectActor) { - closedBySubject = ticket.subjectActor.id === ticket.closedByActor.id; - - container.addTextDisplayComponents( - text(`Closed by ${closedBySubject ? "the subject" : `<@${ticket.closedByActor.discordId}>`}`), - ); - - container.addTextDisplayComponents(text(["**Reason**", `> ${ticket.closeReason}`].join("\n"))); - - if (!limitedDetails && ticket.privateReason) - container.addTextDisplayComponents( - text(["**Private Reason**", `> ${ticket.privateReason}`].join("\n")), - ); - } - - const hasAttachments = ticket.attachments.length > 0; - if (hasAttachments) - container.addTextDisplayComponents(text(`**Attachments:** ${ticket.attachments.length}`)); - else container.addTextDisplayComponents(text("**Attachments:** no attachments")); - - container.addTextDisplayComponents(text(`-# ${ticket.id}`)); - - const actionRow = new ActionRowBuilder<ButtonBuilder>(); - if (limitedDetails) actionRow.addComponents(TicketButtonPresets.thankButton(ticket.id)); - if (!limitedDetails) - actionRow.addComponents( - TicketButtonPresets.viewCommentsButton(ticket.id, ticket.comments.length), - ); - actionRow.addComponents(TicketButtonPresets.transcriptsLink(ticket.id)); - - container.addActionRowComponents(actionRow); - return container; -} - -export function getCommentContainer(ticketId: number, comments: TicketWithRelations["comments"]) { - const formatTime = (date: Date) => `<t:${Math.floor(date.getTime() / 1000)}>`; - - const container = new ContainerBuilder(); - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent(`## Comments on #${String(ticketId).padStart(4, "0")}`), - ); - - if (!comments || comments.length === 0) { - container.addTextDisplayComponents(new TextDisplayBuilder().setContent("No comments found")); - return container; - } - - const recentComments = comments.slice(-10); - - recentComments.forEach((comment) => { - const authorMention = comment.actor?.discordId - ? `<@${comment.actor.discordId}>` - : "Unknown User"; - - const quotedContent = (comment.content || "") - .split("\n") - .map((line) => `> ${line}`) - .join("\n"); - - const commentString = `**${authorMention}** (${formatTime(comment.createdAt)})\n${quotedContent}`; - - container.addTextDisplayComponents(new TextDisplayBuilder().setContent(commentString)); - }); - - return container; -} - -/** - * - * @param user - * @param ticketId - * @param reasons - * @param cleanup whether to delete the ticket channel immediately after closing - * @returns - */ -export async function closeTicket( - user: Actor, - ticketId: string, - reasons: { public: string; private?: string | null }, - cleanup: boolean = false, -) { - const result = await editTicket(ticketId, { - closedAt: new Date(), - closeReason: reasons.public, - privateReason: reasons.private, - closedBy: user.id, - status: "closed", - }); - - if (!result) throw new PublicError("Failed to close ticket: Ticket not found in database"); - - const dbGuild = result.guild; - if (!dbGuild) throw new PublicError("Failed to close ticket: Guild not found in database"); - - const guild = await client.guilds.fetch(dbGuild.discordId); - if (!guild) throw new PublicError("Failed to close ticket: Guild not found in Discord"); - - if (!result.channelId) - throw new PublicError("Failed to close ticket: Channel not found in database"); - - const dbSubject = result.subjectActor; - if (!dbSubject || !dbSubject.discordId) - throw new PublicError("Failed to close ticket: Subject not found in database"); - - const channel = await guild.channels.fetch(result.channelId); - if (!channel || !channel.isTextBased()) - throw new PublicError("Failed to close ticket: Channel not found in database"); - - if (cleanup) { - channel - .delete() - .catch((err) => logger.error(err, `Failed to delete channel ${channel.id} during cleanup`)); - } else { - const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents( - TicketButtonPresets.reopenTicketButton(ticketId), - TicketButtonPresets.cleanTicketButton(ticketId), - ); - - messageChannel(channel, { - content: `This ticket has been closed by <@${user.discordId}>.`, - components: [actionRow], - flags: ["SuppressNotifications"], - }).catch((err) => logger.error(err, `Failed to message closed channel ${channel.id}`)); - - channel - .edit({ - name: channelName(result.localId, result.status), - permissionOverwrites: [ - { - id: dbSubject.discordId!, - deny: [PermissionFlagsBits.ViewChannel], - allow: [], - type: OverwriteType.Member, - }, - ], - }) - .catch((err) => - logger.error(err, `Failed to update name/permissions for closed channel ${channel.id}`), - ); - } - - (async () => { - try { - const botActor = await upsertGuildActor(guild); - if (!botActor) { - logger.warn("Failed to complete closeTicket notifications: Bot actor not found."); - return; - } - - const subject = await client.users.fetch(dbSubject.discordId!); - const container = getTicketContainer(result, true); - - const messageRes = await Result.fromAsync( - messageUser(subject, { - components: [container], - flags: ["IsComponentsV2"], - }), - ); - - if (messageRes.isErr()) { - await addTicketComment({ - actorId: botActor.id, - ticketId: result.id, - content: `User has DMs closed, a notification was not sent.`, - }); - } - - const logChannel = dbGuild.ticketLogChannel; - if (!logChannel) { - await addTicketComment({ - actorId: botActor.id, - ticketId: result.id, - content: `No log channel was found, a notification was not sent.`, - }); - } else { - const containerLong = getTicketContainer(result, false); - const commentContainer = getCommentContainer(result.localId, result.comments); - - const message = await Result.fromAsync( - messageChannel(logChannel, { - components: [containerLong, commentContainer], - flags: ["SuppressNotifications", "IsComponentsV2"], - allowedMentions: { parse: [] }, - }), - ); - - if (message.isErr()) { - await addTicketComment({ - actorId: botActor.id, - ticketId: result.id, - content: `Failed to send log message: ${message.unwrapErr()}`, - }); - } - } - } catch (err) { - logger.error(err, `Error performing background notification tasks for ticket ${result.id}`); - } - })(); - - return result; -} - -export async function reopenTicket(ctx: TicketContext) { - const { actor, guild, ticket, ticketChannel } = ctx; - if (ticket.status !== "closed") throw new PublicError("This ticket is not closed."); - - const canAccess = hasAccessToTicket(actor, guild, ticket, { requireModerator: true }); - if (!canAccess) throw new PublicError("You don't have permission to reopen this ticket."); - - const newTicket = await editTicket(ticket.id, { - status: "open", - closedAt: null, - closedBy: null, - - claimedBy: actor.id, - claimedAt: new Date(), - lastClaimantActivityAt: new Date(), - }); - - if (ticketChannel) { - const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents( - TicketButtonPresets.claimTicketButton(ticket.id), - TicketButtonPresets.closeTicketButton(ticket.id), - ); - - messageChannel(ticketChannel, { - content: `<@${actor.discordId}> reopened this ticket.`, - flags: ["SuppressNotifications"], - components: [actionRow], - }).catch((err) => - logger.error(err, `Failed to send reopen message to channel ${ticketChannel.id}`), - ); - - if (ticketChannel.type === ChannelType.GuildText) { - ticketChannel - .edit({ - name: channelName(ticket.localId, "claimed", actor.displayName || actor.username!), - permissionOverwrites: ticket.subjectActor - ? [ - { - id: ticket.subjectActor.discordId!, - type: OverwriteType.Member, - allow: [ - PermissionFlagsBits.ViewChannel, - PermissionFlagsBits.SendMessages, - PermissionFlagsBits.AttachFiles, - ], - }, - ] - : undefined, - }) - .catch((err) => - logger.error( - err, - `Failed to update name/permissions on reopen for channel ${ticketChannel.id}`, - ), - ); - } - } - - return newTicket; -} - -export async function claimTicket(ctx: TicketContext) { - const { ticket, actor, ticketChannel, guild } = ctx; - if (ticket.status !== "open") throw new Error("Ticket is not open"); - - const canAccess = hasAccessToTicket(actor, guild, ticket, { requireModerator: true }); - if (!canAccess) throw new PublicError("You don't have permission to reopen this ticket."); - - const lastClaimerActive = ticket.lastClaimantActivityAt - ? new Date(ticket.lastClaimantActivityAt) - : null; - - // if the last claimer was active in the last 15 minutes, don't allow claiming - if (lastClaimerActive && lastClaimerActive.getTime() > Date.now() - 1000 * 60 * 15) { - const timeAgo = Math.floor(lastClaimerActive.getTime() / 1000); - throw new PublicError( - `This ticket was claimed by <@${ticket.claimedByActor?.discordId}> <t:${timeAgo}:R>.`, - ); - } - - const newTicket = await editTicket(ticket.id, { - claimedAt: new Date(), - claimedBy: actor.id, - lastClaimantActivityAt: new Date(), - }); - - if (ticketChannel) { - const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents( - TicketButtonPresets.unclaimTicketButton(ticket.id), - ); - - messageChannel(ticketChannel, { - content: `<@${actor.discordId}> has claimed this ticket, they will be with you shortly.`, - flags: ["SuppressNotifications"], - components: [actionRow], - }).catch((err) => - logger.error(err, `failed to send claim message to channel ${ticketChannel.id}`), - ); - - if (ticketChannel.type === ChannelType.GuildText) { - ticketChannel - .edit({ - name: channelName(ticket.localId, "claimed", actor.displayName || actor.username!), - }) - .catch((err) => - logger.error(err, `failed to update channel name on claim for ${ticketChannel.id}`), - ); - } - } - - return newTicket; -} - -export async function addComment(ctx: TicketContext, comment: string) { - const { actor, guild, ticket } = ctx; - - const canAccess = hasAccessToTicket(actor, guild, ticket, { requireModerator: true }); - if (!canAccess) - throw new PublicError("You don't have permission to add a comment to this ticket."); - - const dbComment = await addTicketComment({ - ticketId: ticket.id, - actorId: actor.id, - content: comment, - }); - - if (!dbComment) throw new PublicError("Failed to add comment to ticket."); - return dbComment; -} - -export async function unclaimTicket(ctx: TicketContext) { - const { ticket, actor, ticketChannel, guild } = ctx; - if (ticket.status !== "open") throw new Error("Ticket is not open"); - - const canAccess = hasAccessToTicket(actor, guild, ticket, { requireModerator: true }); - if (!canAccess) throw new PublicError("You don't have permission to reopen this ticket."); - - if (ticket.claimedBy !== actor.id) throw new PublicError("You have not claimed this ticket."); - - const newTicket = await editTicket(ticket.id, { - claimedAt: null, - claimedBy: null, - lastClaimantActivityAt: null, - }); - - if (ticketChannel) { - const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents( - TicketButtonPresets.claimTicketButton(ticket.id), - ); - - messageChannel(ticketChannel, { - content: `<@${actor.discordId}> unclaimed this ticket, someone else will be with you shortly.`, - flags: ["SuppressNotifications"], - components: [actionRow], - }).catch((err) => - logger.error(err, `Failed to send unclaim message to channel ${ticketChannel.id}`), - ); - - if (ticketChannel.type === ChannelType.GuildText) { - ticketChannel - .edit({ - name: channelName(ticket.localId, "open"), - }) - .catch((err) => - logger.error(err, `Failed to update channel name on unclaim for ${ticketChannel.id}`), - ); - } - } - - return newTicket; -} - -export async function cleanTicket(ctx: TicketContext) { - const { ticket, actor, ticketChannel, guild } = ctx; - if (ticket.status !== "closed") - throw new Error("Tickets must be closed before they can be cleaned up."); - - const canAccess = hasAccessToTicket(actor, guild, ticket, { requireModerator: true }); - if (!canAccess) throw new PublicError("You don't have permission to clean up this ticket."); - if (!ticketChannel) throw new PublicError("Ticket channel not found."); - - const result = await editTicket(ticket.id, { - channelId: null, - }); - - ticketChannel - .delete("Ticket channel cleaned up") - .catch((err) => - logger.error(err, `failed to delete channel ${ticketChannel.id} during clean up`), - ); - - 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 function channelName(localId: number, status: string, claimedBy?: string) { - const initNumber = localId.toString().padStart(4, "0"); - - // discord errors if we try to create channels with certain phrases in them - const bad = ["1488", "1919"]; - if (bad.includes(initNumber)) return `${status}-filtered`; - - if (claimedBy) { - const firstLetters = claimedBy.slice(0, 3); - return `${firstLetters}-${status}-${initNumber}`; - } - - return `${status}-ticket-${localId}`; -} - -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(); -} - -export async function messageChannel(channelIn: string | Channel, message: MessageSendPayload) { - const channel = - typeof channelIn === "string" ? await client.channels.fetch(channelIn) : channelIn; - if (!channel) throw new PublicError("Failed to message channel: Channel not found"); - - if (!("send" in channel)) - throw new PublicError("Failed to message channel: Channel is not a text channel"); - - const result = await Result.fromAsync<Message<boolean>>(channel.send(message)); - if (result.isErr()) { - const error = result.unwrapErr(); - logger.warn({ error }, "Failed to message channel"); - - if (error instanceof DiscordAPIError && [50007, 50278].includes(error.code as number)) - throw new PublicError("Failed to message channel: Channel is not a text channel"); - - throw error; - } - - return result.unwrap(); -}
A
apps/bot/src/feats/tickets/actions/assignment.ts
@@ -0,0 +1,136 @@
+import { + type Actor, + addTicketComment, + addTicketParticipant, + editTicket, +} from "@stealth-developers/db"; +import { ActionRowBuilder, ButtonBuilder, ChannelType } from "discord.js"; +import { getGuildActor } from "f/guild-actor"; + +import { PublicError, logger } from "@/lib"; + +import type { TicketContext } from "../types"; + +import { TicketButtonPresets } from "../buttons"; +import { hasAccessToTicket } from "../lib"; +import { channelName, messageChannel } from "./helpers"; + +export async function claimTicket(ctx: TicketContext) { + const { ticket, actor, ticketChannel, guild } = ctx; + if (ticket.status !== "open") throw new Error("Ticket is not open"); + + const canAccess = hasAccessToTicket(actor, guild, ticket, { requireModerator: true }); + if (!canAccess) throw new PublicError("You don't have permission to reopen this ticket."); + + const lastClaimerActive = ticket.lastClaimantActivityAt + ? new Date(ticket.lastClaimantActivityAt) + : null; + + // if the last claimer was active in the last 15 minutes, don't allow claiming + if (lastClaimerActive && lastClaimerActive.getTime() > Date.now() - 1000 * 60 * 15) { + const timeAgo = Math.floor(lastClaimerActive.getTime() / 1000); + throw new PublicError( + `This ticket was claimed by <@${ticket.claimedByActor?.discordId}> <t:${timeAgo}:R>.`, + ); + } + + const newTicket = await editTicket(ticket.id, { + claimedAt: new Date(), + claimedBy: actor.id, + lastClaimantActivityAt: new Date(), + }); + + if (ticketChannel) { + const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents( + TicketButtonPresets.unclaimTicketButton(ticket.id), + ); + + messageChannel(ticketChannel, { + content: `<@${actor.discordId}> has claimed this ticket, they will be with you shortly.`, + flags: ["SuppressNotifications"], + components: [actionRow], + }).catch((err) => + logger.error(err, `failed to send claim message to channel ${ticketChannel.id}`), + ); + + if (ticketChannel.type === ChannelType.GuildText) { + ticketChannel + .edit({ + name: channelName(ticket.localId, "claimed", actor.displayName || actor.username!), + }) + .catch((err) => + logger.error(err, `failed to update channel name on claim for ${ticketChannel.id}`), + ); + } + } + + return newTicket; +} + +export async function unclaimTicket(ctx: TicketContext) { + const { ticket, actor, ticketChannel, guild } = ctx; + if (ticket.status !== "open") throw new Error("Ticket is not open"); + + const canAccess = hasAccessToTicket(actor, guild, ticket, { requireModerator: true }); + if (!canAccess) throw new PublicError("You don't have permission to reopen this ticket."); + + if (ticket.claimedBy !== actor.id) throw new PublicError("You have not claimed this ticket."); + + const newTicket = await editTicket(ticket.id, { + claimedAt: null, + claimedBy: null, + lastClaimantActivityAt: null, + }); + + if (ticketChannel) { + const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents( + TicketButtonPresets.claimTicketButton(ticket.id), + ); + + messageChannel(ticketChannel, { + content: `<@${actor.discordId}> unclaimed this ticket, someone else will be with you shortly.`, + flags: ["SuppressNotifications"], + components: [actionRow], + }).catch((err) => + logger.error(err, `Failed to send unclaim message to channel ${ticketChannel.id}`), + ); + + if (ticketChannel.type === ChannelType.GuildText) { + ticketChannel + .edit({ + name: channelName(ticket.localId, "open"), + }) + .catch((err) => + logger.error(err, `Failed to update channel name on unclaim for ${ticketChannel.id}`), + ); + } + } + + return newTicket; +} + +export async function addParticipant( + guildId: string, + data: { + actor: Actor; + ticketId: string; + role: "participant" | "moderator" | "subject"; + reason?: string; + }, +) { + const botActor = getGuildActor(guildId); + if (!botActor) throw new PublicError("Failed to close ticket: Bot actor not found."); + + await addTicketParticipant({ + actorId: botActor.id, + ticketId: data.ticketId, + role: data.role, + reason: data.reason, + }); + + await addTicketComment({ + actorId: botActor.id, + ticketId: data.ticketId, + content: `Added participant: ${data.actor.displayName || data.actor.username!} - ${data.reason ?? "no reason provided"}`, + }); +}
A
apps/bot/src/feats/tickets/actions/comments.ts
@@ -0,0 +1,67 @@
+import { type TicketWithRelations, addTicketComment } from "@stealth-developers/db"; +import { ContainerBuilder, TextDisplayBuilder } from "discord.js"; + +import { PublicError } from "@/lib"; + +import type { TicketContext } from "../types"; + +import { hasAccessToTicket } from "../lib"; + +export function getCommentContainer(ticketId: number, comments: TicketWithRelations["comments"]) { + const formatTime = (date: Date) => `<t:${Math.floor(date.getTime() / 1000)}>`; + + const container = new ContainerBuilder(); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(`## Comments on #${String(ticketId).padStart(4, "0")}`), + ); + + if (!comments || comments.length === 0) { + container.addTextDisplayComponents(new TextDisplayBuilder().setContent("No comments found")); + return container; + } + + const recentComments = comments.slice(-10); + + recentComments.forEach((comment) => { + const authorMention = comment.actor?.discordId + ? `<@${comment.actor.discordId}>` + : "Unknown User"; + + const quotedContent = (comment.content || "") + .split("\n") + .map((line) => `> ${line}`) + .join("\n"); + + const commentString = `**${authorMention}** (${formatTime(comment.createdAt)})\n${quotedContent}`; + + container.addTextDisplayComponents(new TextDisplayBuilder().setContent(commentString)); + }); + + return container; +} + +/** + * + * @param user + * @param ticketId + * @param reasons + * @param cleanup whether to delete the ticket channel immediately after closing + * @returns + */ + +export async function addComment(ctx: TicketContext, comment: string) { + const { actor, guild, ticket } = ctx; + + const canAccess = hasAccessToTicket(actor, guild, ticket, { requireModerator: true }); + if (!canAccess) + throw new PublicError("You don't have permission to add a comment to this ticket."); + + const dbComment = await addTicketComment({ + ticketId: ticket.id, + actorId: actor.id, + content: comment, + }); + + if (!dbComment) throw new PublicError("Failed to add comment to ticket."); + return dbComment; +}
A
apps/bot/src/feats/tickets/actions/helpers.ts
@@ -0,0 +1,129 @@
+import type { TicketWithRelations } from "@stealth-developers/db"; + +import { Result } from "@sapphire/framework"; +import { + ActionRowBuilder, + ButtonBuilder, + type Channel, + ContainerBuilder, + DiscordAPIError, + Message, + TextDisplayBuilder, +} from "discord.js"; + +import type { AnyUser, MessageSendPayload } from "@/types"; + +import { client } from "@/client"; +import { PublicError, logger } from "@/lib"; + +import TicketButtonPresets from "../buttons"; + +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 function channelName(localId: number, status: string, claimedBy?: string) { + const initNumber = localId.toString().padStart(4, "0"); + + // discord errors if we try to create channels with certain phrases in them + const bad = ["1488", "1919"]; + if (bad.includes(initNumber)) return `${status}-filtered`; + + if (claimedBy) { + const firstLetters = claimedBy.slice(0, 3); + return `${firstLetters}-${status}-${initNumber}`; + } + + return `${status}-ticket-${localId}`; +} + +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(); +} + +export async function messageChannel(channelIn: string | Channel, message: MessageSendPayload) { + const channel = + typeof channelIn === "string" ? await client.channels.fetch(channelIn) : channelIn; + if (!channel) throw new PublicError("Failed to message channel: Channel not found"); + + if (!("send" in channel)) + throw new PublicError("Failed to message channel: Channel is not a text channel"); + + const result = await Result.fromAsync<Message<boolean>>(channel.send(message)); + if (result.isErr()) { + const error = result.unwrapErr(); + logger.warn({ error }, "Failed to message channel"); + + if (error instanceof DiscordAPIError && [50007, 50278].includes(error.code as number)) + throw new PublicError("Failed to message channel: Channel is not a text channel"); + + throw error; + } + + return result.unwrap(); +} + +export const text = (content: string) => new TextDisplayBuilder().setContent(content); + +/** + * @param ticket the ticket to get the container for + * @param limitedDetails whether to exclude comments & other internal details + */ +export function getTicketContainer(ticket: TicketWithRelations, limitedDetails = false) { + const container = new ContainerBuilder(); + container.addTextDisplayComponents(text(`# Ticket #${ticket.localId} - ${ticket.status}`)); + + let closedBySubject = true; + if (ticket.status !== "open" && ticket.closedByActor && ticket.subjectActor) { + closedBySubject = ticket.subjectActor.id === ticket.closedByActor.id; + + container.addTextDisplayComponents( + text(`Closed by ${closedBySubject ? "the subject" : `<@${ticket.closedByActor.discordId}>`}`), + ); + + container.addTextDisplayComponents(text(["**Reason**", `> ${ticket.closeReason}`].join("\n"))); + + if (!limitedDetails && ticket.privateReason) + container.addTextDisplayComponents( + text(["**Private Reason**", `> ${ticket.privateReason}`].join("\n")), + ); + } + + const hasAttachments = ticket.attachments.length > 0; + if (hasAttachments) + container.addTextDisplayComponents(text(`**Attachments:** ${ticket.attachments.length}`)); + else container.addTextDisplayComponents(text("**Attachments:** no attachments")); + + container.addTextDisplayComponents(text(`-# ${ticket.id}`)); + + const actionRow = new ActionRowBuilder<ButtonBuilder>(); + if (limitedDetails) actionRow.addComponents(TicketButtonPresets.thankButton(ticket.id)); + if (!limitedDetails) + actionRow.addComponents( + TicketButtonPresets.viewCommentsButton(ticket.id, ticket.comments.length), + ); + actionRow.addComponents(TicketButtonPresets.transcriptsLink(ticket.id)); + + container.addActionRowComponents(actionRow); + return container; +}
A
apps/bot/src/feats/tickets/actions/index.ts
@@ -0,0 +1,4 @@
+export * from "./lifecycle"; +export * from "./assignment"; +export * from "./helpers"; +export * from "./comments";
A
apps/bot/src/feats/tickets/actions/lifecycle.ts
@@ -0,0 +1,496 @@
+import { Result } from "@sapphire/framework"; +import db, { + type Actor, + addTicketComment, + desc, + editTicket, + eq, + getGuildByDiscordId, + getModerators, + getTicketByChannelId, + tickets, +} from "@stealth-developers/db"; +import { + ActionRowBuilder, + ButtonBuilder, + ChannelType, + ContainerBuilder, + type Guild, + OverwriteType, + PermissionFlagsBits, + TextChannel, + TextDisplayBuilder, +} from "discord.js"; +import { upsertGuildActor } from "f/guild-actor"; +import { randomUUID } from "node:crypto"; + +import type { AnyUser } from "@/types"; + +import { client } from "@/client"; +import { PublicError, getCategory, logger, upsertUser } from "@/lib"; + +import type { CreateTicketArgs, TicketContext } from "../types"; + +import { TicketButtonPresets } from "../buttons"; +import { hasAccessToTicket } from "../lib"; +import { TicketModalPresets } from "../modals"; +import { addParticipant } from "./assignment"; +import { getCommentContainer } from "./comments"; +import { getTicketContainer } from "./helpers"; +import { canDmUser, channelName, messageChannel, messageUser } from "./helpers"; + +export async function createTicket( + opener: AnyUser, + guild: Guild, + args?: CreateTicketArgs, +): Promise<{ channelId: string; publicId: string }> { + const isOnBehalf = !!args?.for; + const publicId = randomUUID(); + + const dbGuild = await getGuildByDiscordId(guild.id); + if (!dbGuild) throw new PublicError("Failed to create ticket: Guild not found in database"); + + const moderators = await getModerators(dbGuild.id); + if (!moderators) throw new PublicError("Failed to create ticket: No moderators found"); + + const creator = await upsertUser(opener, guild); + if (!creator) throw new PublicError("Failed to create ticket: Creator 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) + 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(dbGuild.ticketCategory); + let createdChannel: TextChannel | undefined = undefined; + + const canDm = await canDmUser(subject); + + let ticketId: string | undefined = undefined; + + const txResult = await Result.fromAsync(async () => { + return await db.transaction(async (tx) => { + const [maxTicket] = await tx + .select({ localId: tickets.localId }) + .from(tickets) + .where(eq(tickets.guildId, creator.guildId)) + .orderBy(desc(tickets.localId)) + .limit(1); + + const localId = (maxTicket?.localId ?? 0) + 1; + + const [ticket] = await tx + .insert(tickets) + .values({ + id: publicId, + guildId: creator.guildId, + localId: localId, + status: "provisioning", + openedAt: new Date(), + openedBy: creator.id, + subject: subjectUser.id, + }) + .returning(); + + if (!ticket) throw new PublicError("Failed to add ticket to database, please try again."); + return { ticket, localId }; + }); + }); + + if (txResult.isErr()) { + const error = txResult.unwrapErr(); + const message = + error instanceof PublicError + ? `Failed to create ticket: ${error.message}` + : "There was an error creating the ticket."; + + if (error instanceof Error) logger.error(error.stack, error.message); + throw new PublicError(message); + } + + const { ticket, localId } = txResult.unwrap(); + ticketId = ticket.id; + + const setupResult = await Result.fromAsync(async () => { + const channel = await guild.channels.create({ + name: channelName(localId, "provisioning"), + type: ChannelType.GuildText, + parent: ticketCategory, + permissionOverwrites: [ + { + id: guild.id, + type: OverwriteType.Role, + deny: [PermissionFlagsBits.ViewChannel], + }, + ...moderators.map((moderator) => ({ + id: moderator.discordId!, + type: OverwriteType.Member, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.AttachFiles, + ], + })), + { + id: client.user!.id, + type: OverwriteType.Member, + allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ManageChannels], + }, + { + id: subjectUser.discordId!, + type: OverwriteType.Member, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.AttachFiles, + ], + }, + ], + }); + + if (!channel) throw new PublicError("Failed to create Discord channel."); + createdChannel = channel; + + const greeting = dbGuild.ticketGreeting; + 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.claimTicketButton(publicId), + TicketButtonPresets.closeTicketButton(publicId), + ), + ), + ); + } + + 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({ + content: [ + "# Uploading Videos\n", + "You can either upload your video directly to this channel, or", + "you can use the button below to upload it directly to us, which", + "allows you to upload videos up to 2GB in size.", + ].join(" "), + }), + ) + .addActionRowComponents( + new ActionRowBuilder<ButtonBuilder>().addComponents( + TicketButtonPresets.uploadLink(publicId), + ), + ), + ); + + await channel.send({ + components, + flags: ["IsComponentsV2"], + }); + + await db + .update(tickets) + .set({ channelId: channel.id, status: "open" }) + .where(eq(tickets.id, ticket.id)); + + channel + .edit({ + name: channelName(localId, "open"), + }) + .catch((err) => { + logger.error(err, `failed to update channel name to open for ticket ${ticket.id}`); + }); + + return channel; + }); + + if (setupResult.isErr()) { + if (createdChannel) { + await Result.fromAsync(() => (createdChannel as TextChannel).delete()); + } + + await Result.fromAsync(() => db.delete(tickets).where(eq(tickets.id, ticketId!))); + + const error = setupResult.unwrapErr(); + const message = + error instanceof PublicError + ? `Failed to create ticket: ${error.message}` + : "There was an error creating the ticket."; + + if (error instanceof Error) logger.error(error.stack, error.message); + + throw new PublicError(message); + } + + await addParticipant(guild.id, { + actor: subjectUser, + ticketId: ticketId!, + role: "subject", + reason: "subject of ticket", + }); + + const channel = setupResult.unwrap(); + return { channelId: channel.id, publicId }; +} + +export async function triggerCloseTicket(user: AnyUser, guild: Guild, channelId: string) { + const dbGuild = await getGuildByDiscordId(guild.id); + if (!dbGuild) throw new PublicError("Failed to create ticket: Guild not found in database"); + + const dbUser = await upsertUser(user, guild); + if (!dbUser) throw new PublicError("Failed to create ticket: User not found in database"); + + const ticket = await getTicketByChannelId(channelId); + if (!ticket) throw new PublicError("Failed to close ticket: Ticket not found in database"); + + return TicketModalPresets.close(ticket.id, Boolean(dbUser.moderator)); +} + +/** + * @param user + * @param ticketId + * @param reasons + * @param cleanup whether to delete the ticket channel immediately after closing + * @returns + */ +export async function closeTicket( + user: Actor, + ticketId: string, + reasons: { public: string; private?: string | null }, + cleanup: boolean = false, +) { + const result = await editTicket(ticketId, { + closedAt: new Date(), + closeReason: reasons.public, + privateReason: reasons.private, + closedBy: user.id, + status: "closed", + }); + + if (!result) throw new PublicError("Failed to close ticket: Ticket not found in database"); + + const dbGuild = result.guild; + if (!dbGuild) throw new PublicError("Failed to close ticket: Guild not found in database"); + + const guild = await client.guilds.fetch(dbGuild.discordId); + if (!guild) throw new PublicError("Failed to close ticket: Guild not found in Discord"); + + if (!result.channelId) + throw new PublicError("Failed to close ticket: Channel not found in database"); + + const dbSubject = result.subjectActor; + if (!dbSubject || !dbSubject.discordId) + throw new PublicError("Failed to close ticket: Subject not found in database"); + + const channel = await guild.channels.fetch(result.channelId); + if (!channel || !channel.isTextBased()) + throw new PublicError("Failed to close ticket: Channel not found in database"); + + if (cleanup) { + channel + .delete() + .catch((err) => logger.error(err, `Failed to delete channel ${channel.id} during cleanup`)); + } else { + const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents( + TicketButtonPresets.reopenTicketButton(ticketId), + TicketButtonPresets.cleanTicketButton(ticketId), + ); + + messageChannel(channel, { + content: `This ticket has been closed by <@${user.discordId}>.`, + components: [actionRow], + flags: ["SuppressNotifications"], + }).catch((err) => logger.error(err, `Failed to message closed channel ${channel.id}`)); + + channel + .edit({ + name: channelName(result.localId, result.status), + permissionOverwrites: [ + { + id: dbSubject.discordId!, + deny: [PermissionFlagsBits.ViewChannel], + allow: [], + type: OverwriteType.Member, + }, + ], + }) + .catch((err) => + logger.error(err, `Failed to update name/permissions for closed channel ${channel.id}`), + ); + } + + (async () => { + try { + const botActor = await upsertGuildActor(guild); + if (!botActor) { + logger.warn("Failed to complete closeTicket notifications: Bot actor not found."); + return; + } + + const subject = await client.users.fetch(dbSubject.discordId!); + const container = getTicketContainer(result, true); + + const messageRes = await Result.fromAsync( + messageUser(subject, { + components: [container], + flags: ["IsComponentsV2"], + }), + ); + + if (messageRes.isErr()) { + await addTicketComment({ + actorId: botActor.id, + ticketId: result.id, + content: `User has DMs closed, a notification was not sent.`, + }); + } + + const logChannel = dbGuild.ticketLogChannel; + if (!logChannel) { + await addTicketComment({ + actorId: botActor.id, + ticketId: result.id, + content: `No log channel was found, a notification was not sent.`, + }); + } else { + const containerLong = getTicketContainer(result, false); + const commentContainer = getCommentContainer(result.localId, result.comments); + + const message = await Result.fromAsync( + messageChannel(logChannel, { + components: [containerLong, commentContainer], + flags: ["SuppressNotifications", "IsComponentsV2"], + allowedMentions: { parse: [] }, + }), + ); + + if (message.isErr()) { + await addTicketComment({ + actorId: botActor.id, + ticketId: result.id, + content: `Failed to send log message: ${message.unwrapErr()}`, + }); + } + } + } catch (err) { + logger.error(err, `Error performing background notification tasks for ticket ${result.id}`); + } + })(); + + return result; +} + +export async function reopenTicket(ctx: TicketContext) { + const { actor, guild, ticket, ticketChannel } = ctx; + if (ticket.status !== "closed") throw new PublicError("This ticket is not closed."); + + const canAccess = hasAccessToTicket(actor, guild, ticket, { requireModerator: true }); + if (!canAccess) throw new PublicError("You don't have permission to reopen this ticket."); + + const newTicket = await editTicket(ticket.id, { + status: "open", + closedAt: null, + closedBy: null, + + claimedBy: actor.id, + claimedAt: new Date(), + lastClaimantActivityAt: new Date(), + }); + + if (ticketChannel) { + const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents( + TicketButtonPresets.claimTicketButton(ticket.id), + TicketButtonPresets.closeTicketButton(ticket.id), + ); + + messageChannel(ticketChannel, { + content: `<@${actor.discordId}> reopened this ticket.`, + flags: ["SuppressNotifications"], + components: [actionRow], + }).catch((err) => + logger.error(err, `Failed to send reopen message to channel ${ticketChannel.id}`), + ); + + if (ticketChannel.type === ChannelType.GuildText) { + ticketChannel + .edit({ + name: channelName(ticket.localId, "claimed", actor.displayName || actor.username!), + permissionOverwrites: ticket.subjectActor + ? [ + { + id: ticket.subjectActor.discordId!, + type: OverwriteType.Member, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.AttachFiles, + ], + }, + ] + : undefined, + }) + .catch((err) => + logger.error( + err, + `Failed to update name/permissions on reopen for channel ${ticketChannel.id}`, + ), + ); + } + } + + return newTicket; +} + +export async function cleanTicket(ctx: TicketContext) { + const { ticket, actor, ticketChannel, guild } = ctx; + if (ticket.status !== "closed") + throw new Error("Tickets must be closed before they can be cleaned up."); + + const canAccess = hasAccessToTicket(actor, guild, ticket, { requireModerator: true }); + if (!canAccess) throw new PublicError("You don't have permission to clean up this ticket."); + if (!ticketChannel) throw new PublicError("Ticket channel not found."); + + const result = await editTicket(ticket.id, { + channelId: null, + }); + + ticketChannel + .delete("Ticket channel cleaned up") + .catch((err) => + logger.error(err, `failed to delete channel ${ticketChannel.id} during clean up`), + ); + + return result; +}
M
apps/bot/src/feats/tickets/commands.ts
→
apps/bot/src/feats/tickets/commands.ts
@@ -4,12 +4,12 @@ import { getTicketByGuild, getTicketByUser } from "@stealth-developers/db";
import { ActionRowBuilder, ButtonBuilder, ContainerBuilder, TextDisplayBuilder } from "discord.js"; import { PublicError, errorMessage, getTextChannel, handleError, logger } from "@/lib"; -import { hasAccessToTicket } from "@/lib/tickets"; import { useGetTicketData } from "@/middleware"; import * as actions from "./actions"; import { TicketButtonPresets } from "./buttons"; -import { manageConfig } from "./config"; +import { hasAccessToTicket } from "./lib"; +import { manageConfig } from "./lib/config"; import { TicketModalPresets } from "./modals"; const ticketCommand = useGetTicketData.command("ticket", {
M
apps/bot/src/feats/tickets/config.ts
→
apps/bot/src/feats/tickets/lib/config.ts
@@ -5,7 +5,7 @@ import { LabelBuilder, ModalBuilder, TextInputBuilder, TextInputStyle } from "discord.js";
import { errorMessage } from "@/lib"; -import { configTextModal } from "./modals"; +import { configTextModal } from "../modals"; type GuildKeys = keyof typeof guilds.$inferSelect; type ConfigOptionBase = {
M
apps/bot/src/feats/tickets/types.ts
→
apps/bot/src/feats/tickets/types.ts
@@ -1,9 +1,18 @@
import type { Actor, Guild as DbGuild, TicketWithRelations } from "@stealth-developers/db"; import type { Channel } from "discord.js"; +import type { AnyUser } from "@/types"; + export type TicketContext = { ticket: TicketWithRelations; ticketChannel?: Channel | null; actor: Actor; guild: DbGuild; }; + +export type CreateTicketArgs = + | undefined + | { + for: AnyUser; + reason?: string; + };