cleanup: remove strip backup files

This commit is contained in:
2026-04-21 15:57:51 +00:00
parent 636348d824
commit 1a46fb696a
10 changed files with 3 additions and 5022 deletions

View File

@@ -1,770 +0,0 @@
/**
* Button interaction handlers claim, close, priority, tag delete,
* open-ticket panel button, and ticket_modal submission.
*/
const {
ChannelType,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
AttachmentBuilder,
EmbedBuilder,
PermissionFlagsBits,
ModalBuilder,
TextInputBuilder,
TextInputStyle
} = require('discord.js');
const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config');
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit, getSenderLocal, toDiscordSafeName } = require('../services/tickets');
const { sendTicketClosedEmail } = require('../services/gmail');
const { getTicketActionRow } = require('../utils/ticketComponents');
const { sanitizeEmbedText, truncateEmbedDescription, truncateEmbedField, enforceEmbedLimit } = require('../utils');
const { setEmailRouting } = require('../services/guildSettings');
const { enqueueRename, enqueueSend } = require('../services/channelQueue');
const { runEscalation, runDeescalation } = require('./commands');
const { trackInteraction, trackError } = require('./analytics');
const { pendingCloses } = require('./pendingCloses');
const { increment } = require('../services/patternStore');
const { logError, logSystem } = require('../services/debugLog');
const Ticket = mongoose.model('Ticket');
const Transcript = mongoose.model('Transcript');
const Tag = mongoose.model('Tag');
const User = mongoose.model('User');
/**
* Main button/modal handler called from interactionCreate.
*/
async function handleButton(interaction) {
// --- "Open Ticket" panel buttons → show modal ---
if (interaction.customId === 'open_ticket' || interaction.customId === 'open_ticket_thread' || interaction.customId === 'open_ticket_channel') {
const modalCustomId = interaction.customId === 'open_ticket'
? 'ticket_modal'
: interaction.customId === 'open_ticket_thread'
? 'ticket_modal_thread'
: 'ticket_modal_channel';
const modal = new ModalBuilder()
.setCustomId(modalCustomId)
.setTitle('Please Enter Your Information');
const emailInput = new TextInputBuilder()
.setCustomId('ticket_email')
.setLabel('Account Email:')
.setStyle(TextInputStyle.Short)
.setPlaceholder('Example: broccoli@indifferentbroccoli.com')
.setRequired(true)
.setMaxLength(100);
const gameInput = new TextInputBuilder()
.setCustomId('ticket_game')
.setLabel('What game do you need help with?')
.setStyle(TextInputStyle.Short)
.setPlaceholder('Example: Project Zomboid, Minecraft')
.setRequired(true)
.setMaxLength(100);
const descriptionInput = new TextInputBuilder()
.setCustomId('ticket_description')
.setLabel('What do you need help with?')
.setStyle(TextInputStyle.Paragraph)
.setPlaceholder("Example: I can't connect to my server.")
.setRequired(true)
.setMaxLength(1000);
modal.addComponents(
new ActionRowBuilder().addComponents(emailInput),
new ActionRowBuilder().addComponents(gameInput),
new ActionRowBuilder().addComponents(descriptionInput)
);
return await interaction.showModal(modal);
}
// --- Email routing (no ticket required) ---
if (interaction.customId === 'email_routing_thread' || interaction.customId === 'email_routing_category') {
const value = interaction.customId === 'email_routing_thread' ? 'thread' : 'category';
try {
await setEmailRouting(interaction.guild.id, value);
const label = value === 'thread' ? '**threads**' : '**channels in a category**';
await interaction.reply({
content: `Done. New email tickets will now be created as ${label}.`,
ephemeral: true
});
} catch (err) {
trackError('email-routing-button', err, interaction);
await interaction.reply({
content: 'Failed to update email routing.',
ephemeral: true
}).catch(() => {});
}
return;
}
// --- Ticket-scoped buttons (need ticket lookup) ---
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
if (!ticket) {
return interaction.reply({
content: 'This channel is not linked to a ticket, or the ticket could not be found.',
ephemeral: true
});
}
// --- CLAIM / UNCLAIM ---
if (interaction.customId === 'claim_ticket') {
return handleClaim(interaction, ticket);
}
// --- CLOSE ---
if (interaction.customId === 'close_ticket') {
const confirmRow = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('confirm_close')
.setLabel('Confirm Close')
.setStyle(ButtonStyle.Danger),
new ButtonBuilder()
.setCustomId('cancel_close')
.setLabel('Cancel')
.setStyle(ButtonStyle.Secondary)
);
return interaction.reply({
content: 'Are you sure you want to close this ticket?',
components: [confirmRow]
});
}
if (interaction.customId === 'confirm_close') {
const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
if (pendingCloses.has(interaction.channel.id)) {
return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true });
}
const cancelRow = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('cancel_close')
.setLabel('Cancel Close')
.setStyle(ButtonStyle.Secondary)
);
await interaction.update({ content: `Closing ticket in ${timerSeconds} seconds.`, components: [cancelRow] });
const timerId = setTimeout(async () => {
pendingCloses.delete(interaction.channel.id);
const freshTicket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
if (!freshTicket || freshTicket.status === 'closed') return;
const { logTicketEvent } = require('../services/debugLog');
logTicketEvent('Force-close timer fired', [
{ name: 'Ticket', value: interaction.channel.name || interaction.channel.id },
{ name: 'Set by', value: interaction.user.tag },
{ name: 'Duration', value: `${timerSeconds}s` }
]).catch(() => {});
await handleConfirmClose(interaction, freshTicket);
}, timerSeconds * 1000);
pendingCloses.set(interaction.channel.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag });
return;
}
if (interaction.customId === 'cancel_close') {
const pending = pendingCloses.get(interaction.channel.id);
if (pending) {
clearTimeout(pending.timeout);
pendingCloses.delete(interaction.channel.id);
}
return interaction.update({ content: 'Close cancelled.', components: [] });
}
// --- ESCALATE (prompt for tier 2 or 3) ---
if (interaction.customId === 'escalate_ticket') {
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier >= 2) {
return interaction.reply({ content: 'This ticket is already at tier 3 support.', ephemeral: true });
}
const escalateButtons = [];
if (currentTier < 1) {
escalateButtons.push(
new ButtonBuilder()
.setCustomId('escalate_to_tier2')
.setLabel('To Tier 2')
.setStyle(ButtonStyle.Secondary)
);
}
if (currentTier < 2) {
escalateButtons.push(
new ButtonBuilder()
.setCustomId('escalate_to_tier3')
.setLabel('To Tier 3')
.setStyle(ButtonStyle.Secondary)
);
}
const choiceRow = new ActionRowBuilder().addComponents(escalateButtons);
return interaction.reply({
content: 'Escalate to which tier?',
components: [choiceRow],
ephemeral: true
});
}
if (interaction.customId === 'escalate_to_tier2') {
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier >= 1) {
return interaction.reply({ content: 'This ticket is already at tier 2.', ephemeral: true });
}
const categoryId = ticket.gmailThreadId.startsWith('discord-')
? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID
: CONFIG.EMAIL_ESCALATED2_CHANNEL_ID;
if (!categoryId && !interaction.channel.isThread()) {
return interaction.reply({ content: 'Tier 2 (ESCALATED2) is not configured for this ticket type.', ephemeral: true });
}
try {
await interaction.deferReply();
await runEscalation(interaction, ticket, 1, null);
} catch (err) {
trackError('escalate-button-tier2', err, interaction);
await interaction.editReply({ content: 'Failed to escalate to tier 2.' }).catch(() =>
interaction.followUp({ content: 'Failed to escalate to tier 2.', ephemeral: true }).catch(() => {})
);
}
return;
}
if (interaction.customId === 'escalate_to_tier3') {
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier >= 2) {
return interaction.reply({ content: 'This ticket is already at tier 3.', ephemeral: true });
}
const categoryId = ticket.gmailThreadId.startsWith('discord-')
? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID
: CONFIG.EMAIL_ESCALATED3_CHANNEL_ID;
if (!categoryId && !interaction.channel.isThread()) {
return interaction.reply({ content: 'Tier 3 (ESCALATED3) is not configured for this ticket type.', ephemeral: true });
}
try {
await interaction.deferReply();
await runEscalation(interaction, ticket, 2, null);
} catch (err) {
trackError('escalate-button-tier3', err, interaction);
await interaction.editReply({ content: 'Failed to escalate to tier 3.' }).catch(() =>
interaction.followUp({ content: 'Failed to escalate to tier 3.', ephemeral: true }).catch(() => {})
);
}
return;
}
// --- DEESCALATE ---
if (interaction.customId === 'deescalate_ticket') {
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier === 0) {
return interaction.reply({ content: 'This ticket is not escalated.', ephemeral: true });
}
try {
await interaction.deferReply({ ephemeral: true });
await runDeescalation(interaction, ticket);
} catch (err) {
trackError('deescalate-button', err, interaction);
await interaction.editReply({ content: 'Failed to deescalate this ticket.' }).catch(() =>
interaction.followUp({ content: 'Failed to deescalate this ticket.', ephemeral: true }).catch(() => {})
);
}
return;
}
// --- TAG DELETE CONFIRM ---
if (interaction.customId.startsWith('confirm_delete_tag::')) {
trackInteraction('buttons', 'confirm-delete-tag', interaction.user.tag);
const tagName = interaction.customId.slice('confirm_delete_tag::'.length);
try {
const result = await Tag.deleteOne({ name: tagName });
if (result.deletedCount === 0) {
await interaction.update({
content: `❌ Tag "${tagName}" not found.`,
components: []
});
} else {
await interaction.update({
content: `✅ Tag "${tagName}" deleted successfully.`,
components: []
});
}
} catch (err) {
trackError('tag-delete-confirm', err, interaction);
await interaction.update({
content: '❌ Failed to delete tag.',
components: []
});
}
}
if (interaction.customId === 'cancel_delete_tag') {
return interaction.update({ content: 'Tag deletion cancelled.', components: [] });
}
// Priority is set via /priority slash command only; no priority buttons in tickets.
}
// --- CLAIM LOGIC ---
async function handleClaim(interaction, ticket) {
const freshTicket = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId }).lean();
if (!freshTicket) {
return interaction.reply({ content: 'Ticket data missing.', ephemeral: true });
}
const isClaimed = !!freshTicket.claimedBy;
const claimerLabel =
interaction.member?.displayName || interaction.user.username;
const guild = interaction.guild;
const isClaimedByMe = freshTicket.claimedBy === claimerLabel;
const [row0] = interaction.message.components;
if (!row0) {
return interaction.reply({ content: 'No components to update.', ephemeral: true });
}
const row = ActionRowBuilder.from(row0);
const [btnClose, btnClaim] = row.components;
if (!btnClose || !btnClaim) {
return interaction.reply({ content: 'Buttons missing.', ephemeral: true });
}
if (isClaimed && !isClaimedByMe && !CONFIG.ALLOW_CLAIM_OVERWRITE) {
const { logSecurity } = require('../services/debugLog');
logSecurity('Unauthorized button attempt', interaction.user, interaction.customId).catch(() => {});
return interaction.reply({
content: `This ticket is already claimed by **${freshTicket.claimedBy}**.`,
ephemeral: true
});
}
if (!isClaimed || (isClaimed && !isClaimedByMe && CONFIG.ALLOW_CLAIM_OVERWRITE)) {
await Ticket.updateOne(
{ gmailThreadId: freshTicket.gmailThreadId },
{ $set: { claimedBy: claimerLabel, claimerId: interaction.user.id } }
);
freshTicket.claimedBy = claimerLabel;
freshTicket.claimerId = interaction.user.id;
increment('staff_claims', interaction.user.id, 'today');
increment('staff_claims', interaction.user.id, 'week');
// Resolve claimerEmoji from STAFF_EMOJIS map (fallback to CLAIMER_EMOJI_FALLBACK)
const claimerEmoji = CONFIG.STAFF_EMOJIS.get(interaction.user.id) || CONFIG.CLAIMER_EMOJI_FALLBACK;
const creatorNickname = await resolveCreatorNickname(guild, freshTicket);
const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed';
const newName = makeTicketName(state, freshTicket, creatorNickname, claimerEmoji);
enqueueRename(interaction.channel, newName).catch(err => logError('rename', err).catch(() => {}));
const label = `Unclaim (${claimerLabel})`;
btnClose
.setCustomId('close_ticket')
.setLabel(CONFIG.BUTTON_LABEL_CLOSE)
.setEmoji(CONFIG.BUTTON_EMOJI_CLOSE)
.setStyle(ButtonStyle.Secondary)
.setDisabled(false);
btnClaim
.setCustomId('claim_ticket')
.setEmoji(CONFIG.BUTTON_EMOJI_UNCLAIM)
.setStyle(ButtonStyle.Secondary)
.setDisabled(false)
.setLabel(label);
await interaction.update({ components: [row] });
const claimText = CONFIG.TICKET_CLAIMED_MESSAGE
.replace(/\{staff_mention\}/g, interaction.user.toString())
.replace(/\{staff_name\}/g, interaction.member?.displayName || interaction.user.username);
const claimEmbed = new EmbedBuilder()
.setTitle('✅ Ticket Claimed')
.setDescription(claimText)
.setColor(CONFIG.EMBED_COLOR_CLAIMED)
.setFooter({ text: `Claimed by ${claimerLabel}` });
await interaction.followUp({ embeds: [claimEmbed] });
const { addMemberToStaffThread } = require('../services/staffThread');
await addMemberToStaffThread(interaction.channel, interaction.user.id).catch(() => {});
} else {
// Unclaim
await Ticket.updateOne(
{ gmailThreadId: freshTicket.gmailThreadId },
{ $set: { claimedBy: null, claimerId: null, staffChannelId: null } }
);
freshTicket.claimedBy = null;
freshTicket.claimerId = null;
freshTicket.staffChannelId = null;
const creatorNicknameUnclaim = await resolveCreatorNickname(guild, freshTicket);
const unclaimState = (freshTicket.escalationTier ?? 0) >= 1 ? 'escalated' : 'unclaimed';
enqueueRename(interaction.channel, makeTicketName(unclaimState, freshTicket, creatorNicknameUnclaim)).catch(err => logError('rename', err).catch(() => {}));
btnClose
.setCustomId('close_ticket')
.setLabel(CONFIG.BUTTON_LABEL_CLOSE)
.setEmoji(CONFIG.BUTTON_EMOJI_CLOSE)
.setStyle(ButtonStyle.Secondary)
.setDisabled(false);
btnClaim
.setCustomId('claim_ticket')
.setEmoji(CONFIG.BUTTON_EMOJI_CLAIM)
.setStyle(ButtonStyle.Secondary)
.setDisabled(false)
.setLabel(CONFIG.BUTTON_LABEL_CLAIM);
await interaction.update({ components: [row] });
const unclaimText = CONFIG.TICKET_UNCLAIMED_MESSAGE
.replace(/\{staff_mention\}/g, interaction.user.toString())
.replace(/\{staff_name\}/g, interaction.member?.displayName || interaction.user.username);
const unclaimEmbed = new EmbedBuilder()
.setTitle('🔓 Ticket Unclaimed')
.setDescription(unclaimText)
.setColor(0x808080)
.setFooter({ text: `Unclaimed by ${claimerLabel}` });
await interaction.followUp({ embeds: [unclaimEmbed] });
}
}
// --- CONFIRM CLOSE ---
async function handleConfirmClose(interaction, ticket) {
const closedAt = new Date();
increment('staff_closes', interaction.user.id, 'today');
if (!ticket.ticketTag) {
increment('untagged_closes', 'total', 'today');
}
try {
await interaction.update({ content: 'Archiving and closing...', components: [] });
} catch {
// Already acknowledged fall back to editReply
await interaction.editReply({ content: 'Archiving and closing...', components: [] }).catch(() => {});
}
try {
const messages = await interaction.channel.messages.fetch({ limit: 100 });
const log =
`TRANSCRIPT: ${ticket.subject}\nUser: ${ticket.senderEmail}\n---\n` +
messages
.reverse()
.map(
m =>
`[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`
)
.join('\n');
const file = new AttachmentBuilder(Buffer.from(log), {
name: `transcript-${interaction.channel.name}.txt`
});
const channelName = interaction.channel.name;
const opened = new Date(ticket.createdAt);
const openedStr = opened.toLocaleString('en-US', {
month: '2-digit',
day: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true,
timeZoneName: 'short'
});
const closedStr = closedAt.toLocaleString('en-US', {
month: '2-digit',
day: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true,
timeZoneName: 'short'
});
// In-ticket message before transcript is posted (Discord close message)
const discordCloseContent = CONFIG.DISCORD_CLOSE_MESSAGE;
await enqueueSend(interaction.channel, discordCloseContent);
const transcriptChan = await interaction.client.channels
.fetch(CONFIG.TRANSCRIPT_CHAN)
.catch(() => null);
let transcriptMsg = null;
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
.replace(/\{channel_name\}/g, channelName)
.replace(/\{email\}/g, ticket.senderEmail || '')
.replace(/\{date_opened\}/g, openedStr)
.replace(/\{date_closed\}/g, closedStr)
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
if (transcriptChan) {
transcriptMsg = await enqueueSend(transcriptChan, {
content: transcriptContent,
files: [file]
});
}
// DM the transcript to the ticket creator (Discord-originated tickets).
// Gated because many users have DMs from server members disabled — the send
// then 50007s and generates noise. Default off; enable via env when desired.
if (CONFIG.TRANSCRIPT_DM_TO_CREATOR && ticket.gmailThreadId?.startsWith('discord-')) {
const creatorId = ticket.gmailThreadId.split('-').pop();
try {
const creator = await interaction.client.users.fetch(creatorId);
const dmFile = new AttachmentBuilder(Buffer.from(log), {
name: `transcript-${channelName}.txt`
});
const dmContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
.replace(/\{channel_name\}/g, channelName)
.replace(/\{email\}/g, ticket.senderEmail || '')
.replace(/\{date_opened\}/g, openedStr)
.replace(/\{date_closed\}/g, closedStr);
await creator.send({
content: dmContent,
files: [dmFile]
});
} catch (dmErr) {
// 50007 = "Cannot send messages to this user" — user has DMs off. Expected class; debug-level only.
if (dmErr?.code === 50007) {
logSystem('Transcript DM skipped (recipient has DMs disabled)', [
{ name: 'User', value: creatorId },
{ name: 'Channel', value: channelName }
]).catch(() => {});
} else {
logError('transcript-dm', dmErr).catch(() => {});
}
}
}
const logChan = await interaction.client.channels
.fetch(CONFIG.LOG_CHAN)
.catch(() => null);
if (logChan) {
const closerMention = interaction.user.toString();
const closerDisplayName = interaction.member?.displayName || interaction.user.username;
let logMsg;
if (ticket.gmailThreadId?.startsWith('discord-')) {
const creatorId = ticket.gmailThreadId.split('-').pop();
try {
const creator = await interaction.client.users.fetch(creatorId);
const creatorMention = creator.toString();
logMsg = `Closed ${creatorMention}'s **${channelName}** by ${closerMention} (${closerDisplayName})`;
} catch {
logMsg = `Closed **${channelName}** by ${closerMention} (${closerDisplayName})`;
}
} else {
logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`;
}
await enqueueSend(logChan, logMsg);
}
const closerDisplayName =
interaction.member?.displayName || interaction.user.username;
if (!ticket.gmailThreadId?.startsWith('discord-')) {
await sendTicketClosedEmail(ticket, closerDisplayName);
}
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { discordThreadId: null, status: 'closed' } }
);
try {
const { deleteStaffChannel } = require('../services/staffChannel');
await deleteStaffChannel(interaction.guild, ticket.staffChannelId);
} catch (e) {
console.error('Delete staff channel (close):', e);
}
if (transcriptMsg?.id) {
await Transcript.create({
gmailThreadId: ticket.gmailThreadId,
transcriptMessageId: transcriptMsg.id,
createdAt: new Date()
});
}
const parentCatId = ticket.parentCategoryId;
const guildRef = interaction.guild;
setTimeout(
() => interaction.channel.delete().catch(() => {}),
5000
);
setTimeout(() => {
(async () => {
if (parentCatId && guildRef) {
await cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME);
}
})();
}, 6000);
} catch (e) {
console.error('Close ticket error:', e);
}
}
/**
* Handle the ticket_modal submission (from the open-ticket panel button).
*/
async function handleTicketModal(interaction) {
await interaction.deferReply({ ephemeral: true });
const email = interaction.fields.getTextInputValue('ticket_email').trim().toLowerCase();
const game = interaction.fields.getTextInputValue('ticket_game').trim();
const description = interaction.fields.getTextInputValue('ticket_description');
const subject = game ? `[${game}] ${description.slice(0, 60)}` : description.slice(0, 80);
const priority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal';
const useThread =
interaction.customId === 'ticket_modal_thread' ||
(interaction.customId === 'ticket_modal' && !!CONFIG.DISCORD_THREAD_CHANNEL_ID);
const rateLimit = checkTicketCreationRateLimit(interaction.user.id);
if (!rateLimit.allowed) {
const mins = Math.ceil((rateLimit.retryAfterMs || 0) / 60000);
return interaction.editReply(`You can only create ${CONFIG.RATE_LIMIT_TICKETS_PER_USER} ticket(s) per ${CONFIG.RATE_LIMIT_WINDOW_MINUTES} minutes. Try again in ${mins} minute(s).`);
}
try {
const guild = interaction.guild;
const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean();
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
const creatorNicknameModal = interaction.member?.displayName || interaction.user.username;
const unclaimedName = toDiscordSafeName(`unclaimed-${creatorNicknameModal}-${ticketNumber}`);
let channel;
let parentCategoryIdForTicket = null;
if (useThread && CONFIG.DISCORD_THREAD_CHANNEL_ID) {
try {
channel = await createDiscordTicketAsThread(guild, ticketNumber, interaction.user.id);
parentCategoryIdForTicket = channel.parent?.parentId ?? null;
} catch (err) {
console.error('Discord ticket thread create failed:', err.message);
return interaction.editReply('Could not create ticket thread. Check DISCORD_THREAD_CHANNEL_ID and try again.');
}
} else if (useThread && !CONFIG.DISCORD_THREAD_CHANNEL_ID) {
return interaction.editReply('Thread tickets are not configured (DISCORD_THREAD_CHANNEL_ID is not set). Use a channel panel or set the env variable.');
} else {
let parentId;
try {
parentId = await getOrCreateTicketCategory(
guild,
CONFIG.DISCORD_TICKET_CATEGORY_ID,
CONFIG.TICKET_CATEGORY_NAME
);
} catch (err) {
console.error('getOrCreateTicketCategory (ticket modal):', err);
return interaction.editReply('Discord ticket category could not be resolved. Contact an administrator.');
}
parentCategoryIdForTicket = parentId;
try {
// TODO(queue-migrate): initial permissionOverwrites here are fine since the channel is just being created, but any later permissionOverwrites mutation on this channel should go through channelQueue.
channel = await guild.channels.create({
name: unclaimedName,
type: ChannelType.GuildText,
parent: parentId,
permissionOverwrites: [
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
{
id: interaction.user.id,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
},
{
id: CONFIG.ROLE_ID_TO_PING,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
}
]
});
} catch (err) {
console.error('guild.channels.create (ticket modal):', err);
return interaction.editReply('Failed to create ticket channel. Contact an administrator.');
}
}
const gmailThreadId = `discord-${Date.now()}-${interaction.user.id}`;
const now = new Date();
await Ticket.create({
gmailThreadId,
discordThreadId: channel.id,
senderEmail: email,
subject,
game: game || null,
createdAt: now,
status: 'open',
ticketNumber,
priority,
lastActivity: now,
parentCategoryId: parentCategoryIdForTicket
});
const displayName = interaction.member?.displayName || interaction.user.username;
const descTrimmed = description.length > 500 ? description.slice(0, 497) + '…' : description;
const welcomeEmbed = new EmbedBuilder()
.setTitle("We got your ticket.")
.setDescription("We'll be with you as soon as possible.")
.setColor(5763719)
.setThumbnail("https://indifferentbroccoli.com/img/broccoli_shadow_square.png");
const infoEmbed = new EmbedBuilder()
.setColor(5763719)
.setDescription(truncateEmbedDescription(
`**Account Email:**\n\`\`\`\n${sanitizeEmbedText(email)}\n\`\`\`\n` +
`**Game:**\n\`\`\`\n${sanitizeEmbedText(game) || "Not specified"}\n\`\`\`\n` +
`**What do you need help with?**\n\`\`\`\n${sanitizeEmbedText(descTrimmed)}\n\`\`\``
));
const resourcesEmbed = new EmbedBuilder()
.setTitle("We're ~~happy~~ indifferent to help. :indifferentbroccoli:")
.setDescription("Please feel free to add any additional information to the ticket, including recent changes to the server, if any.")
.setColor(5763719)
.addFields(
{ name: "Check out our wiki for guides:", value: "[Indifferent Broccolipedia](https://wiki.indifferentbroccoli.com)", inline: false }
)
.setFooter({ text: "indifferent broccoli tickets (:|)", iconURL: "https://i.ibb.co/sJdytfFM/Untitled-design-6.png" });
const actionRow = getTicketActionRow({ escalationTier: 0 });
enforceEmbedLimit([welcomeEmbed, infoEmbed, resourcesEmbed]);
let welcomeMsg;
try {
welcomeMsg = await enqueueSend(channel, {
content: `Hey There ${interaction.user} 🥦`,
embeds: [welcomeEmbed, infoEmbed, resourcesEmbed],
components: [actionRow]
});
await Ticket.updateOne(
{ discordThreadId: channel.id },
{ $set: { welcomeMessageId: welcomeMsg.id } }
);
} catch (err) {
console.error('welcomeMessageId-save', err);
}
const { createStaffThread } = require('../services/staffThread');
await createStaffThread(channel, interaction.client).catch(() => {});
if (CONFIG.PIN_INITIAL_MESSAGE_ENABLED && welcomeMsg) {
const { pinMessage } = require('../services/pinMessage');
await pinMessage(welcomeMsg, interaction.client).catch(() => {});
}
increment('user_tickets', interaction.user.id, 'today');
increment('user_tickets', interaction.user.id, 'week');
if (game) {
increment('game_tickets', game, 'today');
increment('game_tickets', game, 'week');
}
await interaction.deleteReply().catch(() => {});
const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null);
if (logChan) {
await enqueueSend(logChan,
`📝 ${channel.name} created by ${interaction.user.tag}`
);
}
} catch (err) {
console.error('Ticket creation error:', err);
await interaction.editReply('Failed to create ticket. Please contact an administrator.');
}
}
module.exports = { handleButton, handleTicketModal };

File diff suppressed because it is too large Load Diff

View File

@@ -1,120 +0,0 @@
/**
* Discord messageCreate handler forwards staff replies to Gmail.
*/
const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config');
const { extractRawEmail } = require('../utils');
const { getGmailClient, sendGmailReply } = require('../services/gmail');
const { updateTicketActivity } = require('../services/tickets');
const { getNotifyDm } = require('../services/staffSettings');
const { pingStaffChannel } = require('../services/staffChannel');
const { notifyStaffOfReply } = require('../services/staffNotifications');
const Ticket = mongoose.model('Ticket');
/**
* Handle a Discord message in a ticket channel → relay to Gmail (email tickets only).
*/
async function handleDiscordReply(m) {
if (m.author.bot || m.interaction) return;
const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean();
if (!ticket) return;
if (ticket.claimerId && m.author.id !== ticket.claimerId && ticket.staffChannelId) {
try {
const staffChan = await m.guild.channels.fetch(ticket.staffChannelId).catch(() => null);
if (staffChan) {
await pingStaffChannel(staffChan, ticket.claimerId, m);
}
const dmEnabled = await getNotifyDm(ticket.claimerId);
if (dmEnabled) {
const staffMember = await m.guild.members.fetch(ticket.claimerId).catch(() => null);
if (staffMember) {
const jumpLink = `https://discord.com/channels/${m.guild.id}/${m.channel.id}/${m.id}`;
await staffMember
.send(
`New customer reply in **${m.channel.name}**:\n> ${m.content.slice(0, 300)}\n[Jump to message](${jumpLink})`
)
.catch(() => {});
}
}
} catch (e) {
console.error('Staff ping error:', e);
}
}
// Track whether last message is from staff or customer
const memberForCheck = await m.guild.members.fetch(m.author.id).catch(() => null);
const isStaffMember = memberForCheck && CONFIG.ROLE_ID_TO_PING && memberForCheck.roles.cache.has(CONFIG.ROLE_ID_TO_PING);
Ticket.updateOne(
{ discordThreadId: m.channel.id },
{ $set: { lastMessageAuthorIsStaff: !!isStaffMember, lastActivity: new Date() } }
).catch(() => {});
// Notify claiming staff if a non-staff user replied (works for both Discord and email tickets)
if (ticket.claimerId && !isStaffMember) {
const guild = m.guild;
const freshTicket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean();
if (freshTicket) {
await notifyStaffOfReply(guild, freshTicket, m).catch(e => console.error('notifyStaffOfReply:', e));
}
}
const discordUser = m.member?.displayName || m.author.username;
if (ticket.gmailThreadId.startsWith('discord-')) {
return;
}
// Email tickets: send reply via Gmail.
try {
const gmail = getGmailClient();
const thread = await gmail.users.threads.get({
userId: 'me',
id: ticket.gmailThreadId
});
const last = [...thread.data.messages].reverse().find(msg => {
const from =
msg.payload.headers.find(h => h.name === 'From')?.value || '';
return !from.toLowerCase().includes(CONFIG.MY_EMAIL);
});
if (!last) return;
let recipient =
last.payload.headers.find(h => h.name === 'From')?.value || '';
const replyTo =
last.payload.headers.find(h => h.name === 'Reply-To')?.value;
if (replyTo) recipient = replyTo;
const subject =
last.payload.headers.find(h => h.name === 'Subject')?.value ||
'Support';
const msgId =
last.payload.headers.find(h => h.name === 'Message-ID')?.value;
const recipientEmail = extractRawEmail(recipient).toLowerCase();
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) {
console.warn('Bad recipient for reply:', recipientEmail);
return;
}
await sendGmailReply(
ticket.gmailThreadId,
m.content,
recipientEmail,
subject,
discordUser,
msgId,
m.author.id
);
await updateTicketActivity(ticket.gmailThreadId);
} catch (e) {
console.error('REPLY ERROR:', e);
}
}
module.exports = { handleDiscordReply };