Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ model Server {
id String @id
name String
blacklisted Boolean @default(false)
nextTicketNumber Int @default(1)
settings Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Expand All @@ -19,6 +20,7 @@ model Server {
mutes Mute[]
bans Ban[]
reminders Reminder[]
tickets Ticket[]

@@map("server")
}
Expand Down Expand Up @@ -80,3 +82,19 @@ model Reminder {
@@index([remindAt])
@@index([userId])
}

model Ticket {
id String @id @default(cuid())
guildId String
ticketNumber Int
channelId String? @unique
userId String
reason String?
createdAt DateTime @default(now())

server Server @relation(fields: [guildId], references: [id], onDelete: Cascade)

@@unique([guildId, ticketNumber])
@@index([guildId, userId])
@@map("tickets")
}
12 changes: 11 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,24 @@ import {
RegisterBehavior,
SapphireClient
} from '@sapphire/framework';
import { GatewayIntentBits } from 'discord.js';
import { ActivityType, GatewayIntentBits } from 'discord.js';

ApplicationCommandRegistries.setDefaultBehaviorWhenNotIdentical(
RegisterBehavior.BulkOverwrite
);

const client = new SapphireClient({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers],
presence: {
activities: [
{
name: 'Tickets!',
type: ActivityType.Playing,
state: 'New Feature'
}
],
status: 'online'
},
baseUserDirectory: fileURLToPath(new URL('.', import.meta.url))
});

Expand Down
68 changes: 39 additions & 29 deletions src/interaction-handlers/ticket/createTicketHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from 'discord.js';
import { TextInputBuilder, TextInputStyle, type ButtonInteraction } from 'discord.js';
import { getSettings, updateSettings } from '#lib/settings.js';
import { createTicket, removeTicket, setTicketChannelId } from '#lib/tickets.js';
import { emojis } from '#utils/emoji.js';

export class ButtonHandler extends InteractionHandler {
Expand Down Expand Up @@ -84,37 +85,46 @@ export class ButtonHandler extends InteractionHandler {
}
}

const channel = await interaction.guild.channels.create({
name: `ticket-${interaction.user.username}`,
type: ChannelType.GuildText,
parent,
permissionOverwrites: [
{
id: interaction.guild.id,
deny: [PermissionFlagsBits.ViewChannel]
},
{
id: interaction.user.id,
allow: [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory
]
},
{
id: interaction.client.user.id,
allow: [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory,
PermissionFlagsBits.ManageChannels
]
}
]
});
const ticket = await createTicket(interaction.guild.id, interaction.guild.name, interaction.user.id, reason);

const channel = await interaction.guild.channels
.create({
name: `${ticket.ticketNumber}-${interaction.user.username}`,
type: ChannelType.GuildText,
parent,
permissionOverwrites: [
{
id: interaction.guild.id,
deny: [PermissionFlagsBits.ViewChannel]
},
{
id: interaction.user.id,
allow: [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory
]
},
{
id: interaction.client.user.id,
allow: [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory,
PermissionFlagsBits.ManageChannels
]
}
]
})
.catch(async (error) => {
await removeTicket(ticket.id).catch(() => null);
throw error;
});

await setTicketChannelId(ticket.id, channel.id);

await modalSubmit.reply({
content: `${emojis.rightArrow2} Created a ticket!`,
content: `${emojis.rightArrow2} Created ticket <#${channel.id}>!`,
flags: MessageFlags.Ephemeral
});

Expand Down
55 changes: 50 additions & 5 deletions src/interaction-handlers/ticket/removeTicketHandler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { InteractionHandler, InteractionHandlerTypes } from '@sapphire/framework';
import { MessageFlags } from 'discord.js';
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js';
import type { ButtonInteraction } from 'discord.js';
import { emojis } from '#utils/emoji.js';
import { getTicketId, removeTicket } from '#lib/tickets.js';

export class ButtonHandler extends InteractionHandler {
public constructor(ctx: InteractionHandler.LoaderContext, options: InteractionHandler.Options) {
Expand All @@ -12,7 +13,13 @@ export class ButtonHandler extends InteractionHandler {
}

public override parse(interaction: ButtonInteraction) {
if (interaction.customId !== 'remove-ticket') return this.none();
if (
interaction.customId !== 'remove-ticket' &&
interaction.customId !== 'confirm-remove-ticket' &&
interaction.customId !== 'cancel-remove-ticket'
) {
return this.none();
}

return this.some();
}
Expand All @@ -34,6 +41,14 @@ export class ButtonHandler extends InteractionHandler {
return;
}

if (interaction.customId === 'cancel-remove-ticket') {
await interaction.update({
content: `${emojis.rightArrow2} Ticket closure cancelled.`,
components: []
});
return;
}

const channel = interaction.channel;

if (!channel || !('deletable' in channel) || !channel.deletable) {
Expand All @@ -44,8 +59,38 @@ export class ButtonHandler extends InteractionHandler {
return;
}

await channel.delete(
`Ticket channel "${channel.name}" (${channel.id}) closed by ${interaction.user.tag} (${interaction.user.id}).`
);
if (interaction.customId === 'remove-ticket') {
const confirmButton = new ButtonBuilder()
.setCustomId('confirm-remove-ticket')
.setLabel('Confirm Close')
.setStyle(ButtonStyle.Danger);

const cancelButton = new ButtonBuilder()
.setCustomId('cancel-remove-ticket')
.setLabel('Cancel')
.setStyle(ButtonStyle.Secondary);

const row = new ActionRowBuilder<ButtonBuilder>().addComponents(confirmButton, cancelButton);

await interaction.reply({
content: `${emojis.rightArrow2} Are you sure you want to close this ticket?`,
components: [row],
flags: MessageFlags.Ephemeral
});
return;
}

await interaction.update({
content: `${emojis.rightArrow2} Closing ticket...`,
components: []
});

const ticket = await getTicketId(interaction.guild.id, channel.id);

if (ticket) {
await removeTicket(ticket.id);
}

await channel.delete(`Ticket closed by ${interaction.user.tag}.`);
}
}
71 changes: 71 additions & 0 deletions src/lib/tickets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { prisma } from "./prisma.js";

export async function createTicket(
guildId: string,
guildName: string,
userId: string,
reason: string,
) {
return prisma.$transaction(async (tx) => {
await tx.server.upsert({
where: { id: guildId },
create: { id: guildId, name: guildName },
update: { name: guildName },
});

const server = await tx.server.update({
where: { id: guildId },
data: {
nextTicketNumber: {
increment: 1,
},
},
select: {
nextTicketNumber: true,
},
});

return tx.ticket.create({
data: {
guildId,
ticketNumber: server.nextTicketNumber - 1,
userId,
reason,
},
});
});
}

export async function setTicketChannelId(ticketId: string, channelId: string) {
return prisma.ticket.update({
where: { id: ticketId },
data: { channelId },
});
}

export async function getTickets(guildId: string, userId: string) {
return prisma.ticket.findMany({
where: { guildId, userId },
orderBy: { createdAt: 'desc' },
});
}

export async function removeTicket(ticketId: string) {
return prisma.ticket.delete({ where: { id: ticketId } });
}

export async function clearTickets(guildId: string, userId: string) {
return prisma.ticket.deleteMany({ where: { guildId, userId } });
}

export async function getTicket(ticketId: string, guildId: string) {
const ticket = await prisma.ticket.findUnique({ where: { id: ticketId } });
if (!ticket || ticket.guildId !== guildId) return null;
return ticket;
}

export async function getTicketId(guildId: string, channelId: string) {
return prisma.ticket.findFirst({
where: { guildId, channelId }
});
}
13 changes: 1 addition & 12 deletions src/listeners/ready.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Listener } from '@sapphire/framework';
import { ActivityType, Events, type Client } from 'discord.js';
import { Events, type Client } from 'discord.js';
import { enforceMute, getActiveMutes } from '#lib/mutes.js';
import { heartbeat } from '#utils/heartbeat.js';
import { purgeExpiredBans } from '#lib/bans.js';
Expand All @@ -21,17 +21,6 @@ export class ReadyListener extends Listener<typeof Events.ClientReady> {
heartbeat(client);
reminderScheduler(client);

client.user.setPresence({
activities: [
{
name: '🎫 Tickets!',
type: ActivityType.Watching,
state: 'New Feature!'
}
],
status: 'idle'
});

const enforceMutes = async () => {
const mutes = await getActiveMutes();
for (const mute of mutes) {
Expand Down