all repos — stealth-developers @ a589dc8e61388a32c604b15ce207ad4f80154ad8

bot: impl {un,}claiming, reopenning, commenting, editing tickets
vi did:web:vt3e.cat
Fri, 26 Jun 2026 18:51:56 +0100
commit

a589dc8e61388a32c604b15ce207ad4f80154ad8

parent

eeba7ee9bd66a3ccc20fe4385bd0b701b1a4ee3e

M apps/bot/src/feats/tickets/README.mdapps/bot/src/feats/tickets/README.md

@@ -4,11 +4,11 @@ - [x] for

- [x] view [ticketId] - [ ] actions - [x] close - - [ ] claim - - [ ] unclaim - - [ ] reopen - - [ ] comment [ticketId?] - - [ ] edit [ticketId?] + - [x] claim + - [x] unclaim + - [x] reopen + - [x] comment [ticketId?] + - [x] edit [ticketId?] - [ ] clean - /ticket delete - [ ] participants - [ ] add
M apps/bot/src/feats/tickets/actions.tsapps/bot/src/feats/tickets/actions.ts

@@ -334,7 +334,7 @@ ticketId: result.id,

content: `No log channel was found, a notification was not sent.`, }); } else { - const container = await getTicketContainer(result, false); + const container = getTicketContainer(result, false); const message = await Result.fromAsync( messageChannel(logChannel, { components: [container],

@@ -375,7 +375,7 @@ if (bad.includes(initNumber)) return `${status}-filtered`;

if (claimedBy) { const firstLetters = claimedBy.slice(0, 3); - return `${firstLetters}-${initNumber}`; + return `${firstLetters}-${status}-${initNumber}`; } return `${status}-ticket-${localId}`;
M apps/bot/src/feats/tickets/commands.tsapps/bot/src/feats/tickets/commands.ts

@@ -3,6 +3,7 @@ import { Result } from "@sapphire/result";

import { ButtonBuilder, ContainerBuilder, TextDisplayBuilder, ActionRowBuilder } from "discord.js"; import { addTicketComment, + editTicket, getTicketByChannelId, getTicketByGuild, getTicketByUser,

@@ -12,12 +13,33 @@ import { errorMessage, getTextChannel, logger, PublicError } from "@/lib";

import { TicketButtonPresets } from "./buttons"; import * as actions from "./actions"; import { manageConfig } from "./config"; -import { getBasicData } from "@/middleware"; +import { useGetData } from "@/middleware"; import { hasAccessToTicket } from "@/lib/tickets"; import { TicketModalPresets } from "./modals"; import { ChannelType } from "discord-api-types/v9"; -const ticketCommand = getBasicData.command("ticket", { +const useGetTicketData = useGetData.use(async (interaction, { guild }) => { + if (interaction.isChatInputCommand()) { + const ticketId = interaction.options.getNumber("ticket", false); + if (ticketId && guild) { + const ticket = await getTicketByGuild(guild.id, ticketId); + if (!ticket) return {}; + return { ticket }; + } + } + + if (!interaction.channel) return {}; + + const ticket = await getTicketByChannelId(interaction.channel.id); + if (!ticket) return {}; + + if (!ticket.channelId) return { ticket }; + const ticketChannel = await getTextChannel(ticket.channelId, true); + + return { ticket, ticketChannel }; +}); + +const ticketCommand = useGetTicketData.command("ticket", { description: "Ticket-related commands", });

@@ -87,12 +109,12 @@

ticketCommand.subcommand("view", { description: "View a ticket", options: { - id: option.string("The ticket's ID", { required: true }), + ticket: option.number("The ticket's ID", { required: true }), }, async run(interaction, args, { guild, actor, user }) { if (guild && actor) { - const ticket = await getTicketByGuild(guild.id, Number(args.id)); - if (!ticket) return errorMessage(interaction, `Ticket with ID ${args.id} not found.`); + const ticket = await getTicketByGuild(guild.id, args.ticket); + if (!ticket) return errorMessage(interaction, `Ticket with ID ${args.ticket} not found.`); if (!hasAccessToTicket(actor, guild, ticket)) return;

@@ -102,8 +124,8 @@ return;

} if (user) { - const ticket = await getTicketByUser(user.id, Number(args.id)); - if (!ticket) return errorMessage(interaction, `Ticket with ID ${args.id} not found.`); + const ticket = await getTicketByUser(user.id, args.ticket); + if (!ticket) return errorMessage(interaction, `Ticket with ID ${args.ticket} not found.`); const container = actions.getTicketContainer(ticket, false); await interaction.reply({ components: [container], flags: ["Ephemeral", "IsComponentsV2"] });

@@ -136,39 +158,159 @@ await interaction.showModal(modal);

}, }); - // TODO)) claim - // TODO)) unclaim - // TODO)) reopen + group.subcommand("reopen", { + description: "Reopen a ticket", + async run(interaction, _, { actor, guild, ticket, ticketChannel }) { + if (!interaction.guild || !guild || !actor) { + return errorMessage(interaction, "This command can only be used in a server."); + } else if (!actor.moderator) { + return errorMessage(interaction, "You do not have permission to use this command."); + } else if (!ticket) { + return errorMessage(interaction, "This command can only be used in a ticket."); + } + + await editTicket(ticket.id, { + status: "open", + closedAt: null, + closedBy: null, + + claimedBy: actor.id, + claimedAt: new Date(), + lastClaimantActivityAt: new Date(), + }); + + if (ticketChannel) { + actions.messageChannel(ticketChannel, { + content: `<@${interaction.user.id}> reopened this ticket.`, + flags: ["SuppressNotifications"], + }); + + if (ticketChannel.type === ChannelType.GuildText) { + ticketChannel.edit({ + name: actions.channelName(ticket.localId, "claimed", interaction.user.displayName), + }); + } + } + + await interaction.reply({ + content: `Ticket has been reopened.`, + flags: ["Ephemeral"], + }); + }, + }); + + group.subcommand("claim", { + description: "Claim the ticket", + async run(interaction, _, { actor, ticket, ticketChannel }) { + if (!interaction.guild) { + return errorMessage(interaction, "This command can only be used in a server."); + } + + if (!ticket) { + return errorMessage(interaction, "This command can only be used in a ticket."); + } else if (!actor?.moderator) { + return errorMessage(interaction, "You do not have permission to use this command."); + } else if (ticket.status !== "open") { + return errorMessage(interaction, "This ticket is not open."); + } + + 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); + return errorMessage( + interaction, + `This ticket was claimed by <@${ticket.claimedByActor?.discordId}> <t:${timeAgo}:R>.`, + ); + } + + await editTicket(ticket.id, { + claimedAt: new Date(), + claimedBy: actor.id, + lastClaimantActivityAt: new Date(), + }); + + if (ticketChannel) { + actions.messageChannel(ticketChannel, { + content: `<@${interaction.user.id}> has claimed this ticket, they will be with you shortly.`, + flags: ["SuppressNotifications"], + }); + + if (ticketChannel.type === ChannelType.GuildText) { + ticketChannel.edit({ + name: actions.channelName(ticket.localId, "claimed", interaction.user.displayName), + }); + } + } + + await interaction.reply({ + content: "Ticket claimed!", + flags: ["Ephemeral"], + }); + }, + }); + + group.subcommand("unclaim", { + description: "Unclaim a ticket", + async run(interaction, _, { actor, ticket, ticketChannel }) { + if (!interaction.guild) { + return errorMessage(interaction, "This command can only be used in a server."); + } + + if (!ticket) { + return errorMessage(interaction, "This command can only be used in a ticket."); + } else if (!actor?.moderator) { + return errorMessage(interaction, "You do not have permission to use this command."); + } else if (ticket.status !== "open") { + return errorMessage(interaction, "This ticket is not open."); + } + + if (ticket.claimedBy !== actor.id) { + return errorMessage(interaction, "You have not claimed this ticket."); + } + + await editTicket(ticket.id, { + claimedAt: null, + claimedBy: null, + lastClaimantActivityAt: null, + }); + + if (ticketChannel) { + actions.messageChannel(ticketChannel, { + content: `<@${interaction.user.id}> unclaimed this ticket, someone else will be with you shortly.`, + flags: ["SuppressNotifications"], + }); + + if (ticketChannel.type === ChannelType.GuildText) { + ticketChannel.edit({ + name: actions.channelName(ticket.localId, "open"), + }); + } + } + + await interaction.reply({ + content: `You have unclaimed ticket #${ticket.localId}.`, + flags: ["Ephemeral"], + }); + }, + }); group.subcommand("comment", { description: "Add a comment to the ticket", options: { - id: option.number("The ID of the ticket", { required: false }), + ticket: option.number("The ID of the ticket", { required: false }), comment: option.string("The comment to add", { required: false }), }, - async run(interaction, { id, comment }, { actor, guild }) { + async run(interaction, { comment }, { actor, guild, ticket }) { if (!interaction.guild || !guild || !actor) return errorMessage(interaction, "This command can only be used in a server."); if (!actor.moderator) return errorMessage(interaction, "You do not have permission to use this command."); - let ticketId = id; - if (!id && !interaction.channel) - errorMessage(interaction, "Either provide an ID or run this in a ticket."); - - if (!id && interaction.channel) { - if (interaction.channel.type !== ChannelType.GuildText) - return errorMessage(interaction, "Either provide an ID or run this in a ticket."); - - const name = interaction.channel.name; - const segments = name.split("-"); - ticketId = parseInt(segments[segments.length - 1]!); - } - - if (!ticketId) return errorMessage(interaction, "Ticket not found."); - - const ticket = await getTicketByGuild(guild.id, ticketId); if (!ticket) return errorMessage(interaction, "Ticket not found."); if (comment) {

@@ -189,7 +331,28 @@ await interaction.showModal(TicketModalPresets.comment(ticket.id));

}, }); - // TODO)) edit + group.subcommand("edit", { + description: "Edit the reasons for closing a ticket", + options: { + ticket: option.number("The ticket to edit", { required: false }), + }, + async run(interaction, _, { actor, ticket }) { + if (!actor) { + return errorMessage(interaction, "This command can only be used in a server."); + } else if (!actor.moderator) { + return errorMessage(interaction, "You do not have permission to use this command."); + } else if (!ticket) { + return errorMessage(interaction, "Ticket not found."); + } + + await interaction.showModal( + TicketModalPresets.edit(ticket.id, { + public: ticket.closeReason, + private: ticket.privateReason, + }), + ); + }, + }); // TODO)) clean }, );
M apps/bot/src/feats/tickets/modals.tsapps/bot/src/feats/tickets/modals.ts

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

import db, { addTicketComment, + editTicket, eq, getActorByDiscordIdAndGuildDiscordId, getGuildByDiscordId,

@@ -77,6 +78,39 @@ return interaction.followUp({ content: "Ticket closed successfully" });

}, }); +export const editReasonModal = kitten.modal("edit-ticket", { + options: { + ticketId: option.string({ required: true }), + }, + run: async (interaction, { ticketId }) => { + await interaction.deferReply({ flags: ["Ephemeral"] }); + if (!interaction.guild) + return errorMessage(interaction, "You must run this command in a guild"); + + const ticket = await getTicket(ticketId); + if (!ticket) return errorMessage(interaction, "Ticket not found"); + + const user = await upsertUser(interaction.user, interaction.guild); + if (!user) return errorMessage(interaction, "Failed to upsert user"); + + const reasons = { + public: interaction.fields.getTextInputValue("reason:public") || null, + private: interaction.fields.getTextInputValue("reason:private") || null, + }; + + const result = await Result.fromAsync( + editTicket(ticketId, { + closeReason: reasons.public ? reasons.public : ticket.closeReason, + privateReason: reasons.private ? reasons.private : ticket.privateReason, + }), + ); + if (result.isErr()) + return handleError(interaction, result.unwrapErr(), "Failed to edit ticket."); + + return interaction.followUp({ content: "Ticket edited successfully" }); + }, +}); + export const commentModal = kitten.modal("m-comment-modal", { options: { ticketId: option.string({ required: true }),

@@ -136,6 +170,36 @@ .addLabelComponents(...labels);

return modal; }, + edit: (id: string, reasons: { public?: string | null; private?: string | null }) => { + const reasonLabel = new LabelBuilder() + .setLabel("Reason") + .setDescription("The reason for closing the ticket; this will be shared with the user") + .setTextInputComponent( + new TextInputBuilder() + .setStyle(TextInputStyle.Paragraph) + .setCustomId("reason:public") + .setValue(reasons.public ?? "") + .setRequired(false), + ); + + const privateReasonLabel = new LabelBuilder() + .setLabel("Private Reason") + .setDescription("The reason for closing the ticket.") + .setTextInputComponent( + new TextInputBuilder() + .setStyle(TextInputStyle.Paragraph) + .setCustomId("reason:private") + .setValue(reasons.private ?? "") + .setRequired(false), + ); + + const modal = new ModalBuilder() + .setCustomId(editReasonModal.id({ ticketId: id })) + .setTitle("Edit ticket") + .addLabelComponents([reasonLabel, privateReasonLabel]); + + return modal; + }, comment: (id: string) => { const commentLabel = new LabelBuilder() .setLabel("Comment")

@@ -156,4 +220,4 @@ return modal;

}, }; -export const TicketModals = [closeTicketModal, configTextModal, commentModal]; +export const TicketModals = [closeTicketModal, configTextModal, commentModal, editReasonModal];
M apps/bot/src/lib/interactions.tsapps/bot/src/lib/interactions.ts

@@ -2,7 +2,7 @@ import type { BaseInteraction, InteractionReplyOptions } from "discord.js";

type Flags = InteractionReplyOptions["flags"]; -export function errorMessage(interaction: BaseInteraction, message: string, ephemeral = false) { +export function errorMessage(interaction: BaseInteraction, message: string, ephemeral = true) { const flags: Flags = ephemeral ? ["Ephemeral"] : []; if (!interaction.isCommand()) return;
M apps/bot/src/middleware/index.tsapps/bot/src/middleware/index.ts

@@ -5,7 +5,7 @@ import { upsertUser } from "@/lib";

const base = kitten.builder(); -export const getUser = base +export const useGetData = base .use(async (interaction) => { if (!interaction.guild) return {};

@@ -36,5 +36,3 @@ throw new HaltExecution("Failed to find your guild in the database. Please try again.");

return { guild }; }); - -export const getBasicData = getUser;