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
14 changes: 14 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ model Server {
tickets Ticket[]
autoroles AutoRole[]
automods AutoMod[]
confessions Confession[]

@@map("server")
}
Expand Down Expand Up @@ -126,4 +127,17 @@ model AutoMod {
@@unique([guildId, word])
@@index([guildId])
@@map("automods")
}

model Confession {
messageId String @id
guildId String
channelId String
threadId String
createdAt DateTime @default(now())

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

@@index([guildId])
@@map("confessions")
}
21 changes: 17 additions & 4 deletions src/commands/utility/confess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ export class ConfessCommand extends Command {
}

public override registerApplicationCommands(_registry: Command.Registry) {
// Registration is handled per-guild at runtime in the Ready listener
_registry.registerChatInputCommand((builder) =>
builder.setName('confess').setDescription('Create a confession')
);
}

public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) {
Expand All @@ -32,8 +34,13 @@ export class ConfessCommand extends Command {

const settings = await getSettings(interaction.guild.id, interaction.guild.name);

if (!settings.confessionChannelId || !settings.confessionEnabled) {
await interaction.reply({ content: `${emojis.rightArrow2} Confessions are not enabled or configured for this server.`, flags: MessageFlags.Ephemeral });
if (settings.confessionEnabled === false) {
await interaction.reply({ content: `${emojis.rightArrow2} Confessions are disabled in this server.`, flags: MessageFlags.Ephemeral });
return;
}

if (!settings.confessionChannelId) {
await interaction.reply({ content: `${emojis.rightArrow2} Confessions are not configured in this server.`, flags: MessageFlags.Ephemeral });
return;
}

Expand Down Expand Up @@ -87,7 +94,13 @@ export class ConfessCommand extends Command {

await message.edit({ components: [row] });

storeConfessionContext({ guildId: interaction.guild.id, channelId: confessionChannel.id, messageId: message.id, threadId: thread.id });
try {
await storeConfessionContext({ guildId: interaction.guild.id, channelId: confessionChannel.id, messageId: message.id, threadId: thread.id });
} catch (error) {
await thread.delete().catch(() => null);
await message.delete().catch(() => null);
throw error;
}

await modalSubmit.reply({ content: `${emojis.rightArrow2} Confession sent.`, flags: MessageFlags.Ephemeral });
}
Expand Down
130 changes: 96 additions & 34 deletions src/interaction-handlers/confession/confessionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,14 @@ import { getSettings } from '#lib/settings.js';
import { emojis } from '#utils/emoji.js';

function parseConfessionButton(customId: string) {
const [action, messageId] = customId.split(':');
const [action, ...parts] = customId.split(':');

if (action === 'delete-confession' && parts.length >= 3) {
const [guildId, channelId, messageId] = parts;
return { action, guildId, channelId, messageId };
}

const [messageId] = parts;
return { action, messageId };
}

Expand Down Expand Up @@ -126,17 +133,16 @@ export class ConfessionButtonHandler extends InteractionHandler {
.setLabel('Report')
.setStyle(ButtonStyle.Danger);

storeConfessionContext({
guildId: interaction.guild.id,
channelId: confessionChannel.id,
messageId: message.id,
threadId: thread.id
});

const row = new ActionRowBuilder<ButtonBuilder>().addComponents(reportButton);

try {
await message.edit({ components: [row] });
await storeConfessionContext({
guildId: interaction.guild.id,
channelId: confessionChannel.id,
messageId: message.id,
threadId: thread.id
});
} catch (error) {
await thread.delete().catch(() => null);
await message.delete().catch(() => null);
Expand All @@ -161,7 +167,7 @@ export class ConfessionButtonHandler extends InteractionHandler {
return;
}

const context = getConfessionContext(parsed.messageId);
const context = await getConfessionContext(parsed.messageId);

if (!context) {
await interaction.reply({
Expand Down Expand Up @@ -210,28 +216,38 @@ export class ConfessionButtonHandler extends InteractionHandler {
}

const reason = modalSubmit.fields.getTextInputValue('confession-report-reason');
const channel = await interaction.client.channels.fetch(context.channelId).catch(() => null);
await modalSubmit.deferReply({ flags: MessageFlags.Ephemeral });
let channel = null;

if (!(channel instanceof TextChannel)) {
await modalSubmit.reply({
content: `${emojis.rightArrow2} The confession channel is no longer available.`,
flags: MessageFlags.Ephemeral
try {
if (interaction.inGuild() && interaction.guild) {
channel = await interaction.guild.channels.fetch(context.channelId).catch(() => null);
} else {
channel = await interaction.client.channels.fetch(context.channelId).catch(() => null);
}
} catch (err) {
channel = null;
}

if (!channel || !channel.isTextBased()) {
console.error('Confession channel fetch failed', { channelId: context.channelId, context });
await modalSubmit.editReply({
content: `${emojis.rightArrow2} The confession channel (<#${context.channelId}>) is no longer available.`
});
return;
}

const message = await channel.messages.fetch(context.messageId).catch(() => null);

if (!message) {
await modalSubmit.reply({
content: `${emojis.rightArrow2} That confession no longer exists.`,
flags: MessageFlags.Ephemeral
await modalSubmit.editReply({
content: `${emojis.rightArrow2} That confession no longer exists.`
});
return;
}

const deleteButton = new ButtonBuilder()
.setCustomId(`delete-confession:${parsed.messageId}`)
.setCustomId(`delete-confession:${context.guildId}:${context.channelId}:${context.messageId}`)
.setLabel('Delete Confession')
.setStyle(ButtonStyle.Danger);

Expand Down Expand Up @@ -261,16 +277,14 @@ export class ConfessionButtonHandler extends InteractionHandler {
const delivered = deliveries.some((result) => result.status === 'fulfilled');

if (!delivered) {
await modalSubmit.reply({
await modalSubmit.editReply({
content: `${emojis.rightArrow2} I could not contact any configured moderators.`,
flags: MessageFlags.Ephemeral
});
return;
}

await modalSubmit.reply({
await modalSubmit.editReply({
content: `${emojis.rightArrow2} Your report was sent to the bot moderators.`,
flags: MessageFlags.Ephemeral
});
return;
}
Expand All @@ -283,7 +297,15 @@ export class ConfessionButtonHandler extends InteractionHandler {
return;
}

const context = getConfessionContext(parsed.messageId);
const context =
parsed.guildId && parsed.channelId && parsed.messageId
? {
guildId: parsed.guildId,
channelId: parsed.channelId,
messageId: parsed.messageId,
threadId: ''
}
: await getConfessionContext(parsed.messageId);

if (!context) {
await interaction.reply({
Expand All @@ -301,28 +323,68 @@ export class ConfessionButtonHandler extends InteractionHandler {
return;
}

const channel = await interaction.client.channels.fetch(context.channelId).catch(() => null);
let channel = null;

if (!(channel instanceof TextChannel)) {
try {
if (interaction.inGuild() && interaction.guild) {
channel = await interaction.guild.channels.fetch(context.channelId).catch(() => null);
} else {
const guild = await interaction.client.guilds.fetch(context.guildId).catch(() => null);
if (guild) {
channel = await guild.channels.fetch(context.channelId).catch(() => null);
}
if (!channel) {
channel = await interaction.client.channels.fetch(context.channelId).catch(() => null);
}
}
} catch (err) {
channel = null;
}

if (!channel || !channel.isTextBased()) {
console.error('Confession channel fetch failed (delete flow)', {
channelId: context.channelId,
guildId: context.guildId,
context
});

// Still allow removal from database if channel is gone
await removeConfessionContext(context.messageId);

await interaction.reply({
content: `${emojis.rightArrow2} The confession channel is no longer available.`
content: `${emojis.rightArrow2} The confession channel (<#${context.channelId}>) is no longer available, but the confession has been removed from records.`
});
return;
}

const message = await channel.messages.fetch(context.messageId).catch(() => null);

if (message) {
const deletedEmbed = new EmbedBuilder()
.setTitle('Confession')
.setDescription('This confession was deleted by moderators.')
.setColor(0xed4245)
.setTimestamp();
if (!message) {
// Message was already deleted, just clean up the record
await removeConfessionContext(context.messageId);

await interaction.reply({
content: `${emojis.rightArrow2} That confession no longer exists, but the record has been cleaned up.`
});
return;
}

const deletedEmbed = new EmbedBuilder()
.setTitle('Confession')
.setDescription('This confession was deleted by moderators.')
.setColor(0xed4245)
.setTimestamp();

await message.edit({ embeds: [deletedEmbed], components: [] }).catch(() => null);
try {
await message.edit({ embeds: [deletedEmbed], components: [] });
} catch {
await interaction.reply({
content: `${emojis.rightArrow2} I could not update the confession message.`
});
return;
}

removeConfessionContext(context.messageId);
await removeConfessionContext(context.messageId);

await interaction.reply({
content: `${emojis.rightArrow2} Confession marked as deleted.`
Expand Down
42 changes: 34 additions & 8 deletions src/lib/confessions.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { prisma } from "./prisma.js";

export type ConfessionContext = {
guildId: string;
channelId: string;
messageId: string;
threadId: string;
};

const confessionContexts = new Map<string, ConfessionContext>();

export function getModeratorIds() {
return [...new Set((process.env.MODERATORS ?? '').split(',').map((id) => id.trim()).filter(Boolean))];
}
Expand All @@ -15,14 +15,40 @@ export function buildConfessionLink(guildId: string, channelId: string, messageI
return `https://discord.com/channels/${guildId}/${channelId}/${messageId}`;
}

export function storeConfessionContext(context: ConfessionContext) {
confessionContexts.set(context.messageId, context);
export async function storeConfessionContext(context: ConfessionContext) {
await prisma.confession.upsert({
where: { messageId: context.messageId },
create: context,
update: context,
});
}

export function getConfessionContext(messageId: string) {
return confessionContexts.get(messageId) ?? null;
export async function getConfessionContext(messageId: string) {
return prisma.confession.findUnique({
where: { messageId },
select: {
guildId: true,
channelId: true,
messageId: true,
threadId: true,
},
});
}

export function removeConfessionContext(messageId: string) {
confessionContexts.delete(messageId);
export async function removeConfessionContext(messageId: string) {
await prisma.confession.deleteMany({
where: { messageId },
});
}

export async function removeConfessionContexts(messageIds: string[]) {
if (messageIds.length === 0) return;

await prisma.confession.deleteMany({
where: {
messageId: {
in: messageIds,
},
},
});
}
2 changes: 2 additions & 0 deletions src/listeners/messageDelete.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Listener } from '@sapphire/framework';
import { Events, EmbedBuilder, Colors, type Message, type PartialMessage } from 'discord.js';
import { removeConfessionContext } from '#lib/confessions.js';
import { isLoggingChannel, logEmbed, truncate } from '#lib/logging.js';

export class MessageDeleteListener extends Listener<typeof Events.MessageDelete> {
Expand All @@ -23,6 +24,7 @@ export class MessageDeleteListener extends Listener<typeof Events.MessageDelete>
.setFooter({ text: `ID: ${message.id}` })
.setTimestamp();

await removeConfessionContext(message.id).catch(() => null);
await logEmbed(guild, embed);
}
}
2 changes: 2 additions & 0 deletions src/listeners/messageDeleteBulk.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Listener } from '@sapphire/framework';
import { EmbedBuilder, Colors, type Collection } from 'discord.js';
import { removeConfessionContexts } from '#lib/confessions.js';
import { isLoggingChannel, logEmbed } from '#lib/logging.js';

export class MessageDeleteBulkListener extends Listener {
Expand All @@ -24,6 +25,7 @@ export class MessageDeleteBulkListener extends Listener {
)
.setTimestamp();

await removeConfessionContexts([...messages.keys()]).catch(() => null);
await logEmbed(guild, embed);
}
}