cleanup: remove strip backup files
This commit is contained in:
@@ -45,7 +45,9 @@
|
|||||||
"Bash(curl -sI http://100.114.205.53:12752/api/notifications/alerts)",
|
"Bash(curl -sI http://100.114.205.53:12752/api/notifications/alerts)",
|
||||||
"Bash(git log *)",
|
"Bash(git log *)",
|
||||||
"Bash(curl *)",
|
"Bash(curl *)",
|
||||||
"Bash(docker inspect *)"
|
"Bash(docker inspect *)",
|
||||||
|
"Bash(git -C /opt/broccolini-bot tag)",
|
||||||
|
"Bash(git -C /opt/broccolini-bot branch)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
/**
|
|
||||||
* Per-key config value validator registry.
|
|
||||||
*
|
|
||||||
* Pattern-driven type inference for every key in ALLOWED_CONFIG_KEYS.
|
|
||||||
* getValidator(key) returns { type, validate(value) }, where validate returns
|
|
||||||
* { ok: true, coerced } — typed value to assign into CONFIG[key]
|
|
||||||
* { ok: false, error } — human-readable reason surfaced in the save UI
|
|
||||||
*
|
|
||||||
* .env always stores String(coerced); CONFIG gets the typed coerced value so
|
|
||||||
* downstream consumers that compare === true / === 5 still work.
|
|
||||||
*
|
|
||||||
* This file is the canonical source for ALLOWED_CONFIG_KEYS — routes/internalApi
|
|
||||||
* imports the Set from here. That keeps the require graph acyclic:
|
|
||||||
* internalApi -> configPersistence -> configSchema
|
|
||||||
* internalApi -> configSchema
|
|
||||||
* No side effects beyond a one-line startup log of the fallback-string keys.
|
|
||||||
*/
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const ALLOWED_CONFIG_KEYS = new Set([
|
|
||||||
// Ticket settings
|
|
||||||
'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'TICKET_T2_CATEGORY_NAME', 'TICKET_T3_CATEGORY_NAME',
|
|
||||||
'EMAIL_TICKET_OVERFLOW_CATEGORY_IDS', 'DISCORD_TICKET_CATEGORY_ID', 'DISCORD_TICKET_OVERFLOW_CATEGORY_IDS',
|
|
||||||
'DISCORD_THREAD_CHANNEL_ID', 'EMAIL_THREAD_CHANNEL_ID', 'THREAD_PARENT_CHANNEL', 'USE_THREADS',
|
|
||||||
// Escalation categories
|
|
||||||
'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID',
|
|
||||||
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID',
|
|
||||||
// Roles and staff
|
|
||||||
'ROLE_ID_TO_PING', 'ROLE_TO_PING_ID', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES',
|
|
||||||
'STAFF_IDS', 'ADMIN_ID', 'STAFF_EMOJIS', 'CLAIMER_EMOJI_FALLBACK',
|
|
||||||
// Channel IDs
|
|
||||||
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
|
|
||||||
'BACKUP_EXPORT_CHANNEL_ID', 'ACCOUNT_INFO_CHANNEL_ID', 'DISCORD_CHANNEL_ID',
|
|
||||||
'GMAIL_LOG_CHANNEL_ID', 'AUTOMATION_LOG_CHANNEL_ID', 'RENAME_LOG_CHANNEL_ID',
|
|
||||||
'SECURITY_LOG_CHANNEL_ID', 'SYSTEM_LOG_CHANNEL_ID',
|
|
||||||
'ALL_STAFF_CHANNEL_ID', 'ALL_STAFF_CHAT_ALERT_CHANNEL_ID',
|
|
||||||
'STAFF_NOTIFICATION_CATEGORY_ID',
|
|
||||||
// Pattern channel IDs
|
|
||||||
'USER_PATTERNS_CHANNEL_ID', 'GAME_PATTERNS_CHANNEL_ID', 'TAG_PATTERNS_CHANNEL_ID',
|
|
||||||
'ESCALATION_PATTERNS_CHANNEL_ID', 'STAFF_PATTERNS_CHANNEL_ID', 'COMBINED_PATTERNS_CHANNEL_ID',
|
|
||||||
// Messages and labels
|
|
||||||
'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE',
|
|
||||||
'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE',
|
|
||||||
'AUTO_CLOSE_MESSAGE', 'TICKET_WELCOME_MESSAGE', 'TICKET_CLAIMED_MESSAGE', 'TICKET_UNCLAIMED_MESSAGE',
|
|
||||||
'REMINDER_MESSAGE', 'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM',
|
|
||||||
'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM',
|
|
||||||
// Branding
|
|
||||||
'LOGO_URL', 'SUPPORT_NAME', 'EMAIL_SIGNATURE', 'GAME_LIST',
|
|
||||||
// Toggles
|
|
||||||
'AUTO_CLOSE_ENABLED', 'AUTO_CLOSE_AFTER_HOURS', 'AUTO_UNCLAIM_ENABLED', 'AUTO_UNCLAIM_AFTER_HOURS',
|
|
||||||
'CLAIM_TIMEOUT_ENABLED', 'CLAIM_TIMEOUT_HOURS', 'ALLOW_CLAIM_OVERWRITE',
|
|
||||||
'REMINDER_ENABLED', 'REMINDER_AFTER_HOURS', 'PRIORITY_ENABLED', 'DEFAULT_PRIORITY',
|
|
||||||
'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID',
|
|
||||||
'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE',
|
|
||||||
'STAFF_DND_COUNTS_AS_AVAILABLE',
|
|
||||||
// Limits and thresholds
|
|
||||||
'GLOBAL_TICKET_LIMIT', 'TICKET_LIMIT_PER_CATEGORY',
|
|
||||||
'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES',
|
|
||||||
'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS',
|
|
||||||
// Embed colors
|
|
||||||
'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLOSED', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO',
|
|
||||||
'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI',
|
|
||||||
// Pattern thresholds
|
|
||||||
'PATTERN_USER_TICKET_THRESHOLD', 'PATTERN_GAME_TICKET_THRESHOLD',
|
|
||||||
'PATTERN_STAFF_STALE_PING_THRESHOLD', 'PATTERN_ESCALATION_THRESHOLD',
|
|
||||||
'PATTERN_RAPID_CLOSE_SECONDS', 'PATTERN_UNCLAIMED_HOURS', 'PATTERN_CHECK_INTERVAL_MINUTES',
|
|
||||||
// Surge settings
|
|
||||||
'SURGE_ROLE_ID', 'SURGE_TICKET_COUNT', 'SURGE_TICKET_WINDOW_MINUTES',
|
|
||||||
'SURGE_GAME_TICKET_COUNT', 'SURGE_GAME_TICKET_WINDOW_MINUTES',
|
|
||||||
'SURGE_STALE_COUNT', 'SURGE_STALE_HOURS',
|
|
||||||
'SURGE_NEEDS_RESPONSE_COUNT', 'SURGE_NEEDS_RESPONSE_HOURS',
|
|
||||||
'SURGE_UNCLAIMED_COUNT', 'SURGE_UNCLAIMED_MINUTES', 'SURGE_TIER3_UNCLAIMED_MINUTES',
|
|
||||||
'SURGE_COOLDOWN_MINUTES', 'SURGE_NO_STAFF_COOLDOWN_MINUTES', 'SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD',
|
|
||||||
// Chat alerts
|
|
||||||
'CHAT_ALERT_CHANNEL_IDS', 'CHAT_ALERT_MESSAGE_COUNT',
|
|
||||||
'CHAT_ALERT_HOURS_WITHOUT_RESPONSE', 'CHAT_ALERT_COOLDOWN_MINUTES',
|
|
||||||
// Notification thresholds
|
|
||||||
'NOTIFICATION_THRESHOLDS_JSON', 'UNCLAIMED_REMINDER_THRESHOLDS',
|
|
||||||
// Notification enable state (Phase 9)
|
|
||||||
'NOTIFICATION_ENABLED_JSON', 'NOTIFICATIONS_MASTER_ENABLED'
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ---------- Regex primitives ----------
|
|
||||||
|
|
||||||
const SNOWFLAKE_RE = /^[0-9]{17,20}$/;
|
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
const HEX_COLOR_RE = /^(?:0x|#)?([0-9A-Fa-f]{6})$/;
|
|
||||||
const INT_RE = /^-?\d+$/;
|
|
||||||
const NUMERIC_COERCE_RE = /^-?\d+(?:\.\d+)?$/;
|
|
||||||
|
|
||||||
function isEmptyInput(v) {
|
|
||||||
return v === '' || v === null || v === undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Validators ----------
|
|
||||||
|
|
||||||
const VALIDATORS = {
|
|
||||||
boolean: {
|
|
||||||
type: 'boolean',
|
|
||||||
validate(value) {
|
|
||||||
if (value === true || value === 'true') return { ok: true, coerced: true };
|
|
||||||
if (value === false || value === 'false') return { ok: true, coerced: false };
|
|
||||||
return { ok: false, error: 'must be true or false' };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
integer: {
|
|
||||||
type: 'integer',
|
|
||||||
validate(value) {
|
|
||||||
if (isEmptyInput(value)) return { ok: true, coerced: '' };
|
|
||||||
const str = String(value).trim();
|
|
||||||
if (!INT_RE.test(str)) return { ok: false, error: 'must be a whole number' };
|
|
||||||
const n = parseInt(str, 10);
|
|
||||||
if (!Number.isFinite(n) || n < 0) return { ok: false, error: 'must be zero or a positive integer' };
|
|
||||||
return { ok: true, coerced: n };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
hex_color: {
|
|
||||||
type: 'hex_color',
|
|
||||||
validate(value) {
|
|
||||||
if (isEmptyInput(value)) return { ok: true, coerced: '' };
|
|
||||||
const str = String(value).trim();
|
|
||||||
const m = str.match(HEX_COLOR_RE);
|
|
||||||
if (!m) return { ok: false, error: 'must be a 6-digit hex color like 0xRRGGBB or #RRGGBB' };
|
|
||||||
return { ok: true, coerced: '0x' + m[1].toUpperCase() };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
url: {
|
|
||||||
type: 'url',
|
|
||||||
validate(value) {
|
|
||||||
if (isEmptyInput(value)) return { ok: true, coerced: '' };
|
|
||||||
const str = String(value).trim();
|
|
||||||
try {
|
|
||||||
new URL(str);
|
|
||||||
return { ok: true, coerced: str };
|
|
||||||
} catch (_) {
|
|
||||||
return { ok: false, error: 'must be a valid URL (include the protocol)' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
email: {
|
|
||||||
type: 'email',
|
|
||||||
validate(value) {
|
|
||||||
if (isEmptyInput(value)) return { ok: true, coerced: '' };
|
|
||||||
const str = String(value).trim();
|
|
||||||
if (!EMAIL_RE.test(str)) return { ok: false, error: 'must look like a valid email address' };
|
|
||||||
return { ok: true, coerced: str };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
discord_id: {
|
|
||||||
type: 'discord_id',
|
|
||||||
validate(value) {
|
|
||||||
if (isEmptyInput(value)) return { ok: true, coerced: '' };
|
|
||||||
const str = String(value).trim();
|
|
||||||
if (!SNOWFLAKE_RE.test(str)) return { ok: false, error: 'must be a Discord ID (17–20 digits) or empty' };
|
|
||||||
return { ok: true, coerced: str };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
discord_id_list: {
|
|
||||||
type: 'discord_id_list',
|
|
||||||
validate(value) {
|
|
||||||
if (isEmptyInput(value)) return { ok: true, coerced: '' };
|
|
||||||
const str = String(value).trim();
|
|
||||||
if (str === '') return { ok: true, coerced: '' };
|
|
||||||
const parts = str.split(',').map(p => p.trim()).filter(Boolean);
|
|
||||||
for (const p of parts) {
|
|
||||||
if (!SNOWFLAKE_RE.test(p)) return { ok: false, error: `"${p}" is not a Discord ID` };
|
|
||||||
}
|
|
||||||
return { ok: true, coerced: parts.join(',') };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
json: {
|
|
||||||
type: 'json',
|
|
||||||
validate(value) {
|
|
||||||
if (isEmptyInput(value)) return { ok: true, coerced: '' };
|
|
||||||
const str = String(value);
|
|
||||||
try {
|
|
||||||
JSON.parse(str);
|
|
||||||
return { ok: true, coerced: str };
|
|
||||||
} catch (_) {
|
|
||||||
return { ok: false, error: 'must be valid JSON' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
string_or_json: {
|
|
||||||
type: 'string_or_json',
|
|
||||||
validate(value) {
|
|
||||||
if (value === null || value === undefined) return { ok: false, error: 'cannot be null' };
|
|
||||||
return { ok: true, coerced: String(value) };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Fallback. Preserves legacy coercion so CONFIG.* values keep their types
|
|
||||||
// for consumers that compare with === true / === 5 (see old applyConfigUpdates).
|
|
||||||
string: {
|
|
||||||
type: 'string',
|
|
||||||
validate(value) {
|
|
||||||
if (value === null || value === undefined) return { ok: false, error: 'cannot be null' };
|
|
||||||
if (value === 'true' || value === true) return { ok: true, coerced: true };
|
|
||||||
if (value === 'false' || value === false) return { ok: true, coerced: false };
|
|
||||||
const str = String(value);
|
|
||||||
if (str !== '' && NUMERIC_COERCE_RE.test(str)) return { ok: true, coerced: Number(str) };
|
|
||||||
return { ok: true, coerced: str };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------- Type inference ----------
|
|
||||||
|
|
||||||
function inferType(key) {
|
|
||||||
// 1. Explicit overrides
|
|
||||||
if (key === 'NOTIFICATION_THRESHOLDS_JSON') return 'json';
|
|
||||||
if (key === 'NOTIFICATION_ENABLED_JSON') return 'json';
|
|
||||||
if (key === 'NOTIFICATIONS_MASTER_ENABLED') return 'boolean';
|
|
||||||
if (key === 'LOGO_URL') return 'url';
|
|
||||||
if (/_EMAIL$/.test(key)) return 'email';
|
|
||||||
if (key.includes('COLOR')) return 'hex_color';
|
|
||||||
if (/_EMOJIS$/.test(key)) return 'string_or_json';
|
|
||||||
// ROLE_ID_TO_PING has _ID mid-key — standard _ID$ pattern misses it.
|
|
||||||
if (key === 'ROLE_ID_TO_PING') return 'discord_id';
|
|
||||||
|
|
||||||
// 2. Name patterns
|
|
||||||
if (/ENABLED$|^USE_|_ON$/.test(key)) return 'boolean';
|
|
||||||
if (/_IDS$/.test(key)) return 'discord_id_list';
|
|
||||||
if (/_ID$/.test(key)) return 'discord_id';
|
|
||||||
if (/_HOURS$|_MINUTES$|_SECONDS$|_COUNT$|_LIMIT$|_THRESHOLD$/.test(key)) return 'integer';
|
|
||||||
|
|
||||||
// 3. Fallback
|
|
||||||
return 'string';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getValidator(key) {
|
|
||||||
return VALIDATORS[inferType(key)];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-build per-key validator map for callers that want O(1) lookup
|
|
||||||
// (and for the smoke test / boot log).
|
|
||||||
const ALL_VALIDATORS = {};
|
|
||||||
for (const key of ALLOWED_CONFIG_KEYS) {
|
|
||||||
ALL_VALIDATORS[key] = getValidator(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Startup log (no-op if console.log is suppressed) ----------
|
|
||||||
|
|
||||||
(function logDistribution() {
|
|
||||||
const dist = {};
|
|
||||||
const fallback = [];
|
|
||||||
for (const [key, v] of Object.entries(ALL_VALIDATORS)) {
|
|
||||||
dist[v.type] = (dist[v.type] || 0) + 1;
|
|
||||||
if (v.type === 'string') fallback.push(key);
|
|
||||||
}
|
|
||||||
console.log('[configSchema] type distribution:', JSON.stringify(dist));
|
|
||||||
if (fallback.length) {
|
|
||||||
console.log(`[configSchema] ${fallback.length} keys use fallback 'string' validator:`, fallback.join(', '));
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
ALLOWED_CONFIG_KEYS,
|
|
||||||
VALIDATORS,
|
|
||||||
ALL_VALIDATORS,
|
|
||||||
getValidator,
|
|
||||||
inferType
|
|
||||||
};
|
|
||||||
@@ -1,675 +0,0 @@
|
|||||||
/**
|
|
||||||
* Ticket database helpers – counters, rename, limits, auto-close,
|
|
||||||
* reminders, auto-unclaim, channel creation.
|
|
||||||
*/
|
|
||||||
const { ChannelType, PermissionFlagsBits } = require('discord.js');
|
|
||||||
const { mongoose, withRetry } = require('../db-connection');
|
|
||||||
const { CONFIG } = require('../config');
|
|
||||||
const { getPriorityEmoji } = require('../utils');
|
|
||||||
const { logAutomation } = require('../services/debugLog');
|
|
||||||
const { enqueueSend, enqueueDelete } = require('./channelQueue');
|
|
||||||
|
|
||||||
const Ticket = mongoose.model('Ticket');
|
|
||||||
const TicketCounter = mongoose.model('TicketCounter');
|
|
||||||
|
|
||||||
// --- TICKET NUMBER ---
|
|
||||||
|
|
||||||
async function getNextTicketNumber(senderEmail) {
|
|
||||||
const senderLocal = senderEmail.split('@')[0].toLowerCase();
|
|
||||||
const counter = await TicketCounter.findOneAndUpdate(
|
|
||||||
{ senderLocal },
|
|
||||||
{ $inc: { counter: 1 } },
|
|
||||||
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
|
||||||
);
|
|
||||||
return { local: senderLocal, number: counter.counter };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- RENAME + NAMING ---
|
|
||||||
// Renames flow through utils/renamer.js (RENAMER_BOT secondary token),
|
|
||||||
// which has its own Discord rate-limit bucket. We no longer gate on the
|
|
||||||
// primary bot's 2/10min per-channel budget here; 429s from the secondary
|
|
||||||
// bot surface via utils/renamer.js instead.
|
|
||||||
|
|
||||||
const RENAME_WINDOW_MS = 10 * 60 * 1000; // 10 minutes (unused; kept for back-compat)
|
|
||||||
const RENAME_LIMIT = 2;
|
|
||||||
|
|
||||||
function getSenderLocal(senderEmail) {
|
|
||||||
return (senderEmail || 'unknown').split('@')[0].toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toDiscordSafeName(str) {
|
|
||||||
return str
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.replace(/[^\p{L}\p{N}\p{Emoji_Presentation}-]/gu, '')
|
|
||||||
.replace(/-{2,}/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '')
|
|
||||||
.slice(0, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a human-friendly creator nickname for channel naming.
|
|
||||||
* Discord tickets: guild member displayName. Email tickets: senderLocal.
|
|
||||||
* @param {import('discord.js').Guild} guild
|
|
||||||
* @param {object} ticket
|
|
||||||
* @returns {Promise<string>}
|
|
||||||
*/
|
|
||||||
async function resolveCreatorNickname(guild, ticket) {
|
|
||||||
if (ticket.gmailThreadId.startsWith('discord-')) {
|
|
||||||
const creatorUserId = ticket.gmailThreadId.split('-').pop();
|
|
||||||
try {
|
|
||||||
const member = await guild.members.fetch(creatorUserId);
|
|
||||||
return member.displayName;
|
|
||||||
} catch {
|
|
||||||
return getSenderLocal(ticket.senderEmail);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return getSenderLocal(ticket.senderEmail);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a channel name from ticket state.
|
|
||||||
* @param {'unclaimed'|'claimed'|'escalated'|'escalated-claimed'} state
|
|
||||||
* @param {object} ticket
|
|
||||||
* @param {string} creatorNickname - pre-resolved via resolveCreatorNickname
|
|
||||||
* @param {string} [claimerEmoji] - required for claimed / escalated-claimed
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
function makeTicketName(state, ticket, creatorNickname, claimerEmoji) {
|
|
||||||
const num = ticket.ticketNumber || 1;
|
|
||||||
switch (state) {
|
|
||||||
case 'claimed':
|
|
||||||
return toDiscordSafeName(`${claimerEmoji}-${creatorNickname}-${num}`);
|
|
||||||
case 'escalated':
|
|
||||||
return toDiscordSafeName(`escalated-${creatorNickname}-${num}`);
|
|
||||||
case 'escalated-claimed':
|
|
||||||
return toDiscordSafeName(`e-${claimerEmoji}-${creatorNickname}-${num}`);
|
|
||||||
case 'unclaimed':
|
|
||||||
default:
|
|
||||||
return toDiscordSafeName(`unclaimed-${creatorNickname}-${num}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retained for external callers (bOSScord, scripts). The gate now lives in
|
|
||||||
// the secondary bot's rate bucket; this helper no longer touches Mongo.
|
|
||||||
async function canRename(_ticket) {
|
|
||||||
return { ok: true, remaining: RENAME_LIMIT, waitMs: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function minutesFromMs(ms) {
|
|
||||||
return Math.max(1, Math.ceil(ms / 60000));
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- RATE LIMIT (per-user ticket creation) ---
|
|
||||||
|
|
||||||
const ticketCreationByUser = new Map(); // userId -> { count, resetAt }
|
|
||||||
|
|
||||||
const TICKET_CREATION_SWEEP_TTL_MS = 48 * 60 * 60 * 1000;
|
|
||||||
const TICKET_CREATION_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
function sweepTicketCreationByUser(now = Date.now()) {
|
|
||||||
// An entry is stale when its window has been expired long enough that no
|
|
||||||
// legitimate rate-limit decision would still consult it. resetAt is a future
|
|
||||||
// ms timestamp when the window ends; cutoff is 48h past that.
|
|
||||||
const cutoff = now - TICKET_CREATION_SWEEP_TTL_MS;
|
|
||||||
for (const [key, entry] of ticketCreationByUser.entries()) {
|
|
||||||
if ((entry?.resetAt ?? 0) < cutoff) ticketCreationByUser.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startTicketsSweeps(trackInterval) {
|
|
||||||
const handle = setInterval(() => sweepTicketCreationByUser(), TICKET_CREATION_SWEEP_INTERVAL_MS);
|
|
||||||
if (typeof handle.unref === 'function') handle.unref();
|
|
||||||
if (typeof trackInterval === 'function') trackInterval(handle);
|
|
||||||
return handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the user can create a ticket (rate limit). If allowed, consumes one slot.
|
|
||||||
* @param {string} userId - Discord user ID
|
|
||||||
* @returns {{ allowed: boolean, retryAfterMs?: number }}
|
|
||||||
*/
|
|
||||||
function checkTicketCreationRateLimit(userId) {
|
|
||||||
const limit = CONFIG.RATE_LIMIT_TICKETS_PER_USER;
|
|
||||||
const windowMs = (CONFIG.RATE_LIMIT_WINDOW_MINUTES || 60) * 60 * 1000;
|
|
||||||
if (!limit || limit <= 0) return { allowed: true };
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
let entry = ticketCreationByUser.get(userId);
|
|
||||||
if (!entry || now >= entry.resetAt) {
|
|
||||||
entry = { count: 1, resetAt: now + windowMs };
|
|
||||||
ticketCreationByUser.set(userId, entry);
|
|
||||||
return { allowed: true };
|
|
||||||
}
|
|
||||||
if (entry.count >= limit) {
|
|
||||||
return { allowed: false, retryAfterMs: entry.resetAt - now };
|
|
||||||
}
|
|
||||||
entry.count++;
|
|
||||||
return { allowed: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- CHANNEL CREATION (overflow: Discord limit 50 channels per category) ---
|
|
||||||
|
|
||||||
const CHANNELS_PER_CATEGORY_LIMIT = 50;
|
|
||||||
|
|
||||||
function escapeCategoryNameForRegex(name) {
|
|
||||||
return String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use getOrCreateTicketCategory instead.
|
|
||||||
* @returns {null}
|
|
||||||
*/
|
|
||||||
function pickTicketCategoryId(guild, categoryIds) {
|
|
||||||
console.warn('[tickets] pickTicketCategoryId is deprecated; use getOrCreateTicketCategory() instead');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function countChannelsInCategory(guild, categoryId) {
|
|
||||||
return guild.channels.cache.filter(c => c.parentId === categoryId).size;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve or create a ticket category with dynamic overflow (Discord max 50 channels per category).
|
|
||||||
* @param {import('discord.js').Guild} guild
|
|
||||||
* @param {string} primaryCategoryId
|
|
||||||
* @param {string} categoryName Display base name (primary category should match; overflows are "(Overflow N)")
|
|
||||||
* @returns {Promise<string>}
|
|
||||||
*/
|
|
||||||
async function getOrCreateTicketCategory(guild, primaryCategoryId, categoryName) {
|
|
||||||
if (!guild) {
|
|
||||||
throw new Error('getOrCreateTicketCategory: guild is required');
|
|
||||||
}
|
|
||||||
if (!primaryCategoryId || !String(primaryCategoryId).trim()) {
|
|
||||||
throw new Error('getOrCreateTicketCategory: primaryCategoryId is required');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
let primary = guild.channels.cache.get(primaryCategoryId);
|
|
||||||
if (!primary) {
|
|
||||||
primary = await guild.channels.fetch(primaryCategoryId).catch(() => null);
|
|
||||||
}
|
|
||||||
if (!primary || primary.type !== ChannelType.GuildCategory) {
|
|
||||||
throw new Error(`getOrCreateTicketCategory: primary category ${primaryCategoryId} not found or not a category`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const escaped = escapeCategoryNameForRegex(categoryName);
|
|
||||||
const overflowRe = new RegExp(`^${escaped} \\(Overflow (\\d+)\\)$`);
|
|
||||||
|
|
||||||
const overflowMatches = [];
|
|
||||||
for (const ch of guild.channels.cache.values()) {
|
|
||||||
if (!ch || ch.type !== ChannelType.GuildCategory) continue;
|
|
||||||
if (ch.id === primaryCategoryId) continue;
|
|
||||||
const m = ch.name.match(overflowRe);
|
|
||||||
if (m) overflowMatches.push({ ch, n: parseInt(m[1], 10) });
|
|
||||||
}
|
|
||||||
overflowMatches.sort((a, b) => a.n - b.n);
|
|
||||||
|
|
||||||
const existingCategories = [primary, ...overflowMatches.map(x => x.ch)];
|
|
||||||
|
|
||||||
for (const cat of existingCategories) {
|
|
||||||
if (countChannelsInCategory(guild, cat.id) < CHANNELS_PER_CATEGORY_LIMIT) {
|
|
||||||
return cat.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const highestN = overflowMatches.length > 0 ? Math.max(...overflowMatches.map(x => x.n)) : 0;
|
|
||||||
const nextN = highestN + 1;
|
|
||||||
const newName = `${categoryName} (Overflow ${nextN})`;
|
|
||||||
const lastCat = existingCategories[existingCategories.length - 1];
|
|
||||||
const position = (lastCat?.rawPosition ?? lastCat?.position ?? 0) + 1;
|
|
||||||
|
|
||||||
let newCat;
|
|
||||||
try {
|
|
||||||
newCat = await guild.channels.create({
|
|
||||||
name: newName,
|
|
||||||
type: ChannelType.GuildCategory,
|
|
||||||
position
|
|
||||||
});
|
|
||||||
} catch (createErr) {
|
|
||||||
console.error('getOrCreateTicketCategory: failed to create overflow category:', createErr);
|
|
||||||
throw createErr;
|
|
||||||
}
|
|
||||||
return newCat.id;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('getOrCreateTicketCategory:', err);
|
|
||||||
const fallback = guild.channels.cache.get(primaryCategoryId);
|
|
||||||
if (fallback?.type === ChannelType.GuildCategory) {
|
|
||||||
return primaryCategoryId;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete an overflow category if it is empty and its name matches "${categoryName} (Overflow N)".
|
|
||||||
* Never deletes the primary category (exact name match).
|
|
||||||
* @param {import('discord.js').Guild} guild
|
|
||||||
* @param {string} categoryId
|
|
||||||
* @param {string} categoryName
|
|
||||||
*/
|
|
||||||
async function cleanupEmptyOverflowCategory(guild, categoryId, categoryName) {
|
|
||||||
try {
|
|
||||||
if (!guild || !categoryId) return;
|
|
||||||
const cached = guild.channels.cache.filter(c => c.parentId === categoryId);
|
|
||||||
if (cached.size !== 0) return;
|
|
||||||
|
|
||||||
let cat = guild.channels.cache.get(categoryId);
|
|
||||||
if (!cat) {
|
|
||||||
cat = await guild.channels.fetch(categoryId).catch(() => null);
|
|
||||||
}
|
|
||||||
if (!cat || cat.type !== ChannelType.GuildCategory) return;
|
|
||||||
if (cat.name === categoryName) return;
|
|
||||||
|
|
||||||
const escaped = escapeCategoryNameForRegex(categoryName);
|
|
||||||
const overflowRe = new RegExp(`^${escaped} \\(Overflow \\d+\\)$`);
|
|
||||||
if (!overflowRe.test(cat.name)) return;
|
|
||||||
|
|
||||||
await cat.delete().catch(deleteErr => {
|
|
||||||
console.error('cleanupEmptyOverflowCategory: delete failed:', deleteErr);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('cleanupEmptyOverflowCategory:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createTicketChannel(guild, ticketNumber, userId, subject, creatorNickname) {
|
|
||||||
if (CONFIG.USE_THREADS && CONFIG.THREAD_PARENT_CHANNEL) {
|
|
||||||
const parentChannel = guild.channels.cache.get(CONFIG.THREAD_PARENT_CHANNEL);
|
|
||||||
if (!parentChannel) {
|
|
||||||
throw new Error('Thread parent channel not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const thread = await parentChannel.threads.create({
|
|
||||||
name: `🎫・ticket-${ticketNumber}`,
|
|
||||||
autoArchiveDuration: 1440,
|
|
||||||
type: ChannelType.PrivateThread,
|
|
||||||
invitable: false,
|
|
||||||
reason: `Ticket #${ticketNumber}`
|
|
||||||
});
|
|
||||||
|
|
||||||
await thread.members.add(userId);
|
|
||||||
// Add all members with the support role so they can see and reply in the thread
|
|
||||||
if (CONFIG.ROLE_ID_TO_PING) {
|
|
||||||
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
|
|
||||||
if (role?.members?.size) {
|
|
||||||
for (const [memberId] of role.members) {
|
|
||||||
if (memberId === userId) continue; // already added
|
|
||||||
await thread.members.add(memberId).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return thread;
|
|
||||||
} else {
|
|
||||||
let parentId;
|
|
||||||
try {
|
|
||||||
parentId = await getOrCreateTicketCategory(guild, CONFIG.TICKET_CATEGORY_ID, CONFIG.TICKET_CATEGORY_NAME);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('getOrCreateTicketCategory (createTicketChannel):', e);
|
|
||||||
throw new Error('Ticket category not found or could not be allocated');
|
|
||||||
}
|
|
||||||
|
|
||||||
let channel;
|
|
||||||
try {
|
|
||||||
channel = await guild.channels.create({
|
|
||||||
name: creatorNickname ? toDiscordSafeName(`unclaimed-${creatorNickname}-${ticketNumber}`) : `ticket-${ticketNumber}`,
|
|
||||||
type: ChannelType.GuildText,
|
|
||||||
parent: parentId,
|
|
||||||
permissionOverwrites: [
|
|
||||||
{
|
|
||||||
id: guild.id,
|
|
||||||
deny: [PermissionFlagsBits.ViewChannel]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: userId,
|
|
||||||
allow: [
|
|
||||||
PermissionFlagsBits.ViewChannel,
|
|
||||||
PermissionFlagsBits.SendMessages,
|
|
||||||
PermissionFlagsBits.ReadMessageHistory
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: CONFIG.ROLE_ID_TO_PING,
|
|
||||||
allow: [
|
|
||||||
PermissionFlagsBits.ViewChannel,
|
|
||||||
PermissionFlagsBits.SendMessages,
|
|
||||||
PermissionFlagsBits.ReadMessageHistory
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error('guild.channels.create (createTicketChannel):', e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
return channel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a private Discord ticket thread under DISCORD_THREAD_CHANNEL_ID.
|
|
||||||
* Adds creator and all members with ROLE_ID_TO_PING.
|
|
||||||
* @param {import('discord.js').Guild} guild
|
|
||||||
* @param {number} ticketNumber
|
|
||||||
* @param {string} creatorUserId
|
|
||||||
* @returns {Promise<import('discord.js').ThreadChannel>}
|
|
||||||
*/
|
|
||||||
async function createDiscordTicketAsThread(guild, ticketNumber, creatorUserId) {
|
|
||||||
const parentId = CONFIG.DISCORD_THREAD_CHANNEL_ID;
|
|
||||||
if (!parentId) throw new Error('DISCORD_THREAD_CHANNEL_ID is not set');
|
|
||||||
const parentChannel = guild.channels.cache.get(parentId);
|
|
||||||
if (!parentChannel) throw new Error('Discord thread parent channel not found');
|
|
||||||
|
|
||||||
const thread = await parentChannel.threads.create({
|
|
||||||
name: `🎫・ticket-${ticketNumber}`,
|
|
||||||
autoArchiveDuration: 1440,
|
|
||||||
type: ChannelType.PrivateThread,
|
|
||||||
invitable: false,
|
|
||||||
reason: `Ticket #${ticketNumber}`
|
|
||||||
});
|
|
||||||
|
|
||||||
await thread.members.add(creatorUserId);
|
|
||||||
if (CONFIG.ROLE_ID_TO_PING) {
|
|
||||||
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
|
|
||||||
if (role?.members?.size) {
|
|
||||||
for (const [memberId] of role.members) {
|
|
||||||
if (memberId === creatorUserId) continue;
|
|
||||||
await thread.members.add(memberId).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return thread;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a private email ticket thread under EMAIL_THREAD_CHANNEL_ID.
|
|
||||||
* Adds all members with ROLE_ID_TO_PING (no creator; email tickets have no Discord user).
|
|
||||||
* @param {import('discord.js').Guild} guild
|
|
||||||
* @param {number} ticketNumber
|
|
||||||
* @param {string} chanName
|
|
||||||
* @returns {Promise<import('discord.js').ThreadChannel>}
|
|
||||||
*/
|
|
||||||
async function createEmailTicketAsThread(guild, ticketNumber, chanName) {
|
|
||||||
const parentId = CONFIG.EMAIL_THREAD_CHANNEL_ID;
|
|
||||||
if (!parentId) throw new Error('EMAIL_THREAD_CHANNEL_ID is not set');
|
|
||||||
const parentChannel = guild.channels.cache.get(parentId);
|
|
||||||
if (!parentChannel) throw new Error('Email thread parent channel not found');
|
|
||||||
|
|
||||||
const thread = await parentChannel.threads.create({
|
|
||||||
name: chanName || `🎫・ticket-${ticketNumber}`,
|
|
||||||
autoArchiveDuration: 1440,
|
|
||||||
type: ChannelType.PrivateThread,
|
|
||||||
invitable: false,
|
|
||||||
reason: `Ticket #${ticketNumber}`
|
|
||||||
});
|
|
||||||
|
|
||||||
if (CONFIG.ROLE_ID_TO_PING) {
|
|
||||||
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
|
|
||||||
if (role?.members?.size) {
|
|
||||||
for (const [memberId] of role.members) {
|
|
||||||
await thread.members.add(memberId).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return thread;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- LIMITS & PERMISSIONS ---
|
|
||||||
|
|
||||||
async function checkTicketLimits(senderEmail) {
|
|
||||||
if (!CONFIG.GLOBAL_TICKET_LIMIT) return { ok: true };
|
|
||||||
|
|
||||||
const currentCount = await Ticket.countDocuments({ senderEmail, status: 'open' });
|
|
||||||
if (currentCount >= CONFIG.GLOBAL_TICKET_LIMIT) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
reason: `You have reached the maximum limit of ${CONFIG.GLOBAL_TICKET_LIMIT} open tickets.`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasBlacklistedRole(member) {
|
|
||||||
if (!CONFIG.BLACKLISTED_ROLES || CONFIG.BLACKLISTED_ROLES.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return member.roles.cache.some(role =>
|
|
||||||
CONFIG.BLACKLISTED_ROLES.includes(role.id)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- ACTIVITY ---
|
|
||||||
|
|
||||||
async function updateTicketActivity(gmailThreadId) {
|
|
||||||
const now = new Date();
|
|
||||||
await Ticket.updateOne(
|
|
||||||
{ gmailThreadId },
|
|
||||||
{ $set: { lastActivity: now, reminderSent: false } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- SCHEDULED CHECKS ---
|
|
||||||
// These accept `client` and optionally `sendTicketClosedEmail` to avoid circular deps.
|
|
||||||
|
|
||||||
async function checkAutoClose(client, sendTicketClosedEmail) {
|
|
||||||
if (!CONFIG.AUTO_CLOSE_ENABLED) return;
|
|
||||||
|
|
||||||
const cutoffTime = new Date(Date.now() - (CONFIG.AUTO_CLOSE_AFTER_HOURS * 60 * 60 * 1000));
|
|
||||||
// Bounded per-tick so a huge backlog drains across successive hourly runs.
|
|
||||||
const staleTickets = await withRetry(() => Ticket.find({
|
|
||||||
status: 'open',
|
|
||||||
lastActivity: { $lt: cutoffTime, $ne: null }
|
|
||||||
}).sort({ createdAt: 1 }).limit(500).lean());
|
|
||||||
|
|
||||||
let checked = 0, closed = 0;
|
|
||||||
for (const ticket of staleTickets) {
|
|
||||||
checked++;
|
|
||||||
try {
|
|
||||||
const guild = client.guilds.cache.first();
|
|
||||||
if (!guild) continue;
|
|
||||||
|
|
||||||
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
|
||||||
if (channel) {
|
|
||||||
await enqueueSend(channel, CONFIG.DISCORD_AUTO_CLOSE_MESSAGE);
|
|
||||||
|
|
||||||
// Persist pendingDelete BEFORE the delay so a shutdown mid-delay can be
|
|
||||||
// resumed on boot via resumePendingDeletes(). Cleared after enqueueDelete
|
|
||||||
// resolves; if the doc is gone the unset is a no-op.
|
|
||||||
await withRetry(() => Ticket.updateOne(
|
|
||||||
{ gmailThreadId: ticket.gmailThreadId },
|
|
||||||
{ $set: { status: 'closed', pendingDelete: true } }
|
|
||||||
));
|
|
||||||
|
|
||||||
await sendTicketClosedEmail(ticket, 'Auto-Close System');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
enqueueDelete(channel).then(() => {
|
|
||||||
withRetry(() => Ticket.updateOne(
|
|
||||||
{ gmailThreadId: ticket.gmailThreadId },
|
|
||||||
{ $unset: { pendingDelete: '' } }
|
|
||||||
)).catch(() => {});
|
|
||||||
}).catch(() => {});
|
|
||||||
}, 5000);
|
|
||||||
closed++;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logAutomation('Auto-close run', null, `checked: ${checked}, closed: ${closed}`).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkReminders(client) {
|
|
||||||
if (!CONFIG.REMINDER_ENABLED) return;
|
|
||||||
|
|
||||||
const reminderTime = new Date(Date.now() - (CONFIG.REMINDER_AFTER_HOURS * 60 * 60 * 1000));
|
|
||||||
const ticketsNeedingReminder = await withRetry(() => Ticket.find({
|
|
||||||
status: 'open',
|
|
||||||
lastActivity: { $lt: reminderTime, $ne: null },
|
|
||||||
reminderSent: false
|
|
||||||
}).lean());
|
|
||||||
|
|
||||||
let checked = 0, reminded = 0;
|
|
||||||
for (const ticket of ticketsNeedingReminder) {
|
|
||||||
checked++;
|
|
||||||
try {
|
|
||||||
const guild = client.guilds.cache.first();
|
|
||||||
if (!guild) continue;
|
|
||||||
|
|
||||||
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
|
||||||
if (channel) {
|
|
||||||
const ping = ticket.claimedBy
|
|
||||||
? `<@${ticket.claimedBy}>`
|
|
||||||
: (CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'everyone');
|
|
||||||
const message = CONFIG.REMINDER_MESSAGE
|
|
||||||
.replace(/\{hours\}/g, String(CONFIG.REMINDER_AFTER_HOURS))
|
|
||||||
.replace(/\{ping\}/g, ping);
|
|
||||||
await enqueueSend(channel, message);
|
|
||||||
|
|
||||||
await withRetry(() => Ticket.updateOne(
|
|
||||||
{ gmailThreadId: ticket.gmailThreadId },
|
|
||||||
{ $set: { reminderSent: true } }
|
|
||||||
));
|
|
||||||
reminded++;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Reminder error for ticket ${ticket.gmailThreadId}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logAutomation('Reminder run', null, `checked: ${checked}, reminded: ${reminded}`).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkAutoUnclaim(client) {
|
|
||||||
if (!CONFIG.AUTO_UNCLAIM_ENABLED) return;
|
|
||||||
|
|
||||||
const unclaimTime = new Date(Date.now() - (CONFIG.AUTO_UNCLAIM_AFTER_HOURS * 60 * 60 * 1000));
|
|
||||||
const staleClaimedTickets = await withRetry(() => Ticket.find({
|
|
||||||
status: 'open',
|
|
||||||
claimedBy: { $ne: null },
|
|
||||||
lastActivity: { $lt: unclaimTime, $ne: null }
|
|
||||||
}).lean());
|
|
||||||
|
|
||||||
let checked = 0, unclaimed = 0;
|
|
||||||
for (const ticket of staleClaimedTickets) {
|
|
||||||
checked++;
|
|
||||||
try {
|
|
||||||
const guild = client.guilds.cache.first();
|
|
||||||
if (!guild) continue;
|
|
||||||
|
|
||||||
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
|
||||||
if (channel) {
|
|
||||||
await withRetry(() => Ticket.updateOne(
|
|
||||||
{ gmailThreadId: ticket.gmailThreadId },
|
|
||||||
{ $set: { claimedBy: null } }
|
|
||||||
));
|
|
||||||
|
|
||||||
await enqueueSend(channel,
|
|
||||||
`This ticket has been auto-unclaimed due to inactivity (${CONFIG.AUTO_UNCLAIM_AFTER_HOURS} hours).`
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`Auto-unclaimed ticket ${ticket.gmailThreadId}`);
|
|
||||||
unclaimed++;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Auto-unclaim error for ticket ${ticket.gmailThreadId}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logAutomation('Auto-unclaim run', null, `checked: ${checked}, unclaimed: ${unclaimed}`).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reconcileDeletedTicketChannels(client) {
|
|
||||||
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID) || client.guilds.cache.first();
|
|
||||||
if (!guild) return { checked: 0, reconciled: 0 };
|
|
||||||
|
|
||||||
// Bounded per-tick; a larger backlog drains in subsequent hourly runs.
|
|
||||||
const openTickets = await Ticket.find({
|
|
||||||
status: 'open',
|
|
||||||
discordThreadId: { $ne: null }
|
|
||||||
}).sort({ createdAt: 1 }).limit(500).lean();
|
|
||||||
|
|
||||||
let checked = 0, reconciled = 0;
|
|
||||||
for (const ticket of openTickets) {
|
|
||||||
checked++;
|
|
||||||
try {
|
|
||||||
let channel = guild.channels.cache.get(ticket.discordThreadId);
|
|
||||||
if (!channel) {
|
|
||||||
channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
|
||||||
}
|
|
||||||
if (!channel) {
|
|
||||||
await Ticket.updateOne(
|
|
||||||
{ gmailThreadId: ticket.gmailThreadId },
|
|
||||||
{ $set: { status: 'closed', discordThreadId: null } }
|
|
||||||
);
|
|
||||||
logAutomation('Reconcile: channel deleted', ticket.discordThreadId, `ticket #${ticket.ticketNumber}`).catch(() => {});
|
|
||||||
reconciled++;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`reconcileDeletedTicketChannels error for ${ticket.gmailThreadId}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (reconciled > 0) {
|
|
||||||
logAutomation('Reconcile run', null, `checked: ${checked}, reconciled: ${reconciled}`).catch(() => {});
|
|
||||||
}
|
|
||||||
return { checked, reconciled };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resume deletes that were pending when the bot last shut down. Called once
|
|
||||||
* from the ready handler. Clears the flag regardless of fetch result so a
|
|
||||||
* stale flag (e.g. channel already gone) can't loop.
|
|
||||||
*/
|
|
||||||
async function resumePendingDeletes(client) {
|
|
||||||
const pending = await Ticket.find({ pendingDelete: true }).lean().catch(() => []);
|
|
||||||
if (!pending.length) return 0;
|
|
||||||
let resumed = 0;
|
|
||||||
for (const ticket of pending) {
|
|
||||||
try {
|
|
||||||
const guild = client.guilds.cache.first();
|
|
||||||
if (guild && ticket.discordThreadId) {
|
|
||||||
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
|
||||||
if (channel) {
|
|
||||||
enqueueDelete(channel).catch(() => {});
|
|
||||||
resumed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ticket.updateOne(
|
|
||||||
{ gmailThreadId: ticket.gmailThreadId },
|
|
||||||
{ $unset: { pendingDelete: '' } }
|
|
||||||
).catch(() => {});
|
|
||||||
} catch (e) {
|
|
||||||
console.error('resumePendingDeletes error:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logAutomation('Pending-delete resume', null, `pending: ${pending.length}, resumed: ${resumed}`).catch(() => {});
|
|
||||||
return resumed;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
getNextTicketNumber,
|
|
||||||
getOrCreateTicketCategory,
|
|
||||||
cleanupEmptyOverflowCategory,
|
|
||||||
createDiscordTicketAsThread,
|
|
||||||
createEmailTicketAsThread,
|
|
||||||
RENAME_WINDOW_MS,
|
|
||||||
RENAME_LIMIT,
|
|
||||||
getSenderLocal,
|
|
||||||
toDiscordSafeName,
|
|
||||||
resolveCreatorNickname,
|
|
||||||
makeTicketName,
|
|
||||||
canRename,
|
|
||||||
minutesFromMs,
|
|
||||||
checkTicketCreationRateLimit,
|
|
||||||
createTicketChannel,
|
|
||||||
checkTicketLimits,
|
|
||||||
hasBlacklistedRole,
|
|
||||||
updateTicketActivity,
|
|
||||||
checkAutoClose,
|
|
||||||
checkReminders,
|
|
||||||
checkAutoUnclaim,
|
|
||||||
reconcileDeletedTicketChannels,
|
|
||||||
resumePendingDeletes,
|
|
||||||
startTicketsSweeps,
|
|
||||||
sweepTicketCreationByUser,
|
|
||||||
_internals: { ticketCreationByUser, TICKET_CREATION_SWEEP_TTL_MS }
|
|
||||||
};
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,484 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Broccolini Settings</title>
|
|
||||||
<link rel="stylesheet" href="/css/main.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="loading" class="loading"><div class="spinner"></div></div>
|
|
||||||
<div id="toast-container"></div>
|
|
||||||
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<div class="sidebar-backdrop" id="sidebar-backdrop" aria-hidden="true"></div>
|
|
||||||
<nav class="sidebar" id="sidebar">
|
|
||||||
<div class="logo">Broccolini Settings</div>
|
|
||||||
<a href="/" class="active">Core</a>
|
|
||||||
<a href="/channels">Channels</a>
|
|
||||||
<a href="/categories">Categories</a>
|
|
||||||
<a href="/gmail">Gmail</a>
|
|
||||||
<a href="/behavior">Ticket Behavior</a>
|
|
||||||
<a href="/threads">Staff Threads</a>
|
|
||||||
<a href="/pins">Pin Messages</a>
|
|
||||||
<a href="/notifications">Notifications</a>
|
|
||||||
<a href="/logging">Logging</a>
|
|
||||||
<a href="/automation">Automation</a>
|
|
||||||
<a href="/appearance">Appearance</a>
|
|
||||||
<a href="/staff">Staff</a>
|
|
||||||
<a href="/advanced">Advanced</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Top bar -->
|
|
||||||
<div class="topbar">
|
|
||||||
<button type="button" class="menu-toggle" id="menu-toggle" aria-label="Toggle navigation" aria-expanded="false" aria-controls="sidebar">
|
|
||||||
<span class="menu-toggle-bars" aria-hidden="true"></span>
|
|
||||||
</button>
|
|
||||||
<h1>Settings</h1>
|
|
||||||
<div class="status">
|
|
||||||
<span class="dot" id="bot-status-dot"></span>
|
|
||||||
<span id="bot-status-text">Checking...</span>
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button type="button" id="logout-btn">Logout</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main content -->
|
|
||||||
<div class="main">
|
|
||||||
|
|
||||||
<!-- 1. Core -->
|
|
||||||
<div class="section" id="s-core">
|
|
||||||
<div class="section-header"><h2>Core</h2><p>Discord bot credentials and guild</p><span class="chevron">▼</span></div>
|
|
||||||
<div class="section-body"><div class="field-grid">
|
|
||||||
<div class="field"><label>Discord Token</label><input type="password" data-key="DISCORD_TOKEN" placeholder="Bot token"></div>
|
|
||||||
<div class="field"><label>Application ID</label><input type="text" data-key="DISCORD_APPLICATION_ID"></div>
|
|
||||||
<div class="field"><label>Guild ID</label><input type="text" data-key="DISCORD_GUILD_ID"></div>
|
|
||||||
</div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 2. Channels -->
|
|
||||||
<div class="section" id="s-channels">
|
|
||||||
<div class="section-header"><h2>Channels</h2><p>Channel assignments for logging, transcripts, and alerts</p><span class="chevron">▼</span></div>
|
|
||||||
<div class="section-body"><div class="field-grid">
|
|
||||||
<div class="field"><label>Transcript Channel</label><input type="text" data-key="TRANSCRIPT_CHANNEL_ID" data-smart="channel"></div>
|
|
||||||
<div class="field"><label>Logging Channel</label><input type="text" data-key="LOGGING_CHANNEL_ID" data-smart="channel"></div>
|
|
||||||
<div class="field"><label>Debugging Channel</label><input type="text" data-key="DEBUGGING_CHANNEL_ID" data-smart="channel"></div>
|
|
||||||
<div class="field"><label>Backup/Export Channel</label><input type="text" data-key="BACKUP_EXPORT_CHANNEL_ID" data-smart="channel"></div>
|
|
||||||
<div class="field"><label>Account Info Channel</label><input type="text" data-key="ACCOUNT_INFO_CHANNEL_ID" data-smart="channel"></div>
|
|
||||||
<div class="field"><label>Gmail Log Channel</label><input type="text" data-key="GMAIL_LOG_CHANNEL_ID" data-smart="channel"></div>
|
|
||||||
<div class="field"><label>Automation Log Channel</label><input type="text" data-key="AUTOMATION_LOG_CHANNEL_ID" data-smart="channel"></div>
|
|
||||||
<div class="field"><label>Rename Log Channel</label><input type="text" data-key="RENAME_LOG_CHANNEL_ID" data-smart="channel"></div>
|
|
||||||
<div class="field"><label>Security Log Channel</label><input type="text" data-key="SECURITY_LOG_CHANNEL_ID" data-smart="channel"></div>
|
|
||||||
<div class="field"><label>System Log Channel</label><input type="text" data-key="SYSTEM_LOG_CHANNEL_ID" data-smart="channel"></div>
|
|
||||||
<div class="field"><label>All Staff Channel</label><input type="text" data-key="ALL_STAFF_CHANNEL_ID" data-smart="channel"></div>
|
|
||||||
<div class="field"><label>Chat Alert Channel</label><input type="text" data-key="ALL_STAFF_CHAT_ALERT_CHANNEL_ID" data-smart="channel"></div>
|
|
||||||
<div class="field"><label>User Patterns Channel</label><input type="text" data-key="USER_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
|
||||||
<div class="field"><label>Game Patterns Channel</label><input type="text" data-key="GAME_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
|
||||||
<div class="field"><label>Tag Patterns Channel</label><input type="text" data-key="TAG_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
|
||||||
<div class="field"><label>Escalation Patterns Channel</label><input type="text" data-key="ESCALATION_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
|
||||||
<div class="field"><label>Staff Patterns Channel</label><input type="text" data-key="STAFF_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
|
||||||
<div class="field"><label>Combined Patterns Channel</label><input type="text" data-key="COMBINED_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
|
||||||
</div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 3. Categories -->
|
|
||||||
<div class="section" id="s-categories">
|
|
||||||
<div class="section-header"><h2>Categories</h2><p>Ticket category assignments and escalation targets</p><span class="chevron">▼</span></div>
|
|
||||||
<div class="section-body"><div class="field-grid">
|
|
||||||
<div class="field"><label>Email Ticket Category</label><input type="text" data-key="TICKET_CATEGORY_ID" data-smart="category"></div>
|
|
||||||
<div class="field"><label>Discord Ticket Category</label><input type="text" data-key="DISCORD_TICKET_CATEGORY_ID" data-smart="category"></div>
|
|
||||||
<div class="field"><label>Email T2 Category</label><input type="text" data-key="EMAIL_ESCALATED2_CHANNEL_ID" data-smart="category"></div>
|
|
||||||
<div class="field"><label>Discord T2 Category</label><input type="text" data-key="DISCORD_ESCALATED2_CHANNEL_ID" data-smart="category"></div>
|
|
||||||
<div class="field"><label>Email T3 Category</label><input type="text" data-key="EMAIL_ESCALATED3_CHANNEL_ID" data-smart="category"></div>
|
|
||||||
<div class="field"><label>Discord T3 Category</label><input type="text" data-key="DISCORD_ESCALATED3_CHANNEL_ID" data-smart="category"></div>
|
|
||||||
<div class="field"><label>Staff Notification Category</label><input type="text" data-key="STAFF_NOTIFICATION_CATEGORY_ID" data-smart="category"></div>
|
|
||||||
<div class="field"><label>Category Name</label><input type="text" data-key="TICKET_CATEGORY_NAME"></div>
|
|
||||||
<div class="field"><label>T2 Category Name</label><input type="text" data-key="TICKET_T2_CATEGORY_NAME"></div>
|
|
||||||
<div class="field"><label>T3 Category Name</label><input type="text" data-key="TICKET_T3_CATEGORY_NAME"></div>
|
|
||||||
<div class="field"><label>Discord Thread Channel</label><input type="text" data-key="DISCORD_THREAD_CHANNEL_ID" data-smart="channel"></div>
|
|
||||||
<div class="field"><label>Email Thread Channel</label><input type="text" data-key="EMAIL_THREAD_CHANNEL_ID" data-smart="channel"></div>
|
|
||||||
</div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 4. Gmail -->
|
|
||||||
<div class="section" id="s-gmail">
|
|
||||||
<div class="section-header"><h2>Gmail</h2><p>Google OAuth credentials and email settings</p><span class="chevron">▼</span></div>
|
|
||||||
<div class="section-body"><div class="field-grid">
|
|
||||||
<div class="field"><label>Google Client ID</label><input type="text" data-key="GOOGLE_CLIENT_ID"></div>
|
|
||||||
<div class="field"><label>Google Client Secret</label><input type="password" data-key="GOOGLE_CLIENT_SECRET"></div>
|
|
||||||
<div class="field"><label>Refresh Token</label><input type="password" data-key="REFRESH_TOKEN"></div>
|
|
||||||
<div class="field"><label>Support Email</label><input type="email" data-key="MY_EMAIL"></div>
|
|
||||||
</div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 5. Ticket Behavior -->
|
|
||||||
<div class="section" id="s-behavior">
|
|
||||||
<div class="section-header"><h2>Ticket Behavior</h2><p>Automation, limits, and messages</p><span class="chevron">▼</span></div>
|
|
||||||
<div class="section-body"><div class="field-grid">
|
|
||||||
<div class="field"><label>Auto-Close</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="AUTO_CLOSE_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
|
||||||
<div class="field"><label>Auto-Close Hours</label><input type="number" data-key="AUTO_CLOSE_AFTER_HOURS"></div>
|
|
||||||
<div class="field"><label>Reminders</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="REMINDER_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
|
||||||
<div class="field"><label>Reminder Hours</label><input type="number" data-key="REMINDER_AFTER_HOURS"></div>
|
|
||||||
<div class="field"><label>Priority System</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="PRIORITY_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
|
||||||
<div class="field"><label>Claim Timeout</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="CLAIM_TIMEOUT_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
|
||||||
<div class="field"><label>Claim Timeout Hours</label><input type="number" data-key="CLAIM_TIMEOUT_HOURS"></div>
|
|
||||||
<div class="field"><label>Auto-Unclaim</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="AUTO_UNCLAIM_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
|
||||||
<div class="field"><label>Auto-Unclaim Hours</label><input type="number" data-key="AUTO_UNCLAIM_AFTER_HOURS"></div>
|
|
||||||
<div class="field"><label>Allow Claim Overwrite</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="ALLOW_CLAIM_OVERWRITE"><span class="slider"></span></label><span>Enabled</span></div></div>
|
|
||||||
<div class="field"><label>Global Ticket Limit</label><input type="number" data-key="GLOBAL_TICKET_LIMIT"></div>
|
|
||||||
<div class="field"><label>Rate Limit (per user)</label><input type="number" data-key="RATE_LIMIT_TICKETS_PER_USER"></div>
|
|
||||||
<div class="field"><label>Rate Limit Window (min)</label><input type="number" data-key="RATE_LIMIT_WINDOW_MINUTES"></div>
|
|
||||||
<div class="field"><label>Role to Ping</label><input type="text" data-key="ROLE_ID_TO_PING" data-smart="role"></div>
|
|
||||||
<div class="field full-width"><label>Welcome Message</label><textarea data-key="TICKET_WELCOME_MESSAGE" rows="3"></textarea></div>
|
|
||||||
<div class="field full-width"><label>Claimed Message</label><textarea data-key="TICKET_CLAIMED_MESSAGE" rows="2"></textarea><div class="hint">Variables: {staff_mention}, {staff_name}</div></div>
|
|
||||||
<div class="field full-width"><label>Unclaimed Message</label><textarea data-key="TICKET_UNCLAIMED_MESSAGE" rows="2"></textarea></div>
|
|
||||||
<div class="field full-width"><label>Escalation Message</label><textarea data-key="ESCALATION_MESSAGE" rows="3"></textarea><div class="hint">Variables: {support_name}</div></div>
|
|
||||||
<div class="field full-width"><label>Reminder Message</label><textarea data-key="REMINDER_MESSAGE" rows="2"></textarea><div class="hint">Variables: {ping}, {hours}</div></div>
|
|
||||||
</div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 6. Staff Threads -->
|
|
||||||
<div class="section" id="s-threads">
|
|
||||||
<div class="section-header"><h2>Staff Threads</h2><p>Private staff discussion threads on ticket channels</p><span class="chevron">▼</span></div>
|
|
||||||
<div class="section-body"><div class="field-grid">
|
|
||||||
<div class="field"><label>Enabled</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="STAFF_THREAD_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
|
||||||
<div class="field"><label>Thread Name</label><input type="text" data-key="STAFF_THREAD_NAME"></div>
|
|
||||||
<div class="field"><label>Auto-Add Role</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="STAFF_THREAD_AUTO_ADD_ROLE"><span class="slider"></span></label><span>Enabled</span></div></div>
|
|
||||||
<div class="field"><label>Staff Thread Role</label><input type="text" data-key="STAFF_THREAD_ROLE_ID" data-smart="role"></div>
|
|
||||||
</div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 7. Pin Messages -->
|
|
||||||
<div class="section" id="s-pins">
|
|
||||||
<div class="section-header"><h2>Pin Messages</h2><p>Auto-pin welcome and escalation messages</p><span class="chevron">▼</span></div>
|
|
||||||
<div class="section-body"><div class="field-grid">
|
|
||||||
<div class="field"><label>Pin Initial Message</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="PIN_INITIAL_MESSAGE_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
|
||||||
<div class="field"><label>Pin Escalation Message</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="PIN_ESCALATION_MESSAGE_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
|
||||||
<div class="field"><label>Suppress Pin Notice</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="PIN_SUPPRESS_SYSTEM_MESSAGE"><span class="slider"></span></label><span>Enabled</span></div></div>
|
|
||||||
</div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 8. Notifications -->
|
|
||||||
<div class="section" id="s-notifications">
|
|
||||||
<div class="section-header"><h2>Notifications</h2><p>Threshold milestones and trigger conditions by alert category</p><span class="chevron">▼</span></div>
|
|
||||||
<div class="section-body">
|
|
||||||
<input type="hidden" data-key="NOTIFICATION_THRESHOLDS_JSON">
|
|
||||||
|
|
||||||
<div class="notif-tabs" role="tablist" aria-label="Notification categories">
|
|
||||||
<button type="button" class="notif-tab-btn active" data-notif-tab="surge">Surge</button>
|
|
||||||
<button type="button" class="notif-tab-btn" data-notif-tab="patterns">Patterns</button>
|
|
||||||
<button type="button" class="notif-tab-btn" data-notif-tab="unclaimed">Unclaimed</button>
|
|
||||||
<button type="button" class="notif-tab-btn" data-notif-tab="chat">Chat</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="notif-panel" data-notif-panel="surge">
|
|
||||||
<div class="notif-toggle-row">
|
|
||||||
<div class="notif-toggle-group">
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" data-notif-master>
|
|
||||||
<span class="slider"></span>
|
|
||||||
</label>
|
|
||||||
<span class="notif-toggle-label">Master (all categories)</span>
|
|
||||||
</div>
|
|
||||||
<div class="notif-toggle-group">
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" data-notif-category-toggle="surge">
|
|
||||||
<span class="slider"></span>
|
|
||||||
</label>
|
|
||||||
<span class="notif-toggle-label">All in category</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="hint">Surge alerts fire when active ticket conditions cross thresholds — high volume, unclaimed queues, no staff online. Each alert escalates through its threshold list, spacing out pings as the condition persists. The counter resets when the condition clears.</p>
|
|
||||||
<div class="notif-editor">
|
|
||||||
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="surge"></select></div>
|
|
||||||
<div class="hint notif-alert-description" data-notif-description="surge"></div>
|
|
||||||
<div class="notif-per-alert-row">
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" data-notif-alert>
|
|
||||||
<span class="slider"></span>
|
|
||||||
</label>
|
|
||||||
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
|
|
||||||
</div>
|
|
||||||
<div class="notif-chips" data-notif-chips="surge"></div>
|
|
||||||
<div class="notif-input-row">
|
|
||||||
<input type="text" class="notif-threshold-input" data-notif-input="surge" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
|
||||||
<button type="button" class="notif-add-btn" data-notif-add="surge">Add</button>
|
|
||||||
</div>
|
|
||||||
<div class="notif-presets" data-notif-presets="surge"></div>
|
|
||||||
</div>
|
|
||||||
<details class="notif-trigger">
|
|
||||||
<summary>Trigger conditions</summary>
|
|
||||||
<div class="field-grid">
|
|
||||||
<div class="field"><label>Surge Role</label><input type="text" data-key="SURGE_ROLE_ID" data-smart="role"></div>
|
|
||||||
<div class="field"><label>Ticket Surge Count</label><input type="number" data-key="SURGE_TICKET_COUNT"></div>
|
|
||||||
<div class="field"><label>Ticket Window (min)</label><input type="number" data-key="SURGE_TICKET_WINDOW_MINUTES"></div>
|
|
||||||
<div class="field"><label>Game Surge Count</label><input type="number" data-key="SURGE_GAME_TICKET_COUNT"></div>
|
|
||||||
<div class="field"><label>Game Window (min)</label><input type="number" data-key="SURGE_GAME_TICKET_WINDOW_MINUTES"></div>
|
|
||||||
<div class="field"><label>Stale Count</label><input type="number" data-key="SURGE_STALE_COUNT"></div>
|
|
||||||
<div class="field"><label>Stale Hours</label><input type="number" data-key="SURGE_STALE_HOURS"></div>
|
|
||||||
<div class="field"><label>Needs Response Count</label><input type="number" data-key="SURGE_NEEDS_RESPONSE_COUNT"></div>
|
|
||||||
<div class="field"><label>Needs Response Hours</label><input type="number" data-key="SURGE_NEEDS_RESPONSE_HOURS"></div>
|
|
||||||
<div class="field"><label>Unclaimed Count</label><input type="number" data-key="SURGE_UNCLAIMED_COUNT"></div>
|
|
||||||
<div class="field"><label>Unclaimed Minutes</label><input type="number" data-key="SURGE_UNCLAIMED_MINUTES"></div>
|
|
||||||
<div class="field"><label>Tier 3 Unclaimed (min)</label><input type="number" data-key="SURGE_TIER3_UNCLAIMED_MINUTES"></div>
|
|
||||||
<div class="field"><label>Cooldown (min)</label><input type="number" data-key="SURGE_COOLDOWN_MINUTES"></div>
|
|
||||||
<div class="field"><label>DND = Available</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="STAFF_DND_COUNTS_AS_AVAILABLE"><span class="slider"></span></label><span>Enabled</span></div></div>
|
|
||||||
<div class="field"><label>No-Staff Cooldown (min)</label><input type="number" data-key="SURGE_NO_STAFF_COOLDOWN_MINUTES"></div>
|
|
||||||
<div class="field"><label>No-Staff Ticket Threshold</label><input type="number" data-key="SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD"></div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="notif-panel hidden" data-notif-panel="patterns">
|
|
||||||
<div class="notif-toggle-row">
|
|
||||||
<div class="notif-toggle-group">
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" data-notif-master>
|
|
||||||
<span class="slider"></span>
|
|
||||||
</label>
|
|
||||||
<span class="notif-toggle-label">Master (all categories)</span>
|
|
||||||
</div>
|
|
||||||
<div class="notif-toggle-group">
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" data-notif-category-toggle="patterns">
|
|
||||||
<span class="slider"></span>
|
|
||||||
</label>
|
|
||||||
<span class="notif-toggle-label">All in category</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="hint">Pattern alerts detect trends over time — surges by game, escalation rates, staff behavior. Each alert fires once per threshold crossed within its window (daily/weekly/monthly) and won't repeat until the next window resets.</p>
|
|
||||||
<div class="notif-editor">
|
|
||||||
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="patterns"></select></div>
|
|
||||||
<div class="hint notif-alert-description" data-notif-description="patterns"></div>
|
|
||||||
<div class="notif-per-alert-row">
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" data-notif-alert>
|
|
||||||
<span class="slider"></span>
|
|
||||||
</label>
|
|
||||||
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
|
|
||||||
</div>
|
|
||||||
<div class="notif-chips" data-notif-chips="patterns"></div>
|
|
||||||
<div class="notif-input-row">
|
|
||||||
<input type="text" class="notif-threshold-input" data-notif-input="patterns" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
|
||||||
<button type="button" class="notif-add-btn" data-notif-add="patterns">Add</button>
|
|
||||||
</div>
|
|
||||||
<div class="notif-presets" data-notif-presets="patterns"></div>
|
|
||||||
</div>
|
|
||||||
<details class="notif-trigger">
|
|
||||||
<summary>Trigger conditions</summary>
|
|
||||||
<div class="field-grid">
|
|
||||||
<div class="field"><label>Check Interval (min)</label><input type="number" data-key="PATTERN_CHECK_INTERVAL_MINUTES"></div>
|
|
||||||
<div class="field"><label>User Ticket Threshold</label><input type="number" data-key="PATTERN_USER_TICKET_THRESHOLD"></div>
|
|
||||||
<div class="field"><label>Game Ticket Threshold</label><input type="number" data-key="PATTERN_GAME_TICKET_THRESHOLD"></div>
|
|
||||||
<div class="field"><label>Staff Stale Ping Threshold</label><input type="number" data-key="PATTERN_STAFF_STALE_PING_THRESHOLD"></div>
|
|
||||||
<div class="field"><label>Escalation Threshold</label><input type="number" data-key="PATTERN_ESCALATION_THRESHOLD"></div>
|
|
||||||
<div class="field"><label>Rapid Close Seconds</label><input type="number" data-key="PATTERN_RAPID_CLOSE_SECONDS"></div>
|
|
||||||
<div class="field"><label>Unclaimed Hours</label><input type="number" data-key="PATTERN_UNCLAIMED_HOURS"></div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="notif-panel hidden" data-notif-panel="unclaimed">
|
|
||||||
<div class="notif-toggle-row">
|
|
||||||
<div class="notif-toggle-group">
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" data-notif-master>
|
|
||||||
<span class="slider"></span>
|
|
||||||
</label>
|
|
||||||
<span class="notif-toggle-label">Master (all categories)</span>
|
|
||||||
</div>
|
|
||||||
<div class="notif-toggle-group">
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" data-notif-category-toggle="unclaimed">
|
|
||||||
<span class="slider"></span>
|
|
||||||
</label>
|
|
||||||
<span class="notif-toggle-label">All in category</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="hint">Per-ticket reminders sent to staff notification channels when a ticket remains unclaimed. Each threshold fires once per ticket. Escalating a ticket resets the threshold list so reminders restart for the new tier.</p>
|
|
||||||
<div class="notif-editor">
|
|
||||||
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="unclaimed"></select></div>
|
|
||||||
<div class="hint notif-alert-description" data-notif-description="unclaimed"></div>
|
|
||||||
<div class="notif-per-alert-row">
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" data-notif-alert>
|
|
||||||
<span class="slider"></span>
|
|
||||||
</label>
|
|
||||||
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
|
|
||||||
</div>
|
|
||||||
<div class="notif-chips" data-notif-chips="unclaimed"></div>
|
|
||||||
<div class="notif-input-row">
|
|
||||||
<input type="text" class="notif-threshold-input" data-notif-input="unclaimed" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
|
||||||
<button type="button" class="notif-add-btn" data-notif-add="unclaimed">Add</button>
|
|
||||||
</div>
|
|
||||||
<div class="notif-presets" data-notif-presets="unclaimed"></div>
|
|
||||||
</div>
|
|
||||||
<details class="notif-trigger">
|
|
||||||
<summary>Trigger conditions</summary>
|
|
||||||
<div class="field-grid">
|
|
||||||
<div class="field full-width"><p class="hint">Unclaimed notifications use threshold milestones only.</p></div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="notif-panel hidden" data-notif-panel="chat">
|
|
||||||
<div class="notif-toggle-row">
|
|
||||||
<div class="notif-toggle-group">
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" data-notif-master>
|
|
||||||
<span class="slider"></span>
|
|
||||||
</label>
|
|
||||||
<span class="notif-toggle-label">Master (all categories)</span>
|
|
||||||
</div>
|
|
||||||
<div class="notif-toggle-group">
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" data-notif-category-toggle="chat">
|
|
||||||
<span class="slider"></span>
|
|
||||||
</label>
|
|
||||||
<span class="notif-toggle-label">All in category</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="hint">Monitors configured chat channels for unresponded user messages. Fires at escalating intervals while the condition persists. Resets when a staff member responds.</p>
|
|
||||||
<div class="notif-editor">
|
|
||||||
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="chat"></select></div>
|
|
||||||
<div class="hint notif-alert-description" data-notif-description="chat"></div>
|
|
||||||
<div class="notif-per-alert-row">
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" data-notif-alert>
|
|
||||||
<span class="slider"></span>
|
|
||||||
</label>
|
|
||||||
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
|
|
||||||
</div>
|
|
||||||
<div class="notif-chips" data-notif-chips="chat"></div>
|
|
||||||
<div class="notif-input-row">
|
|
||||||
<input type="text" class="notif-threshold-input" data-notif-input="chat" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
|
||||||
<button type="button" class="notif-add-btn" data-notif-add="chat">Add</button>
|
|
||||||
</div>
|
|
||||||
<div class="notif-presets" data-notif-presets="chat"></div>
|
|
||||||
</div>
|
|
||||||
<details class="notif-trigger">
|
|
||||||
<summary>Trigger conditions</summary>
|
|
||||||
<div class="field-grid">
|
|
||||||
<div class="field"><label>Chat Alert Message Count</label><input type="number" data-key="CHAT_ALERT_MESSAGE_COUNT"></div>
|
|
||||||
<div class="field"><label>Chat No-Response Hours</label><input type="number" data-key="CHAT_ALERT_HOURS_WITHOUT_RESPONSE"></div>
|
|
||||||
<div class="field"><label>Chat Alert Cooldown (min)</label><input type="number" data-key="CHAT_ALERT_COOLDOWN_MINUTES"></div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 10. Logging -->
|
|
||||||
<div class="section" id="s-logging">
|
|
||||||
<div class="section-header"><h2>Logging</h2><p>Log channel configuration (channels set in Channels section)</p><span class="chevron">▼</span></div>
|
|
||||||
<div class="section-body"><div class="field-grid">
|
|
||||||
<div class="field full-width"><p class="logging-hint">Log channels are configured in the <a href="/channels">Channels</a> section. This section shows which logs are active based on configured channels.</p></div>
|
|
||||||
</div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 11. Automation -->
|
|
||||||
<div class="section" id="s-automation">
|
|
||||||
<div class="section-header"><h2>Automation</h2><p>Polling intervals and timer durations</p><span class="chevron">▼</span></div>
|
|
||||||
<div class="section-body"><div class="field-grid">
|
|
||||||
<div class="field"><label>Gmail Poll Interval (sec)</label><select data-key="GMAIL_POLL_INTERVAL_SECONDS">
|
|
||||||
<option value="5">5s</option><option value="10">10s</option><option value="30">30s</option><option value="45">45s</option>
|
|
||||||
<option value="60">1m</option><option value="120">2m</option><option value="180">3m</option><option value="240">4m</option>
|
|
||||||
<option value="300">5m</option><option value="600">10m</option>
|
|
||||||
</select></div>
|
|
||||||
<div class="field"><label>Force-Close Timer (sec)</label><select data-key="FORCE_CLOSE_TIMER_SECONDS">
|
|
||||||
<option value="5">5s</option><option value="10">10s</option><option value="30">30s</option><option value="45">45s</option>
|
|
||||||
<option value="60">1m</option><option value="120">2m</option><option value="180">3m</option><option value="240">4m</option>
|
|
||||||
<option value="300">5m</option><option value="600">10m</option>
|
|
||||||
</select></div>
|
|
||||||
</div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 12. Appearance -->
|
|
||||||
<div class="section" id="s-appearance">
|
|
||||||
<div class="section-header"><h2>Appearance</h2><p>Embed colors, button labels, and emojis</p><span class="chevron">▼</span></div>
|
|
||||||
<div class="section-body"><div class="field-grid">
|
|
||||||
<div class="field"><label>Open Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_OPEN"><span>Open tickets</span></div></div>
|
|
||||||
<div class="field"><label>Closed Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_CLOSED"><span>Closed tickets</span></div></div>
|
|
||||||
<div class="field"><label>Claimed Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_CLAIMED"><span>Claimed tickets</span></div></div>
|
|
||||||
<div class="field"><label>Escalated Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_ESCALATED"><span>Escalated tickets</span></div></div>
|
|
||||||
<div class="field"><label>Info Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_INFO"><span>Info embeds</span></div></div>
|
|
||||||
<div class="field"><label>Close Button Label</label><input type="text" data-key="BUTTON_LABEL_CLOSE"></div>
|
|
||||||
<div class="field"><label>Claim Button Label</label><input type="text" data-key="BUTTON_LABEL_CLAIM"></div>
|
|
||||||
<div class="field"><label>Unclaim Button Label</label><input type="text" data-key="BUTTON_LABEL_UNCLAIM"></div>
|
|
||||||
<div class="field"><label>Close Emoji</label><input type="text" data-key="BUTTON_EMOJI_CLOSE"></div>
|
|
||||||
<div class="field"><label>Claim Emoji</label><input type="text" data-key="BUTTON_EMOJI_CLAIM"></div>
|
|
||||||
<div class="field"><label>Unclaim Emoji</label><input type="text" data-key="BUTTON_EMOJI_UNCLAIM"></div>
|
|
||||||
<div class="field"><label>High Priority Emoji</label><input type="text" data-key="PRIORITY_HIGH_EMOJI"></div>
|
|
||||||
<div class="field"><label>Medium Priority Emoji</label><input type="text" data-key="PRIORITY_MEDIUM_EMOJI"></div>
|
|
||||||
<div class="field"><label>Low Priority Emoji</label><input type="text" data-key="PRIORITY_LOW_EMOJI"></div>
|
|
||||||
<div class="field"><label>Claimer Emoji Fallback</label><input type="text" data-key="CLAIMER_EMOJI_FALLBACK"></div>
|
|
||||||
</div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 13. Staff -->
|
|
||||||
<div class="section" id="s-staff">
|
|
||||||
<div class="section-header"><h2>Staff</h2><p>Staff IDs, emojis, and admin settings</p><span class="chevron">▼</span></div>
|
|
||||||
<div class="section-body"><div class="field-grid">
|
|
||||||
<div class="field full-width"><label>Staff IDs (comma-separated)</label><input type="text" data-key="STAFF_IDS" data-smart="multi-member"></div>
|
|
||||||
<div class="field"><label>Admin ID</label><input type="text" data-key="ADMIN_ID" data-smart="member"></div>
|
|
||||||
<div class="field full-width"><label>Staff Emojis (userId:emoji, comma-separated)</label><input type="text" data-key="STAFF_EMOJIS"><div class="hint">Format: 123456:emoji,789012:emoji</div></div>
|
|
||||||
<div class="field full-width"><label>Additional Staff Roles (comma-separated)</label><input type="text" data-key="ADDITIONAL_STAFF_ROLES"><div class="hint">Role IDs with staff permissions</div></div>
|
|
||||||
<div class="field full-width"><label>Blacklisted Roles (comma-separated)</label><input type="text" data-key="BLACKLISTED_ROLES"><div class="hint">Role IDs that cannot open tickets</div></div>
|
|
||||||
<div class="field full-width"><label>Unclaimed Reminder Thresholds (hours, comma-separated)</label><input type="text" data-key="UNCLAIMED_REMINDER_THRESHOLDS"><div class="hint">e.g. 1,2,4</div></div>
|
|
||||||
</div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 14. Advanced -->
|
|
||||||
<div class="section" id="s-advanced">
|
|
||||||
<div class="section-header"><h2>Advanced</h2><p>Ports, URLs, game list, branding</p><span class="chevron">▼</span></div>
|
|
||||||
<div class="section-body"><div class="field-grid">
|
|
||||||
<div class="field"><label>Bot Port</label><input type="number" data-key="DISCORD_ONLY_PORT"></div>
|
|
||||||
<div class="field"><label>Healthcheck Host</label><input type="text" data-key="HEALTHCHECK_HOST" placeholder="leave empty for all interfaces"></div>
|
|
||||||
<div class="field"><label>Settings Port</label><input type="number" data-key="SETTINGS_PORT"></div>
|
|
||||||
<div class="field"><label>Settings Domain</label><input type="text" data-key="SETTINGS_DOMAIN"></div>
|
|
||||||
<div class="field"><label>Internal API Port</label><input type="number" data-key="INTERNAL_API_PORT"></div>
|
|
||||||
<div class="field"><label>Support Name</label><input type="text" data-key="SUPPORT_NAME"></div>
|
|
||||||
<div class="field"><label>Logo URL</label><input type="text" data-key="LOGO_URL"></div>
|
|
||||||
<div class="field full-width"><label>Game List (comma-separated)</label><textarea data-key="GAME_LIST" rows="3"></textarea></div>
|
|
||||||
<div class="field full-width"><label>Email Signature (HTML, use \n for breaks)</label><textarea data-key="EMAIL_SIGNATURE" rows="3"></textarea></div>
|
|
||||||
<div class="field full-width"><label>Close Subject Prefix</label><input type="text" data-key="TICKET_CLOSE_SUBJECT_PREFIX"></div>
|
|
||||||
<div class="field full-width"><label>Close Message (email body)</label><textarea data-key="TICKET_CLOSE_MESSAGE" rows="2"></textarea></div>
|
|
||||||
<div class="field full-width"><label>Discord Close Message</label><textarea data-key="DISCORD_CLOSE_MESSAGE" rows="2"></textarea></div>
|
|
||||||
<div class="field full-width"><label>Transcript Message</label><textarea data-key="DISCORD_TRANSCRIPT_MESSAGE" rows="2"></textarea><div class="hint">Variables: {channel_name}, {email}, {date_opened}, {date_closed}</div></div>
|
|
||||||
<div class="field full-width"><label>Auto-Close Message</label><textarea data-key="DISCORD_AUTO_CLOSE_MESSAGE" rows="2"></textarea></div>
|
|
||||||
</div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Save bar -->
|
|
||||||
<div id="save-bar" class="save-bar">
|
|
||||||
<span id="change-count">0 unsaved changes</span>
|
|
||||||
<div class="save-actions">
|
|
||||||
<button type="button" id="save-btn">Save</button>
|
|
||||||
<button type="button" id="save-restart-btn" class="danger">Save & Restart Now</button>
|
|
||||||
<button type="button" id="schedule-restart-btn" class="secondary">Schedule restart...</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Schedule modal -->
|
|
||||||
<div id="schedule-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="schedule-modal-title">
|
|
||||||
<div class="modal-card">
|
|
||||||
<h3 id="schedule-modal-title">Schedule restart</h3>
|
|
||||||
<input type="datetime-local" id="schedule-datetime" aria-label="Restart date and time">
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button type="button" id="schedule-confirm-btn">Schedule</button>
|
|
||||||
<button type="button" id="schedule-cancel-btn" class="secondary">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script defer src="/js/util.js"></script>
|
|
||||||
<script defer src="/js/router.js"></script>
|
|
||||||
<script defer src="/js/fields.js"></script>
|
|
||||||
<script defer src="/js/notifications.js"></script>
|
|
||||||
<script defer src="/js/discord.js"></script>
|
|
||||||
<script defer src="/js/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
document.getElementById('loading').classList.remove('hidden');
|
|
||||||
try {
|
|
||||||
await Util.fetchCsrfToken();
|
|
||||||
const [config] = await Promise.all([
|
|
||||||
fetch('/api/config', { credentials: 'same-origin' }).then(r => r.json()),
|
|
||||||
DiscordFields.fetchGuildData()
|
|
||||||
]);
|
|
||||||
Fields.setSavedConfig(config);
|
|
||||||
document.getElementById('bot-status-dot').className = 'dot online';
|
|
||||||
document.getElementById('bot-status-text').textContent = 'Connected';
|
|
||||||
Fields.populateFields(config);
|
|
||||||
Notifications.initNotificationsEditor(config);
|
|
||||||
Fields.initSmartSelects(config);
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('bot-status-dot').className = 'dot offline';
|
|
||||||
document.getElementById('bot-status-text').textContent = 'Unreachable';
|
|
||||||
}
|
|
||||||
document.getElementById('loading').classList.add('hidden');
|
|
||||||
setupSectionToggles();
|
|
||||||
Fields.setupSaveBar();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupSectionToggles() {
|
|
||||||
document.querySelectorAll('.section-header').forEach(header => {
|
|
||||||
header.addEventListener('click', () => {
|
|
||||||
header.closest('.section').classList.toggle('collapsed');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function openScheduleModal() {
|
|
||||||
const modal = document.getElementById('schedule-modal');
|
|
||||||
const dt = document.getElementById('schedule-datetime');
|
|
||||||
const min = Util.formatLocalDateTime(new Date(Date.now() + 60000));
|
|
||||||
dt.min = min;
|
|
||||||
dt.value = min;
|
|
||||||
Util.openModal(modal, { initialFocus: '#schedule-datetime' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmScheduledRestart() {
|
|
||||||
const dt = document.getElementById('schedule-datetime').value;
|
|
||||||
if (!dt) return;
|
|
||||||
await fetch('/api/restart', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: Util.csrfHeaders({ 'Content-Type': 'application/json' }),
|
|
||||||
body: JSON.stringify({ mode: 'scheduled', scheduledFor: new Date(dt).toISOString() })
|
|
||||||
});
|
|
||||||
Util.closeModal(document.getElementById('schedule-modal'));
|
|
||||||
Util.showToast(`Restart scheduled for ${new Date(dt).toLocaleString()}`, 'warning');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doLogout() {
|
|
||||||
try {
|
|
||||||
await fetch('/logout', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: Util.csrfHeaders()
|
|
||||||
});
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
window.location.href = '/login';
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupActionButtons() {
|
|
||||||
document.getElementById('save-btn')?.addEventListener('click', () => Fields.saveConfig('save'));
|
|
||||||
document.getElementById('save-restart-btn')?.addEventListener('click', () => Fields.saveConfig('restart'));
|
|
||||||
document.getElementById('schedule-restart-btn')?.addEventListener('click', openScheduleModal);
|
|
||||||
document.getElementById('schedule-confirm-btn')?.addEventListener('click', confirmScheduledRestart);
|
|
||||||
document.getElementById('schedule-cancel-btn')?.addEventListener('click', () => {
|
|
||||||
Util.closeModal(document.getElementById('schedule-modal'));
|
|
||||||
});
|
|
||||||
document.getElementById('logout-btn')?.addEventListener('click', doLogout);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupMobileNav() {
|
|
||||||
const toggle = document.getElementById('menu-toggle');
|
|
||||||
const backdrop = document.getElementById('sidebar-backdrop');
|
|
||||||
|
|
||||||
toggle?.addEventListener('click', () => {
|
|
||||||
Util.setSidebarOpen(!document.body.classList.contains('sidebar-open'));
|
|
||||||
});
|
|
||||||
backdrop?.addEventListener('click', () => Util.setSidebarOpen(false));
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape' && document.body.classList.contains('sidebar-open')) {
|
|
||||||
Util.setSidebarOpen(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
if (!Util.isMobileViewport() && document.body.classList.contains('sidebar-open')) {
|
|
||||||
Util.setSidebarOpen(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let healthPollHandle = null;
|
|
||||||
|
|
||||||
function setBotStatus(online) {
|
|
||||||
const dot = document.getElementById('bot-status-dot');
|
|
||||||
const text = document.getElementById('bot-status-text');
|
|
||||||
if (!dot || !text) return;
|
|
||||||
dot.className = online ? 'dot online' : 'dot offline';
|
|
||||||
text.textContent = online ? 'Connected' : 'Unreachable';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollHealth() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/healthz', { credentials: 'same-origin' });
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setBotStatus(Boolean(data.bot));
|
|
||||||
} else {
|
|
||||||
setBotStatus(false);
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
setBotStatus(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleNextHealthPoll() {
|
|
||||||
if (document.hidden) return;
|
|
||||||
healthPollHandle = setTimeout(async () => {
|
|
||||||
await pollHealth();
|
|
||||||
scheduleNextHealthPoll();
|
|
||||||
}, 20000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function startHealthPolling() {
|
|
||||||
if (healthPollHandle) clearTimeout(healthPollHandle);
|
|
||||||
scheduleNextHealthPoll();
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopHealthPolling() {
|
|
||||||
if (healthPollHandle) {
|
|
||||||
clearTimeout(healthPollHandle);
|
|
||||||
healthPollHandle = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupHealthPolling() {
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
|
||||||
if (document.hidden) stopHealthPolling();
|
|
||||||
else startHealthPolling();
|
|
||||||
});
|
|
||||||
window.addEventListener('pagehide', stopHealthPolling);
|
|
||||||
startHealthPolling();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
|
||||||
Router.setupSidebarRouting();
|
|
||||||
setupActionButtons();
|
|
||||||
setupMobileNav();
|
|
||||||
await init();
|
|
||||||
Router.navigate(location.pathname, false);
|
|
||||||
setupHealthPolling();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.App = { init };
|
|
||||||
})();
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const ROUTES = {
|
|
||||||
'/': 's-core',
|
|
||||||
'/channels': 's-channels',
|
|
||||||
'/categories': 's-categories',
|
|
||||||
'/gmail': 's-gmail',
|
|
||||||
'/behavior': 's-behavior',
|
|
||||||
'/threads': 's-threads',
|
|
||||||
'/pins': 's-pins',
|
|
||||||
'/notifications': 's-notifications',
|
|
||||||
'/logging': 's-logging',
|
|
||||||
'/automation': 's-automation',
|
|
||||||
'/appearance': 's-appearance',
|
|
||||||
'/staff': 's-staff',
|
|
||||||
'/advanced': 's-advanced'
|
|
||||||
};
|
|
||||||
|
|
||||||
function navigate(path, updateHistory = true) {
|
|
||||||
const sectionId = ROUTES[path] || ROUTES['/'];
|
|
||||||
const normalizedPath = ROUTES[path] ? path : '/';
|
|
||||||
if (updateHistory) history.pushState({}, '', normalizedPath);
|
|
||||||
|
|
||||||
document.querySelectorAll('.section').forEach(section => {
|
|
||||||
section.classList.toggle('hidden', section.id !== sectionId);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('.sidebar a').forEach(link => {
|
|
||||||
link.classList.toggle('active', link.getAttribute('href') === normalizedPath);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupSidebarRouting() {
|
|
||||||
const sidebar = document.querySelector('.sidebar');
|
|
||||||
if (!sidebar) return;
|
|
||||||
|
|
||||||
sidebar.addEventListener('click', e => {
|
|
||||||
const a = e.target.closest('a');
|
|
||||||
if (!a) return;
|
|
||||||
e.preventDefault();
|
|
||||||
navigate(a.getAttribute('href'));
|
|
||||||
if (Util.isMobileViewport()) Util.setSidebarOpen(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('popstate', () => {
|
|
||||||
navigate(location.pathname, false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.Router = { ROUTES, navigate, setupSidebarRouting };
|
|
||||||
})();
|
|
||||||
Reference in New Issue
Block a user