Merge: dead-code removal + close/escalation dedup

This commit is contained in:
2026-06-02 19:59:20 +00:00
19 changed files with 141 additions and 217 deletions

View File

@@ -79,8 +79,7 @@ const client = new Client({
GatewayIntentBits.Guilds, GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent, GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMembers
GatewayIntentBits.GuildPresences // Required for staff presence detection; enable in Discord Developer Portal
], ],
partials: [Partials.Channel] partials: [Partials.Channel]
}); });
@@ -238,15 +237,6 @@ client.once('ready', async () => {
client.login(CONFIG.DISCORD_TOKEN); client.login(CONFIG.DISCORD_TOKEN);
const app = express(); const app = express();
app.use(express.json());
// Reject API traffic with 503 until ready event has fired and routes are mounted.
// (appReady is declared at module top so the ready callback can flip it.)
app.use((req, res, next) => {
if (!appReady && req.path.startsWith('/api')) {
return res.status(503).json({ error: 'Bot is starting; API not ready yet.' });
}
next();
});
app.get('/', (req, res) => res.send(appReady ? 'Active' : 'Starting')); app.get('/', (req, res) => res.send(appReady ? 'Active' : 'Starting'));
// app.listen is called inside client.once('ready') after MongoDB connects and routes mount. // app.listen is called inside client.once('ready') after MongoDB connects and routes mount.

View File

@@ -17,7 +17,6 @@ const CONFIG = {
TICKET_CATEGORY_NAME: process.env.TICKET_CATEGORY_NAME || 'Open Tickets', TICKET_CATEGORY_NAME: process.env.TICKET_CATEGORY_NAME || 'Open Tickets',
DISCORD_TICKET_CATEGORY_ID: process.env.DISCORD_TICKET_CATEGORY_ID || process.env.TICKET_CATEGORY_ID, DISCORD_TICKET_CATEGORY_ID: process.env.DISCORD_TICKET_CATEGORY_ID || process.env.TICKET_CATEGORY_ID,
ROLE_ID_TO_PING: process.env.ROLE_ID_TO_PING, ROLE_ID_TO_PING: process.env.ROLE_ID_TO_PING,
ROLE_TO_PING_ID: process.env.ROLE_ID_TO_PING || process.env.ROLE_TO_PING_ID,
TRANSCRIPT_CHANNEL_ID: process.env.TRANSCRIPT_CHANNEL_ID, TRANSCRIPT_CHANNEL_ID: process.env.TRANSCRIPT_CHANNEL_ID,
LOGGING_CHANNEL_ID: process.env.LOGGING_CHANNEL_ID, LOGGING_CHANNEL_ID: process.env.LOGGING_CHANNEL_ID,
DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null, DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null,
@@ -30,7 +29,6 @@ const CONFIG = {
CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫', CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫',
PORT: toInt(process.env.DISCORD_ONLY_PORT, 5000), PORT: toInt(process.env.DISCORD_ONLY_PORT, 5000),
HEALTHCHECK_HOST: process.env.HEALTHCHECK_HOST || null, // null = listen on all interfaces; set to 127.0.0.1 for local-only HEALTHCHECK_HOST: process.env.HEALTHCHECK_HOST || null, // null = listen on all interfaces; set to 127.0.0.1 for local-only
SIGNATURE: (process.env.EMAIL_SIGNATURE || '').trim().replace(/\\n/g, '\n'),
GAME_LIST: process.env.GAME_LIST || '', GAME_LIST: process.env.GAME_LIST || '',
// Tier 2/3 escalation: category IDs where ticket channels are placed (env uses *_CHANNEL_* for legacy naming). // Tier 2/3 escalation: category IDs where ticket channels are placed (env uses *_CHANNEL_* for legacy naming).
EMAIL_ESCALATED2_CHANNEL_ID: process.env.EMAIL_ESCALATED2_CHANNEL_ID || null, EMAIL_ESCALATED2_CHANNEL_ID: process.env.EMAIL_ESCALATED2_CHANNEL_ID || null,
@@ -56,9 +54,6 @@ const CONFIG = {
TICKET_WELCOME_MESSAGE: process.env.TICKET_WELCOME_MESSAGE || "We got your ticket. We'll be with you as soon as possible. Feel free to add any additional information to your ticket.", TICKET_WELCOME_MESSAGE: process.env.TICKET_WELCOME_MESSAGE || "We got your ticket. We'll be with you as soon as possible. Feel free to add any additional information to your ticket.",
TICKET_CLAIMED_MESSAGE: process.env.TICKET_CLAIMED_MESSAGE || 'Ticket claimed by {staff_mention} 🚀', TICKET_CLAIMED_MESSAGE: process.env.TICKET_CLAIMED_MESSAGE || 'Ticket claimed by {staff_mention} 🚀',
TICKET_UNCLAIMED_MESSAGE: process.env.TICKET_UNCLAIMED_MESSAGE || 'Ticket unclaimed by {staff_mention} ☀️', TICKET_UNCLAIMED_MESSAGE: process.env.TICKET_UNCLAIMED_MESSAGE || 'Ticket unclaimed by {staff_mention} ☀️',
REMINDER_ENABLED: process.env.REMINDER_ENABLED === 'true',
REMINDER_AFTER_HOURS: toInt(process.env.REMINDER_AFTER_HOURS, 24),
REMINDER_MESSAGE: process.env.REMINDER_MESSAGE || 'Hey {ping}! This ticket has been inactive for {hours} hours. Please provide an update or close the ticket.',
PRIORITY_ENABLED: process.env.PRIORITY_ENABLED === 'true', PRIORITY_ENABLED: process.env.PRIORITY_ENABLED === 'true',
DEFAULT_PRIORITY: process.env.DEFAULT_PRIORITY || 'normal', DEFAULT_PRIORITY: process.env.DEFAULT_PRIORITY || 'normal',
PRIORITY_HIGH_EMOJI: process.env.PRIORITY_HIGH_EMOJI || '🔴', PRIORITY_HIGH_EMOJI: process.env.PRIORITY_HIGH_EMOJI || '🔴',
@@ -80,7 +75,6 @@ const CONFIG = {
ADMIN_ID: process.env.ADMIN_ID || null, ADMIN_ID: process.env.ADMIN_ID || null,
FORCE_CLOSE_TIMER: toInt(process.env.FORCE_CLOSE_TIMER_SECONDS, 60), FORCE_CLOSE_TIMER: toInt(process.env.FORCE_CLOSE_TIMER_SECONDS, 60),
GMAIL_POLL_INTERVAL_MS: toInt(process.env.GMAIL_POLL_INTERVAL_SECONDS, 30) * 1000, GMAIL_POLL_INTERVAL_MS: toInt(process.env.GMAIL_POLL_INTERVAL_SECONDS, 30) * 1000,
RENAME_LOG_CHANNEL_ID: process.env.RENAME_LOG_CHANNEL_ID || null,
STAFF_THREAD_ENABLED: process.env.STAFF_THREAD_ENABLED === 'true', STAFF_THREAD_ENABLED: process.env.STAFF_THREAD_ENABLED === 'true',
STAFF_THREAD_NAME: process.env.STAFF_THREAD_NAME || 'Staff Discussion', STAFF_THREAD_NAME: process.env.STAFF_THREAD_NAME || 'Staff Discussion',
STAFF_THREAD_AUTO_ADD_ROLE: process.env.STAFF_THREAD_AUTO_ADD_ROLE === 'true', STAFF_THREAD_AUTO_ADD_ROLE: process.env.STAFF_THREAD_AUTO_ADD_ROLE === 'true',
@@ -89,9 +83,6 @@ const CONFIG = {
PIN_ESCALATION_MESSAGE_ENABLED: process.env.PIN_ESCALATION_MESSAGE_ENABLED === 'true', PIN_ESCALATION_MESSAGE_ENABLED: process.env.PIN_ESCALATION_MESSAGE_ENABLED === 'true',
TRANSCRIPT_DM_TO_CREATOR: process.env.TRANSCRIPT_DM_TO_CREATOR === 'true', TRANSCRIPT_DM_TO_CREATOR: process.env.TRANSCRIPT_DM_TO_CREATOR === 'true',
PIN_SUPPRESS_SYSTEM_MESSAGE: process.env.PIN_SUPPRESS_SYSTEM_MESSAGE === 'true', PIN_SUPPRESS_SYSTEM_MESSAGE: process.env.PIN_SUPPRESS_SYSTEM_MESSAGE === 'true',
SETTINGS_PORT: toInt(process.env.SETTINGS_PORT, 12752),
SETTINGS_ADMIN_PASSWORD: process.env.SETTINGS_ADMIN_PASSWORD || null,
SETTINGS_DOMAIN: process.env.SETTINGS_DOMAIN || 'tickets.indifferentketchup.com',
INTERNAL_API_PORT: toInt(process.env.INTERNAL_API_PORT, 12753), INTERNAL_API_PORT: toInt(process.env.INTERNAL_API_PORT, 12753),
INTERNAL_API_SECRET: process.env.INTERNAL_API_SECRET || null INTERNAL_API_SECRET: process.env.INTERNAL_API_SECRET || null
}; };

View File

@@ -37,7 +37,6 @@ function setPollSuspended(val) {
pollSuspended = !!val; pollSuspended = !!val;
if (!pollSuspended) authErrorNotified = false; if (!pollSuspended) authErrorNotified = false;
} }
function isPollSuspended() { return pollSuspended; }
// ============================================================ // ============================================================
// Helpers (extracted from the original 309-line poll()). // Helpers (extracted from the original 309-line poll()).
@@ -73,6 +72,16 @@ function locateGuild(client) {
* - followupBody: defensive — strip quotes but fall back to raw text if * - followupBody: defensive — strip quotes but fall back to raw text if
* stripping leaves nothing. Used for follow-up posts on an existing thread. * stripping leaves nothing. Used for follow-up posts on an existing thread.
*/ */
// Shared final cleanup for both the first-message and follow-up body paths:
// drop the "Get Outlook for ..." mobile-signature line, strip a dangling
// trailing "<" left by truncated HTML, and trim.
function finalizeBody(text) {
return text
.replace(/Get Outlook for [^\n]+/gi, '')
.replace(/<\s*$/gm, '')
.trim();
}
function parseGmailMessage(email) { function parseGmailMessage(email) {
const headers = email.data.payload.headers; const headers = email.data.payload.headers;
const from = headers.find(h => h.name === 'From')?.value || ''; const from = headers.find(h => h.name === 'From')?.value || '';
@@ -92,10 +101,7 @@ function parseGmailMessage(email) {
firstBody = stripMobileFooter(firstBody); firstBody = stripMobileFooter(firstBody);
firstBody = firstBody.replace(/^\s*\n+/g, ''); firstBody = firstBody.replace(/^\s*\n+/g, '');
firstBody = firstBody.replace(/\n{3,}/g, '\n\n'); firstBody = firstBody.replace(/\n{3,}/g, '\n\n');
firstBody = firstBody firstBody = finalizeBody(firstBody);
.replace(/Get Outlook for [^\n]+/gi, '')
.replace(/<\s*$/gm, '')
.trim();
const rawText = rawBody.replace(/\r\n/g, '\n'); const rawText = rawBody.replace(/\r\n/g, '\n');
let followupBody = stripEmailQuotes(rawText); let followupBody = stripEmailQuotes(rawText);
@@ -103,10 +109,7 @@ function parseGmailMessage(email) {
followupBody = followupBody.replace(/^\s*\n*/, '\n'); followupBody = followupBody.replace(/^\s*\n*/, '\n');
followupBody = followupBody.replace(/\n{3,}/g, '\n\n'); followupBody = followupBody.replace(/\n{3,}/g, '\n\n');
followupBody = stripMobileFooter(followupBody); followupBody = stripMobileFooter(followupBody);
followupBody = followupBody followupBody = finalizeBody(followupBody);
.replace(/Get Outlook for [^\n]+/gi, '')
.replace(/<\s*$/gm, '')
.trim();
return { return {
isSelf, isSelf,
@@ -405,4 +408,4 @@ async function poll(client) {
} }
} }
module.exports = { poll, setPollSuspended, isPollSuspended }; module.exports = { poll, setPollSuspended };

View File

@@ -16,7 +16,6 @@ const {
AttachmentBuilder, AttachmentBuilder,
EmbedBuilder, EmbedBuilder,
MessageFlags, MessageFlags,
PermissionFlagsBits,
ModalBuilder, ModalBuilder,
TextInputBuilder, TextInputBuilder,
TextInputStyle TextInputStyle
@@ -25,10 +24,11 @@ const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName } = require('../services/tickets'); const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName } = require('../services/tickets');
const { sendTicketClosedEmail } = require('../services/gmail'); const { sendTicketClosedEmail } = require('../services/gmail');
const { getTicketActionRow } = require('../utils/ticketComponents'); const { getTicketActionRow, ticketChannelOverwrites } = require('../utils/ticketComponents');
const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../services/transcript');
const { sanitizeEmbedText, truncateEmbedDescription } = require('../utils'); const { sanitizeEmbedText, truncateEmbedDescription } = require('../utils');
const { enqueueRename, enqueueSend } = require('../services/channelQueue'); const { enqueueRename, enqueueSend } = require('../services/channelQueue');
const { runEscalation, runDeescalation } = require('./commands'); const { runEscalation, runDeescalation, resolveEscalationCategoryId } = require('./commands');
const { pendingCloses } = require('./pendingCloses'); const { pendingCloses } = require('./pendingCloses');
const { addMemberToStaffThread, createStaffThread } = require('../services/staffThread'); const { addMemberToStaffThread, createStaffThread } = require('../services/staffThread');
const { pinMessage } = require('../services/pinMessage'); const { pinMessage } = require('../services/pinMessage');
@@ -309,7 +309,7 @@ async function handleConfirmCloseRequest(interaction, ticket) {
await runFinalClose(interaction, freshTicket, effectiveSendEmail); await runFinalClose(interaction, freshTicket, effectiveSendEmail);
}, timerSeconds * 1000)); }, timerSeconds * 1000));
pendingCloses.set(channelId, { timeout: timerId, userId: interaction.user.id, username: userTag, sendEmail }); pendingCloses.set(channelId, { timeout: timerId, username: userTag, sendEmail });
} }
async function handleCancelCloseRequest(interaction) { async function handleCancelCloseRequest(interaction) {
@@ -358,10 +358,7 @@ async function handleEscalateButton(interaction, ticket) {
return interaction.reply({ content: `This ticket is already at tier ${tier + 1}.`, flags: MessageFlags.Ephemeral }); return interaction.reply({ content: `This ticket is already at tier ${tier + 1}.`, flags: MessageFlags.Ephemeral });
} }
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); const categoryId = resolveEscalationCategoryId(ticket, tier);
const categoryId = tier === 1
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
if (!categoryId && !interaction.channel.isThread()) { if (!categoryId && !interaction.channel.isThread()) {
return interaction.reply({ return interaction.reply({
@@ -474,33 +471,6 @@ async function runFinalClose(interaction, ticket, sendEmail = true) {
} }
} }
/** Render the last 100 messages of a channel as a plaintext transcript. */
async function buildTranscriptText(channel, ticket) {
const messages = await channel.messages.fetch({ limit: 100 });
return `TRANSCRIPT: ${ticket.subject}\nUser: ${ticket.senderEmail}\n---\n` +
messages
.reverse()
.map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`)
.join('\n');
}
function formatDateForTranscript(d) {
return new Date(d).toLocaleString('en-US', {
month: '2-digit', day: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: true, timeZoneName: 'short'
});
}
function renderTranscriptHeader(channelName, senderEmail, openedStr, closedStr) {
return CONFIG.DISCORD_TRANSCRIPT_MESSAGE
.replace(/\{channel_name\}/g, channelName)
.replace(/\{email\}/g, senderEmail || '')
.replace(/\{date_opened\}/g, openedStr)
.replace(/\{date_closed\}/g, closedStr)
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
}
async function dmTranscriptToCreator(client, ticket, channelName, transcriptText, openedStr, closedStr) { async function dmTranscriptToCreator(client, ticket, channelName, transcriptText, openedStr, closedStr) {
// Prefer ticket.creatorId (stored on creation). Fall back to legacy parsing for // Prefer ticket.creatorId (stored on creation). Fall back to legacy parsing for
// pre-creatorId modal tickets only — split-pop returns the wrong value for // pre-creatorId modal tickets only — split-pop returns the wrong value for
@@ -601,17 +571,7 @@ async function handleTicketModal(interaction) {
name: unclaimedName, name: unclaimedName,
type: ChannelType.GuildText, type: ChannelType.GuildText,
parent: parentCategoryIdForTicket, parent: parentCategoryIdForTicket,
permissionOverwrites: [ permissionOverwrites: ticketChannelOverwrites(guild, interaction.user.id)
{ 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) { } catch (err) {
console.error('guild.channels.create (ticket modal):', err); console.error('guild.channels.create (ticket modal):', err);

View File

@@ -14,6 +14,7 @@ const { enqueueSend } = require('../../services/channelQueue');
const { logTicketEvent } = require('../../services/debugLog'); const { logTicketEvent } = require('../../services/debugLog');
const { pendingCloses } = require('../pendingCloses'); const { pendingCloses } = require('../pendingCloses');
const { findTicketForChannel } = require('../sharedHelpers'); const { findTicketForChannel } = require('../sharedHelpers');
const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../../services/transcript');
const Ticket = mongoose.model('Ticket'); const Ticket = mongoose.model('Ticket');
@@ -56,7 +57,7 @@ async function handleForceClose(interaction) {
const channelRef = interaction.channel; const channelRef = interaction.channel;
const clientRef = interaction.client; const clientRef = interaction.client;
const timerId = setTimeout(() => finalizeForceClose(channelRef, clientRef), timerSeconds * 1000); const timerId = setTimeout(() => finalizeForceClose(channelRef, clientRef), timerSeconds * 1000);
pendingCloses.set(channelRef.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag }); pendingCloses.set(channelRef.id, { timeout: timerId, username: interaction.user.tag });
} }
/** Performs the actual force-close work after the countdown elapses. */ /** Performs the actual force-close work after the countdown elapses. */
@@ -92,14 +93,7 @@ async function finalizeForceClose(channelRef, clientRef) {
async function postTranscript(channelRef, clientRef, freshTicket) { async function postTranscript(channelRef, clientRef, freshTicket) {
await enqueueSend(channelRef, CONFIG.DISCORD_CLOSE_MESSAGE); await enqueueSend(channelRef, CONFIG.DISCORD_CLOSE_MESSAGE);
const messages = await channelRef.messages.fetch({ limit: 100 }); const log = await buildTranscriptText(channelRef, freshTicket);
const log =
`TRANSCRIPT: ${freshTicket.subject}\nUser: ${freshTicket.senderEmail}\n---\n` +
messages
.reverse()
.map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`)
.join('\n');
const file = new AttachmentBuilder(Buffer.from(log), { const file = new AttachmentBuilder(Buffer.from(log), {
name: `transcript-${channelRef.name}.txt` name: `transcript-${channelRef.name}.txt`
}); });
@@ -109,19 +103,9 @@ async function postTranscript(channelRef, clientRef, freshTicket) {
.catch(() => null); .catch(() => null);
if (!transcriptChan) return; if (!transcriptChan) return;
const fmt = (d) => new Date(d).toLocaleString('en-US', { const openedStr = formatDateForTranscript(freshTicket.createdAt);
month: '2-digit', day: '2-digit', year: 'numeric', const closedStr = formatDateForTranscript(new Date());
hour: '2-digit', minute: '2-digit', second: '2-digit', const transcriptContent = renderTranscriptHeader(channelRef.name, freshTicket.senderEmail, openedStr, closedStr);
hour12: true, timeZoneName: 'short'
});
const openedStr = fmt(freshTicket.createdAt);
const closedStr = fmt(new Date());
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
.replace(/\{channel_name\}/g, channelRef.name)
.replace(/\{email\}/g, freshTicket.senderEmail || '')
.replace(/\{date_opened\}/g, openedStr)
.replace(/\{date_closed\}/g, closedStr)
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
await enqueueSend(transcriptChan, { content: transcriptContent, files: [file] }); await enqueueSend(transcriptChan, { content: transcriptContent, files: [file] });
} }

View File

@@ -6,14 +6,13 @@
const { const {
ChannelType, ChannelType,
EmbedBuilder, EmbedBuilder,
MessageFlags, MessageFlags
PermissionFlagsBits
} = require('discord.js'); } = require('discord.js');
const { mongoose } = require('../../db-connection'); const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config'); const { CONFIG } = require('../../config');
const { getPriorityEmoji } = require('../../utils'); const { getPriorityEmoji } = require('../../utils');
const { checkTicketCreationRateLimit, getOrCreateTicketCategory } = require('../../services/tickets'); const { checkTicketCreationRateLimit, getOrCreateTicketCategory, makeTicketName } = require('../../services/tickets');
const { getTicketActionRow } = require('../../utils/ticketComponents'); const { getTicketActionRow, ticketChannelOverwrites } = require('../../utils/ticketComponents');
const { enqueueSend } = require('../../services/channelQueue'); const { enqueueSend } = require('../../services/channelQueue');
const { logError } = require('../../services/debugLog'); const { logError } = require('../../services/debugLog');
@@ -36,6 +35,8 @@ async function handleCreateTicketFromMessage(interaction) {
const guild = interaction.guild; const guild = interaction.guild;
const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean(); const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean();
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1; const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
const creatorNickname = message.member?.displayName || message.author.username;
const unclaimedName = makeTicketName('unclaimed', { ticketNumber }, creatorNickname);
let parentCategoryIdForTicket; let parentCategoryIdForTicket;
try { try {
@@ -52,20 +53,10 @@ async function handleCreateTicketFromMessage(interaction) {
let channel; let channel;
try { try {
channel = await guild.channels.create({ channel = await guild.channels.create({
name: `ticket-${ticketNumber}`, name: unclaimedName,
type: ChannelType.GuildText, type: ChannelType.GuildText,
parent: parentCategoryIdForTicket, parent: parentCategoryIdForTicket,
permissionOverwrites: [ permissionOverwrites: ticketChannelOverwrites(guild, message.author.id)
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
{
id: message.author.id,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
},
{
id: CONFIG.ROLE_ID_TO_PING,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
}
]
}); });
} catch (err) { } catch (err) {
console.error('guild.channels.create (context menu ticket):', err); console.error('guild.channels.create (context menu ticket):', err);

View File

@@ -19,15 +19,26 @@ const { fetchLoggingChannel } = require('./helpers');
const Ticket = mongoose.model('Ticket'); const Ticket = mongoose.model('Ticket');
/**
* Resolve the destination category for an escalation target tier
* (nextTier 1 = tier 2, 2 = tier 3), picking the Discord vs email category set
* by ticket origin. Returns null/undefined when the relevant category is unset.
*/
function resolveEscalationCategoryId(ticket, nextTier) {
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
if (nextTier === 1) {
return isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID;
}
return isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID;
}
/** /**
* Run escalation to a target tier (1 = tier 2, 2 = tier 3). Caller must * Run escalation to a target tier (1 = tier 2, 2 = tier 3). Caller must
* validate ticket and currentTier < nextTier, and have already deferred. * validate ticket and currentTier < nextTier, and have already deferred.
*/ */
async function runEscalation(interaction, ticket, nextTier) { async function runEscalation(interaction, ticket, nextTier) {
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
const categoryId = nextTier === 1 const categoryId = resolveEscalationCategoryId(ticket, nextTier);
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
// Clear claim on escalation // Clear claim on escalation
await Ticket.updateOne( await Ticket.updateOne(
@@ -88,7 +99,7 @@ async function runEscalation(interaction, ticket, nextTier) {
const escalatorName = interaction.member?.displayName || interaction.user.username; const escalatorName = interaction.member?.displayName || interaction.user.username;
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3'; const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
const emailBody = `${escalatorName} escalated this ticket to ${tierLabel}.`; const emailBody = `${escalatorName} escalated this ticket to ${tierLabel}.`;
await sendTicketNotificationEmail(ticket, null, emailBody, interaction.user.id); await sendTicketNotificationEmail(ticket, emailBody, interaction.user.id);
} catch (emailErr) { } catch (emailErr) {
console.error('Escalation email failed (non-fatal):', emailErr.message); console.error('Escalation email failed (non-fatal):', emailErr.message);
} }
@@ -179,9 +190,7 @@ async function handleEscalate(interaction) {
} }
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
const categoryId = nextTier === 1 const categoryId = resolveEscalationCategoryId(ticket, nextTier);
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
const configKey = nextTier === 1 ? 'ESCALATED2' : 'ESCALATED3'; const configKey = nextTier === 1 ? 'ESCALATED2' : 'ESCALATED3';
if (!categoryId && !interaction.channel.isThread()) { if (!categoryId && !interaction.channel.isThread()) {
return interaction.reply({ return interaction.reply({
@@ -210,4 +219,4 @@ async function handleDeescalate(interaction) {
); );
} }
module.exports = { runEscalation, runDeescalation, handleEscalate, handleDeescalate }; module.exports = { runEscalation, runDeescalation, handleEscalate, handleDeescalate, resolveEscalationCategoryId };

View File

@@ -25,7 +25,7 @@ const { logError, logTicketEvent } = require('../../services/debugLog');
const { findTicketForChannel } = require('../sharedHelpers'); const { findTicketForChannel } = require('../sharedHelpers');
const { requireStaffRole, fetchLoggingChannel } = require('./helpers'); const { requireStaffRole, fetchLoggingChannel } = require('./helpers');
const { runEscalation, runDeescalation, handleEscalate, handleDeescalate } = require('./escalation'); const { runEscalation, runDeescalation, handleEscalate, handleDeescalate, resolveEscalationCategoryId } = require('./escalation');
const { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close'); const { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close');
const { handleResponse, handleAutocomplete } = require('./response'); const { handleResponse, handleAutocomplete } = require('./response');
const { handlePanel, handleSignature } = require('./panel'); const { handlePanel, handleSignature } = require('./panel');
@@ -266,7 +266,7 @@ async function handleHelp(interaction) {
}, },
{ {
name: 'Ticket Management', name: 'Ticket Management',
value: '`/transfer @staff` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/topic <text>` - Set ticket topic/description' value: '`/transfer @staff [reason]` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/cancel-close` - Abort a pending force-close countdown\n`/topic <text>` - Set ticket topic/description'
}, },
{ {
name: 'Saved Responses', name: 'Saved Responses',
@@ -282,10 +282,18 @@ async function handleHelp(interaction) {
}, },
{ {
name: 'Escalation', name: 'Escalation',
value: '`/escalate [reason] [tier]` - Escalate ticket (tier 2 or 3, or one step)\n`/deescalate` - De-escalate ticket (tier 3→2 or tier 2→normal)' value: '`/escalate <level>` - Escalate ticket (tier 2 or 3, or one step)\n`/deescalate` - De-escalate ticket (tier 3→2 or tier 2→normal)'
},
{
name: 'Staff Configuration',
value: '`/notifydm` - Toggle DM notifications for your claimed tickets\n`/signature` - Set your email signature\n`/closetimer <seconds>` - Set the force-close countdown\n`/staffthread` - Toggle/configure per-ticket staff threads\n`/pinmessages` - Toggle auto-pinning of ticket messages\n`/gmailpoll <interval>` - Set the Gmail poll interval'
},
{
name: 'Right-click (Apps menu)',
value: '`Create Ticket From Message` - Turn a message into a ticket\n`View User Tickets` - Show a user\'s recent tickets'
} }
]) ])
.setFooter({ text: 'Click buttons on ticket messages to claim/close' }); .setFooter({ text: 'Click buttons on ticket messages to claim/close. Config changes via slash commands apply until the next restart.' });
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
} }
@@ -342,5 +350,6 @@ module.exports = {
handleContextMenu, handleContextMenu,
handleAutocomplete, handleAutocomplete,
runEscalation, runEscalation,
runDeescalation runDeescalation,
resolveEscalationCategoryId
}; };

View File

@@ -5,7 +5,6 @@ const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
const { extractRawEmail, isStaff } = require('../utils'); const { extractRawEmail, isStaff } = require('../utils');
const { getGmailClient, sendGmailReply } = require('../services/gmail'); const { getGmailClient, sendGmailReply } = require('../services/gmail');
const { updateTicketActivity } = require('../services/tickets');
const { getNotifyDm } = require('../services/staffSettings'); const { getNotifyDm } = require('../services/staffSettings');
const { logError } = require('../services/debugLog'); const { logError } = require('../services/debugLog');
@@ -93,8 +92,6 @@ async function handleDiscordReply(m) {
msgId, msgId,
m.author.id m.author.id
); );
await updateTicketActivity(ticket.gmailThreadId);
} catch (e) { } catch (e) {
console.error('REPLY ERROR:', e); console.error('REPLY ERROR:', e);
} }

View File

@@ -1,7 +1,7 @@
/** /**
* Shared pending-close timer map. * Shared pending-close timer map.
* Keyed by channel.id → { timeout, userId, username }. * Keyed by channel.id → { timeout, username, sendEmail }.
* Used by buttons.js (sets timers) and commands.js (cancel-close clears them). * Used by buttons.js (sets timers) and commands/ (cancel-close clears them).
*/ */
const pendingCloses = new Map(); const pendingCloses = new Map();

View File

@@ -15,7 +15,6 @@ const ticketSchema = new mongoose.Schema({
escalationTier: { type: Number, default: 0 }, escalationTier: { type: Number, default: 0 },
ticketNumber: Number, ticketNumber: Number,
priority: { type: String, default: 'normal', enum: ['low', 'normal', 'medium', 'high'] }, priority: { type: String, default: 'normal', enum: ['low', 'normal', 'medium', 'high'] },
ticketTag: String,
lastActivity: Date, lastActivity: Date,
welcomeMessageId: String, welcomeMessageId: String,
claimerId: String, claimerId: String,

View File

@@ -193,8 +193,4 @@ router.post('/gmail/reload', express.json(), async (req, res) => {
} }
}); });
// Expose the allowlist for the Phase 8 schema smoke test. Attached to the
// router function object; doesn't show up as a route.
router._allowedKeys = Array.from(ALLOWED_CONFIG_KEYS);
module.exports = router; module.exports = router;

View File

@@ -24,23 +24,22 @@ const ALLOWED_CONFIG_KEYS = new Set([
'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID', 'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID',
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID', 'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID',
// Roles and staff // Roles and staff
'ROLE_ID_TO_PING', 'ROLE_TO_PING_ID', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES', 'ROLE_ID_TO_PING', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES',
'ADMIN_ID', 'ADMIN_ID',
// Channel IDs // Channel IDs
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID', 'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
'RENAME_LOG_CHANNEL_ID',
// Messages and labels // Messages and labels
'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE', 'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE',
'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE', 'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE',
'TICKET_WELCOME_MESSAGE', 'TICKET_CLAIMED_MESSAGE', 'TICKET_UNCLAIMED_MESSAGE', 'TICKET_WELCOME_MESSAGE', 'TICKET_CLAIMED_MESSAGE', 'TICKET_UNCLAIMED_MESSAGE',
'REMINDER_MESSAGE', 'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM', 'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM',
'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM', 'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM',
// Branding // Branding
'LOGO_URL', 'SUPPORT_NAME', 'EMAIL_SIGNATURE', 'GAME_LIST', 'LOGO_URL', 'SUPPORT_NAME', 'EMAIL_SIGNATURE', 'GAME_LIST',
// Toggles // Toggles
'AUTO_CLOSE_ENABLED', 'AUTO_CLOSE_AFTER_HOURS', 'AUTO_UNCLAIM_ENABLED', 'AUTO_UNCLAIM_AFTER_HOURS', 'AUTO_CLOSE_ENABLED', 'AUTO_CLOSE_AFTER_HOURS', 'AUTO_UNCLAIM_ENABLED', 'AUTO_UNCLAIM_AFTER_HOURS',
'ALLOW_CLAIM_OVERWRITE', 'ALLOW_CLAIM_OVERWRITE',
'REMINDER_ENABLED', 'REMINDER_AFTER_HOURS', 'PRIORITY_ENABLED', 'DEFAULT_PRIORITY', 'PRIORITY_ENABLED', 'DEFAULT_PRIORITY',
'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID', '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', 'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE',
// Limits and thresholds // Limits and thresholds
@@ -140,26 +139,6 @@ const VALIDATORS = {
return { ok: true, coerced: parts.join(',') }; 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 // Fallback. Preserves legacy coercion so CONFIG.* values keep their types
// for consumers that compare with === true / === 5 (see old applyConfigUpdates). // for consumers that compare with === true / === 5 (see old applyConfigUpdates).
string: { string: {

View File

@@ -182,18 +182,17 @@ async function sendTicketClosedEmail(ticket, closerName, userId = null) {
/** /**
* Send a notification email in the ticket thread (e.g. escalation, high-priority). * Send a notification email in the ticket thread (e.g. escalation, high-priority).
* @param {Object} ticket - Ticket with gmailThreadId, senderEmail, subject * @param {Object} ticket - Ticket with gmailThreadId, senderEmail, subject
* @param {string} subjectLine - Fallback subject if the thread can't be queried
* @param {string} messageBody - Plain or HTML message body * @param {string} messageBody - Plain or HTML message body
* @param {string} [userId] - Discord user ID for signature (optional) * @param {string} [userId] - Discord user ID for signature (optional)
*/ */
async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, userId = null) { async function sendTicketNotificationEmail(ticket, messageBody, userId = null) {
try { try {
const recipient = resolveCustomerRecipient(ticket, 'sendTicketNotificationEmail'); const recipient = resolveCustomerRecipient(ticket, 'sendTicketNotificationEmail');
if (!recipient) return; if (!recipient) return;
const gmail = getGmailClient(); const gmail = getGmailClient();
const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId); const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId);
const encodedSubject = encodeReplySubject(subject || subjectLine || ticket.subject || 'Support'); const encodedSubject = encodeReplySubject(subject || ticket.subject || 'Support');
await sendThreadedEmail(gmail, { await sendThreadedEmail(gmail, {
threadId: ticket.gmailThreadId, threadId: ticket.gmailThreadId,

View File

@@ -1,6 +1,6 @@
/** /**
* Ticket database helpers counters, rename, limits, auto-close, * Ticket database helpers counters, rename, limits, auto-close,
* reminders, auto-unclaim, channel creation. * auto-unclaim, channel creation.
*/ */
const { ChannelType } = require('discord.js'); const { ChannelType } = require('discord.js');
const { mongoose, withRetry } = require('../db-connection'); const { mongoose, withRetry } = require('../db-connection');
@@ -269,15 +269,6 @@ async function checkTicketLimits(senderEmail) {
return { ok: true }; return { ok: true };
} }
// --- ACTIVITY ---
async function updateTicketActivity(gmailThreadId) {
await Ticket.updateOne(
{ gmailThreadId },
{ $set: { lastActivity: new Date() } }
);
}
// --- SCHEDULED CHECKS --- // --- SCHEDULED CHECKS ---
// These accept `client` and optionally `sendTicketClosedEmail` to avoid circular deps. // These accept `client` and optionally `sendTicketClosedEmail` to avoid circular deps.
@@ -291,11 +282,11 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
lastActivity: { $lt: cutoffTime, $ne: null } lastActivity: { $lt: cutoffTime, $ne: null }
}).sort({ createdAt: 1 }).limit(500).lean()); }).sort({ createdAt: 1 }).limit(500).lean());
const guild = client.guilds.cache.first();
if (!guild) return;
for (const ticket of staleTickets) { for (const ticket of staleTickets) {
try { try {
const guild = client.guilds.cache.first();
if (!guild) continue;
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null); const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
if (channel) { if (channel) {
await enqueueSend(channel, CONFIG.DISCORD_AUTO_CLOSE_MESSAGE); await enqueueSend(channel, CONFIG.DISCORD_AUTO_CLOSE_MESSAGE);
@@ -337,11 +328,11 @@ async function checkAutoUnclaim(client) {
lastActivity: { $lt: unclaimTime, $ne: null } lastActivity: { $lt: unclaimTime, $ne: null }
}).lean()); }).lean());
const guild = client.guilds.cache.first();
if (!guild) return;
for (const ticket of staleClaimedTickets) { for (const ticket of staleClaimedTickets) {
try { try {
const guild = client.guilds.cache.first();
if (!guild) continue;
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null); const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
if (channel) { if (channel) {
await withRetry(() => Ticket.updateOne( await withRetry(() => Ticket.updateOne(
@@ -426,7 +417,6 @@ module.exports = {
makeTicketName, makeTicketName,
checkTicketCreationRateLimit, checkTicketCreationRateLimit,
checkTicketLimits, checkTicketLimits,
updateTicketActivity,
checkAutoClose, checkAutoClose,
checkAutoUnclaim, checkAutoUnclaim,
reconcileDeletedTicketChannels, reconcileDeletedTicketChannels,

37
services/transcript.js Normal file
View File

@@ -0,0 +1,37 @@
/**
* Shared transcript rendering for the two close paths
* (handlers/buttons.js runFinalClose and handlers/commands/close.js postTranscript).
* Pure formatting only — each caller owns its own posting / DB / email side effects.
*/
const { CONFIG } = require('../config');
/** Render the last 100 messages of a channel as a plaintext transcript. */
async function buildTranscriptText(channel, ticket) {
const messages = await channel.messages.fetch({ limit: 100 });
return `TRANSCRIPT: ${ticket.subject}\nUser: ${ticket.senderEmail}\n---\n` +
messages
.reverse()
.map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`)
.join('\n');
}
/** Format a date for the transcript header (US locale, 12h, with time zone). */
function formatDateForTranscript(d) {
return new Date(d).toLocaleString('en-US', {
month: '2-digit', day: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: true, timeZoneName: 'short'
});
}
/** Build the transcript-channel message body from DISCORD_TRANSCRIPT_MESSAGE. */
function renderTranscriptHeader(channelName, senderEmail, openedStr, closedStr) {
return CONFIG.DISCORD_TRANSCRIPT_MESSAGE
.replace(/\{channel_name\}/g, channelName)
.replace(/\{email\}/g, senderEmail || '')
.replace(/\{date_opened\}/g, openedStr)
.replace(/\{date_closed\}/g, closedStr)
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
}
module.exports = { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader };

View File

@@ -84,7 +84,6 @@
<div class="field"><label>Debugging Channel</label><input type="text" data-key="DEBUGGING_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>Gmail Log Channel</label><input type="text" data-key="GMAIL_LOG_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>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>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>System Log Channel</label><input type="text" data-key="SYSTEM_LOG_CHANNEL_ID" data-smart="channel"></div>
</div></div> </div></div>
@@ -123,8 +122,6 @@
<div class="section-body"><div class="field-grid"> <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</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>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>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</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>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>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>
@@ -136,7 +133,6 @@
<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>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>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>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></div>
</div> </div>

View File

@@ -22,9 +22,6 @@ function isStaff(member) {
// --- TEXT PROCESSING --- // --- TEXT PROCESSING ---
const BLOCK_TAG_REGEX =
/<\/(p|div|li|h[1-6]|tr|table|section|article|blockquote)>/gi;
function escapeRegex(str) { function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
} }
@@ -40,28 +37,6 @@ function escapeHtml(str) {
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
} }
function decodeHtmlEntities(str) {
if (!str) return '';
return str
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ');
}
function htmlToTextWithBlocks(html) {
return decodeHtmlEntities(
html
.replace(/\r\n/g, '\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(BLOCK_TAG_REGEX, '\n\n')
.replace(/<(ul|ol)[^>]*>/gi, '\n')
.replace(/<[^>]*>?/gm, '')
);
}
// --- EMAIL BODY EXTRACTION --- // --- EMAIL BODY EXTRACTION ---
function decodeGmailData(p) { function decodeGmailData(p) {
@@ -277,7 +252,6 @@ module.exports = {
escapeHtml, escapeHtml,
safeEqual, safeEqual,
isStaff, isStaff,
htmlToTextWithBlocks,
getCleanBody, getCleanBody,
stripEmailQuotes, stripEmailQuotes,
stripMobileFooter, stripMobileFooter,

View File

@@ -2,16 +2,36 @@
* Ticket action row builder Close, Claim, Escalate (if tier < 3), Deescalate (if tier >= 2). * Ticket action row builder Close, Claim, Escalate (if tier < 3), Deescalate (if tier >= 2).
* Used by handlers/buttons.js and handlers/commands.js. * Used by handlers/buttons.js and handlers/commands.js.
*/ */
const { ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js'); const { ActionRowBuilder, ButtonBuilder, ButtonStyle, PermissionFlagsBits } = require('discord.js');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
/**
* permissionOverwrites for a Discord-originated ticket channel: deny @everyone,
* allow the creating user and the staff ping role. Used by the button and
* context-menu creation paths (the email/gmail path differs — no Discord
* creator — and builds its own overwrites).
* @param {import('discord.js').Guild} guild
* @param {string} creatorId - Discord user ID of the ticket creator
*/
function ticketChannelOverwrites(guild, creatorId) {
const allow = [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory
];
return [
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
{ id: creatorId, allow },
{ id: CONFIG.ROLE_ID_TO_PING, allow }
];
}
/** /**
* Build the standard ticket action row (Close, Claim, optionally Escalate, optionally Deescalate). * Build the standard ticket action row (Close, Claim, optionally Escalate, optionally Deescalate).
* @param {Object} ticket - Ticket with escalationTier (0, 1, 2) and optionally escalated * @param {Object} ticket - Ticket with escalationTier (0, 1, 2) and optionally escalated
* @param {Object} [options] - { unclaimLabel, unclaimEmoji } for claim button when ticket is claimed
* @returns {ActionRowBuilder} * @returns {ActionRowBuilder}
*/ */
function getTicketActionRow(ticket, options = {}) { function getTicketActionRow(ticket) {
const tier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); const tier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
const row = new ActionRowBuilder(); const row = new ActionRowBuilder();
@@ -23,8 +43,8 @@ function getTicketActionRow(ticket, options = {}) {
.setStyle(ButtonStyle.Secondary), .setStyle(ButtonStyle.Secondary),
new ButtonBuilder() new ButtonBuilder()
.setCustomId('claim_ticket') .setCustomId('claim_ticket')
.setLabel(options.unclaimLabel ?? CONFIG.BUTTON_LABEL_CLAIM) .setLabel(CONFIG.BUTTON_LABEL_CLAIM)
.setEmoji(options.unclaimEmoji ?? CONFIG.BUTTON_EMOJI_CLAIM) .setEmoji(CONFIG.BUTTON_EMOJI_CLAIM)
.setStyle(ButtonStyle.Secondary) .setStyle(ButtonStyle.Secondary)
); );
@@ -48,4 +68,4 @@ function getTicketActionRow(ticket, options = {}) {
return row; return row;
} }
module.exports = { getTicketActionRow }; module.exports = { getTicketActionRow, ticketChannelOverwrites };