strip: remove pattern/surge/chat alert monitoring + unused commands
- delete services/{patternChecker,patternStore,surgeChecker,chatAlertChecker,staffNotifications,staffChannel,notificationRegistry,notificationEnabled,staffPresence}.js
- remove /notification, /staffnotification, /tag, /priority
- /escalate: drop action param, always unclaim
- purge PATTERN_*, SURGE_*, CHAT_ALERT_*, STAFF_* env vars from config + .env.example
- drop StaffNotification model
- ~2500 LOC removed
- settings-site /internal/notifications/* endpoints gone (UI will 404 until trimmed)
This commit is contained in:
@@ -25,7 +25,6 @@ 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');
|
||||
@@ -342,11 +341,8 @@ async function handleClaim(interaction, ticket) {
|
||||
);
|
||||
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 claimerEmoji = '🎫';
|
||||
const creatorNickname = await resolveCreatorNickname(guild, freshTicket);
|
||||
|
||||
const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed';
|
||||
@@ -385,11 +381,10 @@ async function handleClaim(interaction, ticket) {
|
||||
// Unclaim
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: freshTicket.gmailThreadId },
|
||||
{ $set: { claimedBy: null, claimerId: null, staffChannelId: null } }
|
||||
{ $set: { claimedBy: null, claimerId: null } }
|
||||
);
|
||||
freshTicket.claimedBy = null;
|
||||
freshTicket.claimerId = null;
|
||||
freshTicket.staffChannelId = null;
|
||||
|
||||
const creatorNicknameUnclaim = await resolveCreatorNickname(guild, freshTicket);
|
||||
const unclaimState = (freshTicket.escalationTier ?? 0) >= 1 ? 'escalated' : 'unclaimed';
|
||||
@@ -425,10 +420,6 @@ async function handleClaim(interaction, ticket) {
|
||||
// --- 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 {
|
||||
@@ -563,13 +554,6 @@ async function handleConfirmClose(interaction, ticket) {
|
||||
{ $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,
|
||||
@@ -746,13 +730,6 @@ async function handleTicketModal(interaction) {
|
||||
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);
|
||||
|
||||
770
handlers/buttons.js.bak-20260421
Normal file
770
handlers/buttons.js.bak-20260421
Normal file
@@ -0,0 +1,770 @@
|
||||
/**
|
||||
* 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 };
|
||||
@@ -11,9 +11,9 @@ const {
|
||||
PermissionFlagsBits
|
||||
} = require('discord.js');
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { CONFIG, TICKET_TAGS } = require('../config');
|
||||
const { getPriorityEmoji, getPriorityColor, replaceVariables, escapeRegex } = require('../utils');
|
||||
const { makeTicketName, resolveCreatorNickname, getSenderLocal, toDiscordSafeName, getOrCreateTicketCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
|
||||
const { CONFIG } = require('../config');
|
||||
const { getPriorityEmoji, replaceVariables, escapeRegex } = require('../utils');
|
||||
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
|
||||
const { sendTicketNotificationEmail } = require('../services/gmail');
|
||||
const { getTicketActionRow } = require('../utils/ticketComponents');
|
||||
const { getEmailRouting } = require('../services/guildSettings');
|
||||
@@ -24,12 +24,10 @@ const { logTicketEvent, logSecurity, logError } = require('../services/debugLog'
|
||||
const { handleAccountInfoCommand } = require('./accountinfo');
|
||||
const { handleSetupCommand } = require('./setup');
|
||||
const { pendingCloses } = require('./pendingCloses');
|
||||
const { increment } = require('../services/patternStore');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
const Tag = mongoose.model('Tag');
|
||||
const User = mongoose.model('User');
|
||||
const StaffNotification = mongoose.model('StaffNotification');
|
||||
|
||||
/**
|
||||
* True if member has the support role (ROLE_ID_TO_PING) or any ADDITIONAL_STAFF_ROLES.
|
||||
@@ -74,17 +72,11 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
||||
// Clear claim on escalation
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null, unclaimedRemindersSent: [] } }
|
||||
{ $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null } }
|
||||
);
|
||||
ticket.escalated = true;
|
||||
ticket.escalationTier = nextTier;
|
||||
ticket.claimedBy = null;
|
||||
increment('escalations', ticket.game || 'unknown', 'today');
|
||||
increment('escalations', ticket.game || 'unknown', 'week');
|
||||
increment('user_escalations', ticket.senderEmail, 'week');
|
||||
increment('staff_escalations', interaction.user.id, 'today');
|
||||
increment('staff_escalations', interaction.user.id, 'week');
|
||||
if (ticket.game) increment(`staff_game_escalations:${interaction.user.id}`, ticket.game, 'week');
|
||||
|
||||
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
|
||||
const newName = makeTicketName('escalated', ticket, creatorNickname);
|
||||
@@ -265,12 +257,11 @@ async function handleCommand(interaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
// /escalate (tier 2 or 3 via level; works for both email and Discord)
|
||||
// /escalate (tier 2 or 3 via level; works for both email and Discord). Always unclaims on escalate.
|
||||
if (interaction.commandName === 'escalate') {
|
||||
const reason = null;
|
||||
const level = interaction.options.getString('level');
|
||||
const nextTier = level === '3' ? 2 : 1;
|
||||
const action = interaction.options.getString('action');
|
||||
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
if (!ticket) {
|
||||
@@ -301,12 +292,6 @@ async function handleCommand(interaction) {
|
||||
try {
|
||||
await interaction.deferReply();
|
||||
await runEscalation(interaction, ticket, nextTier, reason);
|
||||
if (action === 'unclaim') {
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { claimedBy: null, claimerId: null } }
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Escalate error:', err);
|
||||
await interaction.editReply({ content: 'Failed to escalate this ticket.' }).catch(() =>
|
||||
@@ -315,83 +300,6 @@ async function handleCommand(interaction) {
|
||||
}
|
||||
}
|
||||
|
||||
// /notification set | /notification add
|
||||
if (interaction.commandName === 'notification') {
|
||||
const sub = interaction.options.getSubcommand();
|
||||
if (sub === 'set') {
|
||||
const hours = interaction.options.getInteger('hours');
|
||||
try {
|
||||
await StaffNotification.findOneAndUpdate(
|
||||
{ userId: interaction.user.id },
|
||||
{ $set: { cooldownHours: hours, updatedAt: new Date() } },
|
||||
{ upsert: true }
|
||||
);
|
||||
return interaction.reply({ content: `Notification cooldown set to ${hours} hour(s).`, ephemeral: true });
|
||||
} catch (err) {
|
||||
console.error('notification set error:', err);
|
||||
return interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
if (sub === 'add') {
|
||||
if (!CONFIG.STAFF_NOTIFICATION_CATEGORY_ID) {
|
||||
return interaction.reply({ content: 'STAFF_NOTIFICATION_CATEGORY_ID is not configured.', ephemeral: true });
|
||||
}
|
||||
const member = interaction.options.getMember('member');
|
||||
if (!member) {
|
||||
return interaction.reply({ content: 'Could not resolve that member.', ephemeral: true });
|
||||
}
|
||||
const displayName = member.displayName;
|
||||
const emoji = CONFIG.STAFF_EMOJIS.get(member.id) || '';
|
||||
const chanName = toDiscordSafeName(`${displayName}${emoji}`);
|
||||
try {
|
||||
const newChannel = await interaction.guild.channels.create({
|
||||
name: chanName,
|
||||
type: ChannelType.GuildText,
|
||||
parent: CONFIG.STAFF_NOTIFICATION_CATEGORY_ID,
|
||||
permissionOverwrites: [
|
||||
{ id: interaction.guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||||
{ id: member.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] },
|
||||
...(CONFIG.ROLE_ID_TO_PING ? [{ id: CONFIG.ROLE_ID_TO_PING, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] }] : [])
|
||||
]
|
||||
});
|
||||
await StaffNotification.findOneAndUpdate(
|
||||
{ userId: member.id },
|
||||
{ $set: { channelId: newChannel.id, guildId: interaction.guild.id, updatedAt: new Date() } },
|
||||
{ upsert: true }
|
||||
);
|
||||
return interaction.reply({ content: `Notification channel created: ${newChannel}`, ephemeral: true });
|
||||
} catch (err) {
|
||||
console.error('notification add error:', err);
|
||||
return interaction.reply({ content: 'Failed to create notification channel.', ephemeral: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// /staffnotification (admin only)
|
||||
if (interaction.commandName === 'staffnotification') {
|
||||
if (interaction.user.id !== CONFIG.ADMIN_ID) {
|
||||
logSecurity('Unauthorized command attempt', interaction.user, interaction.commandName).catch(() => {});
|
||||
return interaction.reply({ content: 'This command is restricted to the bot admin.', ephemeral: true });
|
||||
}
|
||||
const member = interaction.options.getMember('member');
|
||||
const hours = interaction.options.getInteger('hours');
|
||||
if (!member) {
|
||||
return interaction.reply({ content: 'Could not resolve that member.', ephemeral: true });
|
||||
}
|
||||
try {
|
||||
await StaffNotification.findOneAndUpdate(
|
||||
{ userId: member.id },
|
||||
{ $set: { cooldownHours: hours, updatedAt: new Date() } },
|
||||
{ upsert: true }
|
||||
);
|
||||
return interaction.reply({ content: `Notification cooldown for ${member.displayName} set to ${hours} hour(s).`, ephemeral: true });
|
||||
} catch (err) {
|
||||
console.error('staffnotification error:', err);
|
||||
return interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
if (interaction.commandName === 'notifydm') {
|
||||
try {
|
||||
const setting = interaction.options.getString('setting') === 'on';
|
||||
@@ -723,32 +631,6 @@ async function handleCommand(interaction) {
|
||||
}
|
||||
}
|
||||
|
||||
// /tag – ticket category dropdown only
|
||||
if (interaction.commandName === 'tag') {
|
||||
trackInteraction('commands', 'tag', interaction.user.tag);
|
||||
const categoryValue = interaction.options.getString('category');
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
if (!ticket) {
|
||||
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
|
||||
}
|
||||
try {
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { ticketTag: categoryValue } }
|
||||
);
|
||||
const tagEntry = (TICKET_TAGS || []).find(t => t.value === categoryValue);
|
||||
const emoji = tagEntry ? tagEntry.emoji : '';
|
||||
const channelMessage = `Your ticket has been categorized as ${emoji} **${tagEntry ? tagEntry.name : categoryValue}** ${emoji}.`;
|
||||
await interaction.reply(channelMessage);
|
||||
increment('tag_usage', categoryValue, 'today');
|
||||
increment('tag_usage', categoryValue, 'week');
|
||||
if (ticket.game) increment(`tag_game:${categoryValue}`, ticket.game, 'week');
|
||||
} catch (err) {
|
||||
trackError('tag-command', err, interaction);
|
||||
await interaction.reply({ content: 'Failed to set ticket category.', ephemeral: true });
|
||||
}
|
||||
}
|
||||
|
||||
// /response – saved response tags (send, create, edit, delete, list)
|
||||
if (interaction.commandName === 'response') {
|
||||
trackInteraction('commands', 'response', interaction.user.tag);
|
||||
@@ -936,14 +818,14 @@ async function handleCommand(interaction) {
|
||||
},
|
||||
{
|
||||
name: 'Ticket Management',
|
||||
value: '`/transfer @staff` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/topic <text>` - Set ticket topic/description\n`/priority <level>` - Set ticket priority\n`/accountinfo email` - Look up website account by email\n`/accountinfo discord @user` - Look up website account by Discord user'
|
||||
value: '`/transfer @staff` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/topic <text>` - Set ticket topic/description\n`/accountinfo email` - Look up website account by email\n`/accountinfo discord @user` - Look up website account by Discord user'
|
||||
},
|
||||
{
|
||||
name: 'Tags & Responses',
|
||||
value: '`/tag` - Set ticket category (dropdown)\n`/response send <name>` - Send saved response\n`/response create|edit|delete|list` - Manage saved responses'
|
||||
name: 'Saved Responses',
|
||||
value: '`/response send <name>` - Send saved response\n`/response create|edit|delete|list` - Manage saved responses'
|
||||
},
|
||||
{
|
||||
name: 'Variables (for tags)',
|
||||
name: 'Variables (for responses)',
|
||||
value: '`{ticket.user}`, `{ticket.email}`, `{ticket.number}`, `{ticket.subject}`, `{staff.name}`, `{server.name}`, `{date}`, `{time}`'
|
||||
},
|
||||
{
|
||||
@@ -960,63 +842,6 @@ async function handleCommand(interaction) {
|
||||
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
}
|
||||
|
||||
// /priority
|
||||
if (interaction.commandName === 'priority') {
|
||||
const level = interaction.options.getString('level');
|
||||
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
if (!ticket) {
|
||||
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
|
||||
}
|
||||
|
||||
const priorityOrder = ['low', 'normal', 'medium', 'high'];
|
||||
const oldIdx = priorityOrder.indexOf((ticket.priority || 'normal').toLowerCase());
|
||||
const newIdx = priorityOrder.indexOf(level.toLowerCase());
|
||||
const emoji = getPriorityEmoji(level);
|
||||
const levelLabel = level.charAt(0).toUpperCase() + level.slice(1).toLowerCase();
|
||||
|
||||
let channelMessage;
|
||||
if (level === 'normal') {
|
||||
channelMessage = 'Your ticket priority has returned to Normal.';
|
||||
} else if (newIdx > oldIdx) {
|
||||
channelMessage = `Your ticket has been upgraded to ${emoji} **${levelLabel}** ${emoji}.`;
|
||||
} else if (newIdx < oldIdx) {
|
||||
channelMessage = `Your ticket has been downgraded to ${emoji} **${levelLabel}** ${emoji}.`;
|
||||
} else {
|
||||
channelMessage = `Priority set to ${emoji} **${levelLabel}** ${emoji}.`;
|
||||
}
|
||||
|
||||
try {
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { priority: level } }
|
||||
);
|
||||
|
||||
const priorityTitle =
|
||||
newIdx === oldIdx
|
||||
? 'Priority Set'
|
||||
: `Priority ${newIdx > oldIdx ? 'Upgraded' : 'Downgraded'} → ${levelLabel}`;
|
||||
const priorityEmbed = new EmbedBuilder()
|
||||
.setTitle(priorityTitle)
|
||||
.setDescription(channelMessage)
|
||||
.setColor(getPriorityColor(level))
|
||||
.setFooter({ text: interaction.member?.displayName || interaction.user.username });
|
||||
await interaction.reply({ embeds: [priorityEmbed] });
|
||||
|
||||
if (level === 'high' && ticket.gmailThreadId && !ticket.gmailThreadId.startsWith('discord-')) {
|
||||
await sendTicketNotificationEmail(
|
||||
ticket,
|
||||
`Priority updated: ${levelLabel}`,
|
||||
channelMessage,
|
||||
interaction.member?.displayName || interaction.user.username
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Priority update error:', err);
|
||||
await interaction.reply({ content: 'Failed to update priority.', ephemeral: true });
|
||||
}
|
||||
}
|
||||
|
||||
// /panel
|
||||
if (interaction.commandName === 'panel') {
|
||||
const channel = interaction.options.getChannel('channel');
|
||||
|
||||
1470
handlers/commands.js.bak-20260421
Normal file
1470
handlers/commands.js.bak-20260421
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,6 @@ 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');
|
||||
|
||||
@@ -21,29 +19,6 @@ async function handleDiscordReply(m) {
|
||||
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);
|
||||
@@ -52,12 +27,19 @@ async function handleDiscordReply(m) {
|
||||
{ $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));
|
||||
// DM the claimer if they have notifydm on and a non-staff user replied.
|
||||
if (ticket.claimerId && !isStaffMember && m.author.id !== ticket.claimerId) {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
120
handlers/messages.js.bak-20260421
Normal file
120
handlers/messages.js.bak-20260421
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 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 };
|
||||
Reference in New Issue
Block a user