huge changes
This commit is contained in:
@@ -20,8 +20,11 @@ const { getEmailRouting } = require('../services/guildSettings');
|
||||
const { enqueueRename, enqueueMove } = require('../services/channelQueue');
|
||||
const { setNotifyDm } = require('../services/staffSettings');
|
||||
const { trackInteraction, trackError, getAnalyticsSummary } = require('./analytics');
|
||||
const { logTicketEvent, logSecurity } = 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');
|
||||
@@ -55,6 +58,7 @@ async function requireStaffRole(interaction) {
|
||||
content: `This command is only available to the support team (${roleMention}).`,
|
||||
ephemeral: true
|
||||
});
|
||||
logSecurity('Unauthorized command attempt', interaction.user, interaction.commandName).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -75,6 +79,12 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
||||
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 renameInfo = await canRename(ticket);
|
||||
@@ -124,12 +134,17 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
||||
.setFooter({ text: `Escalated by ${interaction.member?.displayName || interaction.user.username}` });
|
||||
const updatedTicketForRow = { ...ticket, escalationTier: nextTier, escalated: true };
|
||||
const escalationRow = getTicketActionRow(updatedTicketForRow);
|
||||
await interaction.channel.send({
|
||||
const escalationMsg = await interaction.channel.send({
|
||||
content: null,
|
||||
embeds: [escalatedEmbed],
|
||||
components: [escalationRow]
|
||||
});
|
||||
|
||||
if (CONFIG.PIN_ESCALATION_MESSAGE_ENABLED && escalationMsg) {
|
||||
const { pinMessage } = require('../services/pinMessage');
|
||||
await pinMessage(escalationMsg, interaction.client).catch(() => {});
|
||||
}
|
||||
|
||||
if (!isDiscordTicket && ticket.gmailThreadId) {
|
||||
try {
|
||||
const emailBody = CONFIG.ESCALATION_MESSAGE.replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME) + (reason ? `\n\nReason: ${reason}` : '');
|
||||
@@ -144,12 +159,16 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
||||
}
|
||||
}
|
||||
|
||||
if (nextTier === 2 && ticket.welcomeMessageId) {
|
||||
try {
|
||||
const welcomeMsg = await interaction.channel.messages.fetch(ticket.welcomeMessageId);
|
||||
await welcomeMsg.edit({ components: [getTicketActionRow(updatedTicketForRow)] });
|
||||
} catch (e) {
|
||||
console.error('Failed to update welcome message after escalate:', e.message);
|
||||
if (nextTier === 2) {
|
||||
if (!ticket.welcomeMessageId) {
|
||||
console.warn('welcomeMessageId is null/undefined; skipping welcome-message update for escalation');
|
||||
} else {
|
||||
try {
|
||||
const welcomeMsg = await interaction.channel.messages.fetch(ticket.welcomeMessageId);
|
||||
await welcomeMsg.edit({ components: [getTicketActionRow(updatedTicketForRow)] });
|
||||
} catch (e) {
|
||||
console.error('Failed to update welcome message after escalate:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,6 +395,7 @@ async function handleCommand(interaction) {
|
||||
// /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');
|
||||
@@ -540,6 +560,79 @@ async function handleCommand(interaction) {
|
||||
}
|
||||
}
|
||||
|
||||
// /gmailpoll
|
||||
// /staffthread
|
||||
if (interaction.commandName === 'staffthread') {
|
||||
const sub = interaction.options.getSubcommand();
|
||||
if (sub === 'toggle') {
|
||||
CONFIG.STAFF_THREAD_ENABLED = !CONFIG.STAFF_THREAD_ENABLED;
|
||||
return interaction.reply({ content: `Staff threads are now **${CONFIG.STAFF_THREAD_ENABLED ? 'enabled' : 'disabled'}**.`, ephemeral: true });
|
||||
}
|
||||
if (sub === 'name') {
|
||||
const name = interaction.options.getString('thread_name').slice(0, 100);
|
||||
CONFIG.STAFF_THREAD_NAME = name;
|
||||
return interaction.reply({ content: `Staff thread name set to **${name}**.`, ephemeral: true });
|
||||
}
|
||||
if (sub === 'autorole') {
|
||||
const enabled = interaction.options.getBoolean('enabled');
|
||||
CONFIG.STAFF_THREAD_AUTO_ADD_ROLE = enabled;
|
||||
return interaction.reply({ content: `Auto-add role to staff thread is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// /pinmessages
|
||||
if (interaction.commandName === 'pinmessages') {
|
||||
const sub = interaction.options.getSubcommand();
|
||||
const enabled = interaction.options.getBoolean('enabled');
|
||||
if (sub === 'initial') {
|
||||
CONFIG.PIN_INITIAL_MESSAGE_ENABLED = enabled;
|
||||
return interaction.reply({ content: `Auto-pin initial message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
|
||||
}
|
||||
if (sub === 'escalation') {
|
||||
CONFIG.PIN_ESCALATION_MESSAGE_ENABLED = enabled;
|
||||
return interaction.reply({ content: `Auto-pin escalation message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
|
||||
}
|
||||
if (sub === 'suppress') {
|
||||
CONFIG.PIN_SUPPRESS_SYSTEM_MESSAGE = enabled;
|
||||
return interaction.reply({ content: `Suppress pin system message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.commandName === 'gmailpoll') {
|
||||
const seconds = parseInt(interaction.options.getString('interval'), 10);
|
||||
const { setGmailPollInterval } = require('../broccolini-discord');
|
||||
setGmailPollInterval(seconds * 1000);
|
||||
logTicketEvent('Gmail poll interval updated', [{ name: 'Interval', value: `${seconds}s` }, { name: 'Set by', value: interaction.user.tag }], interaction).catch(() => {});
|
||||
return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, ephemeral: true });
|
||||
}
|
||||
|
||||
// /closetimer
|
||||
if (interaction.commandName === 'closetimer') {
|
||||
const seconds = parseInt(interaction.options.getString('seconds'), 10);
|
||||
CONFIG.FORCE_CLOSE_TIMER = seconds;
|
||||
logTicketEvent('Close timer updated', [{ name: 'Duration', value: `${seconds}s` }, { name: 'Set by', value: interaction.user.tag }], interaction).catch(() => {});
|
||||
return interaction.reply({ content: `Force-close timer set to ${seconds} seconds.`, ephemeral: true });
|
||||
}
|
||||
|
||||
// /cancel-close
|
||||
if (interaction.commandName === 'cancel-close') {
|
||||
const pending = pendingCloses.get(interaction.channel.id);
|
||||
if (!pending) {
|
||||
return interaction.reply({ content: 'No pending close for this channel.', ephemeral: true });
|
||||
}
|
||||
clearTimeout(pending.timeout);
|
||||
const { logTicketEvent } = require('../services/debugLog');
|
||||
logTicketEvent('Force-close cancelled', [
|
||||
{ name: 'Ticket', value: interaction.channel.name || interaction.channel.id },
|
||||
{ name: 'Cancelled by', value: interaction.user.tag },
|
||||
{ name: 'Original setter', value: pending.username || 'Unknown' }
|
||||
], interaction).catch(() => {});
|
||||
pendingCloses.delete(interaction.channel.id);
|
||||
return interaction.reply({ content: 'Close cancelled.', ephemeral: true });
|
||||
}
|
||||
|
||||
// /force-close
|
||||
if (interaction.commandName === 'force-close') {
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
@@ -547,71 +640,86 @@ async function handleCommand(interaction) {
|
||||
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { status: 'closed' } }
|
||||
);
|
||||
if (pendingCloses.has(interaction.channel.id)) {
|
||||
return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true });
|
||||
}
|
||||
|
||||
await interaction.reply('Ticket force-closed. Archiving...');
|
||||
const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
|
||||
await interaction.reply(`Closing ticket in ${timerSeconds} seconds. Use \`/cancel-close\` to abort.`);
|
||||
|
||||
const channelRef = interaction.channel;
|
||||
const clientRef = interaction.client;
|
||||
const timerId = setTimeout(async () => {
|
||||
pendingCloses.delete(channelRef.id);
|
||||
const freshTicket = await Ticket.findOne({ discordThreadId: channelRef.id }).lean();
|
||||
if (!freshTicket || freshTicket.status === 'closed') return;
|
||||
|
||||
try {
|
||||
await interaction.channel.send(CONFIG.DISCORD_CLOSE_MESSAGE);
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: freshTicket.gmailThreadId },
|
||||
{ $set: { status: 'closed' } }
|
||||
);
|
||||
|
||||
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');
|
||||
await channelRef.send('Ticket force-closed. Archiving...');
|
||||
|
||||
const file = new AttachmentBuilder(Buffer.from(log), {
|
||||
name: `transcript-${interaction.channel.name}.txt`
|
||||
});
|
||||
|
||||
const transcriptChan = await interaction.client.channels
|
||||
.fetch(CONFIG.TRANSCRIPT_CHAN)
|
||||
.catch(() => null);
|
||||
|
||||
if (transcriptChan) {
|
||||
const closedAt = new Date();
|
||||
const openedStr = new Date(ticket.createdAt).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'
|
||||
});
|
||||
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
||||
.replace(/\{channel_name\}/g, interaction.channel.name)
|
||||
.replace(/\{email\}/g, ticket.senderEmail || '')
|
||||
.replace(/\{date_opened\}/g, openedStr)
|
||||
.replace(/\{date_closed\}/g, closedStr)
|
||||
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
|
||||
await transcriptChan.send({
|
||||
content: transcriptContent,
|
||||
files: [file]
|
||||
});
|
||||
}
|
||||
} catch (tErr) {
|
||||
console.error('Transcript error (force-close):', tErr);
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await interaction.channel.delete('Ticket force-closed');
|
||||
} catch (e) {
|
||||
console.error('Failed to delete channel:', e);
|
||||
await channelRef.send(CONFIG.DISCORD_CLOSE_MESSAGE);
|
||||
|
||||
const messages = await channelRef.messages.fetch({ limit: 100 });
|
||||
const log =
|
||||
`TRANSCRIPT: ${freshTicket.subject}\nUser: ${freshTicket.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-${channelRef.name}.txt`
|
||||
});
|
||||
|
||||
const transcriptChan = await clientRef.channels
|
||||
.fetch(CONFIG.TRANSCRIPT_CHAN)
|
||||
.catch(() => null);
|
||||
|
||||
if (transcriptChan) {
|
||||
const closedAt = new Date();
|
||||
const openedStr = new Date(freshTicket.createdAt).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'
|
||||
});
|
||||
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
||||
.replace(/\{channel_name\}/g, channelRef.name)
|
||||
.replace(/\{email\}/g, freshTicket.senderEmail || '')
|
||||
.replace(/\{date_opened\}/g, openedStr)
|
||||
.replace(/\{date_closed\}/g, closedStr)
|
||||
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
|
||||
await transcriptChan.send({
|
||||
content: transcriptContent,
|
||||
files: [file]
|
||||
});
|
||||
}
|
||||
} catch (tErr) {
|
||||
console.error('Transcript error (force-close):', tErr);
|
||||
}
|
||||
}, 5000);
|
||||
} catch (err) {
|
||||
console.error('Force close error:', err);
|
||||
await interaction.reply({ content: 'Failed to close ticket.', ephemeral: true });
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await channelRef.delete('Ticket force-closed');
|
||||
} catch (e) {
|
||||
console.error('Failed to delete channel:', e);
|
||||
}
|
||||
}, 5000);
|
||||
} catch (err) {
|
||||
console.error('Force close error:', err);
|
||||
}
|
||||
}, timerSeconds * 1000);
|
||||
pendingCloses.set(channelRef.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag });
|
||||
}
|
||||
|
||||
// /topic
|
||||
@@ -649,6 +757,9 @@ async function handleCommand(interaction) {
|
||||
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 });
|
||||
@@ -1189,16 +1300,20 @@ async function handleContextMenu(interaction) {
|
||||
|
||||
const row = getTicketActionRow({ escalationTier: 0 });
|
||||
|
||||
const welcomeMsg = await channel.send({
|
||||
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\nHey There ${message.author} 🥦`,
|
||||
embeds: [welcomeEmbed, infoEmbed],
|
||||
components: [row]
|
||||
});
|
||||
try {
|
||||
const welcomeMsg = await channel.send({
|
||||
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\nHey There ${message.author} 🥦`,
|
||||
embeds: [welcomeEmbed, infoEmbed],
|
||||
components: [row]
|
||||
});
|
||||
|
||||
await Ticket.updateOne(
|
||||
{ discordThreadId: channel.id },
|
||||
{ $set: { welcomeMessageId: welcomeMsg.id } }
|
||||
);
|
||||
await Ticket.updateOne(
|
||||
{ discordThreadId: channel.id },
|
||||
{ $set: { welcomeMessageId: welcomeMsg.id } }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('welcomeMessageId-save', err);
|
||||
}
|
||||
|
||||
await interaction.editReply(`✅ Ticket created: ${channel}`);
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user