Remove dead/stale code, dedup close+escalation paths

Dead/stale removals (grep-confirmed no consumers):
- config: drop 9 unread CONFIG keys (ROLE_TO_PING_ID, SIGNATURE,
  REMINDER_*, RENAME_LOG_CHANNEL_ID, SETTINGS_*); remove their
  ALLOWED_CONFIG_KEYS entries and the orphaned settings-site UI fields
- configSchema: delete unreachable json/string_or_json validators
- models: drop unused ticketTag field
- gmail-poll: remove unused isPollSuspended export
- utils: remove dead htmlToTextWithBlocks/decodeHtmlEntities/BLOCK_TAG_REGEX
- internalApi: remove router._allowedKeys (test it served is gone)
- discord client: drop unused GuildPresences privileged intent
- broccolini-discord: remove dormant /api 503 gate (no /api routes)

Fixes:
- context-menu ticket create now uses makeTicketName('unclaimed', ...)
  instead of the contract-violating ticket-<n> name
- drop write-only pending.userId from both close paths

Dedup / simplify:
- new services/transcript.js shares the transcript text/date/header
  builders between the button and force-close paths (had drifted)
- resolveEscalationCategoryId() replaces 3 copies of the category logic
- ticketChannelOverwrites() shares the create-permission array between
  the two interactive ticket-create paths
- finalizeBody() shares the email-cleanup tail in parseGmailMessage
- getTicketActionRow drops its never-passed options arg;
  sendTicketNotificationEmail drops its always-null subjectLine arg
- hoist invariant guild lookup out of the auto-close/unclaim loops
- drop redundant lastActivity write (and now-dead updateTicketActivity)
- /help lists all current commands and the right-click apps
This commit is contained in:
2026-06-02 19:59:14 +00:00
parent a388d99fdf
commit 2fab3b97bf
19 changed files with 141 additions and 217 deletions

View File

@@ -79,8 +79,7 @@ const client = new Client({
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildPresences // Required for staff presence detection; enable in Discord Developer Portal
GatewayIntentBits.GuildMembers
],
partials: [Partials.Channel]
});
@@ -238,15 +237,6 @@ client.once('ready', async () => {
client.login(CONFIG.DISCORD_TOKEN);
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.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',
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_TO_PING_ID: process.env.ROLE_ID_TO_PING || process.env.ROLE_TO_PING_ID,
TRANSCRIPT_CHANNEL_ID: process.env.TRANSCRIPT_CHANNEL_ID,
LOGGING_CHANNEL_ID: process.env.LOGGING_CHANNEL_ID,
DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null,
@@ -30,7 +29,6 @@ const CONFIG = {
CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫',
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
SIGNATURE: (process.env.EMAIL_SIGNATURE || '').trim().replace(/\\n/g, '\n'),
GAME_LIST: process.env.GAME_LIST || '',
// 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,
@@ -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_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} ☀️',
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',
DEFAULT_PRIORITY: process.env.DEFAULT_PRIORITY || 'normal',
PRIORITY_HIGH_EMOJI: process.env.PRIORITY_HIGH_EMOJI || '🔴',
@@ -80,7 +75,6 @@ const CONFIG = {
ADMIN_ID: process.env.ADMIN_ID || null,
FORCE_CLOSE_TIMER: toInt(process.env.FORCE_CLOSE_TIMER_SECONDS, 60),
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_NAME: process.env.STAFF_THREAD_NAME || 'Staff Discussion',
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',
TRANSCRIPT_DM_TO_CREATOR: process.env.TRANSCRIPT_DM_TO_CREATOR === '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_SECRET: process.env.INTERNAL_API_SECRET || null
};

View File

@@ -37,7 +37,6 @@ function setPollSuspended(val) {
pollSuspended = !!val;
if (!pollSuspended) authErrorNotified = false;
}
function isPollSuspended() { return pollSuspended; }
// ============================================================
// 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
* 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) {
const headers = email.data.payload.headers;
const from = headers.find(h => h.name === 'From')?.value || '';
@@ -92,10 +101,7 @@ function parseGmailMessage(email) {
firstBody = stripMobileFooter(firstBody);
firstBody = firstBody.replace(/^\s*\n+/g, '');
firstBody = firstBody.replace(/\n{3,}/g, '\n\n');
firstBody = firstBody
.replace(/Get Outlook for [^\n]+/gi, '')
.replace(/<\s*$/gm, '')
.trim();
firstBody = finalizeBody(firstBody);
const rawText = rawBody.replace(/\r\n/g, '\n');
let followupBody = stripEmailQuotes(rawText);
@@ -103,10 +109,7 @@ function parseGmailMessage(email) {
followupBody = followupBody.replace(/^\s*\n*/, '\n');
followupBody = followupBody.replace(/\n{3,}/g, '\n\n');
followupBody = stripMobileFooter(followupBody);
followupBody = followupBody
.replace(/Get Outlook for [^\n]+/gi, '')
.replace(/<\s*$/gm, '')
.trim();
followupBody = finalizeBody(followupBody);
return {
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,
EmbedBuilder,
MessageFlags,
PermissionFlagsBits,
ModalBuilder,
TextInputBuilder,
TextInputStyle
@@ -25,10 +24,11 @@ const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config');
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName } = require('../services/tickets');
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 { enqueueRename, enqueueSend } = require('../services/channelQueue');
const { runEscalation, runDeescalation } = require('./commands');
const { runEscalation, runDeescalation, resolveEscalationCategoryId } = require('./commands');
const { pendingCloses } = require('./pendingCloses');
const { addMemberToStaffThread, createStaffThread } = require('../services/staffThread');
const { pinMessage } = require('../services/pinMessage');
@@ -309,7 +309,7 @@ async function handleConfirmCloseRequest(interaction, ticket) {
await runFinalClose(interaction, freshTicket, effectiveSendEmail);
}, 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) {
@@ -358,10 +358,7 @@ async function handleEscalateButton(interaction, ticket) {
return interaction.reply({ content: `This ticket is already at tier ${tier + 1}.`, flags: MessageFlags.Ephemeral });
}
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
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);
const categoryId = resolveEscalationCategoryId(ticket, tier);
if (!categoryId && !interaction.channel.isThread()) {
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) {
// Prefer ticket.creatorId (stored on creation). Fall back to legacy parsing for
// pre-creatorId modal tickets only — split-pop returns the wrong value for
@@ -601,17 +571,7 @@ async function handleTicketModal(interaction) {
name: unclaimedName,
type: ChannelType.GuildText,
parent: parentCategoryIdForTicket,
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]
}
]
permissionOverwrites: ticketChannelOverwrites(guild, interaction.user.id)
});
} catch (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 { pendingCloses } = require('../pendingCloses');
const { findTicketForChannel } = require('../sharedHelpers');
const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../../services/transcript');
const Ticket = mongoose.model('Ticket');
@@ -56,7 +57,7 @@ async function handleForceClose(interaction) {
const channelRef = interaction.channel;
const clientRef = interaction.client;
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. */
@@ -92,14 +93,7 @@ async function finalizeForceClose(channelRef, clientRef) {
async function postTranscript(channelRef, clientRef, freshTicket) {
await enqueueSend(channelRef, CONFIG.DISCORD_CLOSE_MESSAGE);
const messages = await channelRef.messages.fetch({ limit: 100 });
const log =
`TRANSCRIPT: ${freshTicket.subject}\nUser: ${freshTicket.senderEmail}\n---\n` +
messages
.reverse()
.map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`)
.join('\n');
const log = await buildTranscriptText(channelRef, freshTicket);
const file = new AttachmentBuilder(Buffer.from(log), {
name: `transcript-${channelRef.name}.txt`
});
@@ -109,19 +103,9 @@ async function postTranscript(channelRef, clientRef, freshTicket) {
.catch(() => null);
if (!transcriptChan) return;
const fmt = (d) => 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'
});
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}`;
const openedStr = formatDateForTranscript(freshTicket.createdAt);
const closedStr = formatDateForTranscript(new Date());
const transcriptContent = renderTranscriptHeader(channelRef.name, freshTicket.senderEmail, openedStr, closedStr);
await enqueueSend(transcriptChan, { content: transcriptContent, files: [file] });
}

View File

@@ -6,14 +6,13 @@
const {
ChannelType,
EmbedBuilder,
MessageFlags,
PermissionFlagsBits
MessageFlags
} = require('discord.js');
const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config');
const { getPriorityEmoji } = require('../../utils');
const { checkTicketCreationRateLimit, getOrCreateTicketCategory } = require('../../services/tickets');
const { getTicketActionRow } = require('../../utils/ticketComponents');
const { checkTicketCreationRateLimit, getOrCreateTicketCategory, makeTicketName } = require('../../services/tickets');
const { getTicketActionRow, ticketChannelOverwrites } = require('../../utils/ticketComponents');
const { enqueueSend } = require('../../services/channelQueue');
const { logError } = require('../../services/debugLog');
@@ -36,6 +35,8 @@ async function handleCreateTicketFromMessage(interaction) {
const guild = interaction.guild;
const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean();
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
const creatorNickname = message.member?.displayName || message.author.username;
const unclaimedName = makeTicketName('unclaimed', { ticketNumber }, creatorNickname);
let parentCategoryIdForTicket;
try {
@@ -52,20 +53,10 @@ async function handleCreateTicketFromMessage(interaction) {
let channel;
try {
channel = await guild.channels.create({
name: `ticket-${ticketNumber}`,
name: unclaimedName,
type: ChannelType.GuildText,
parent: parentCategoryIdForTicket,
permissionOverwrites: [
{ 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]
}
]
permissionOverwrites: ticketChannelOverwrites(guild, message.author.id)
});
} catch (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');
/**
* 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
* validate ticket and currentTier < nextTier, and have already deferred.
*/
async function runEscalation(interaction, ticket, nextTier) {
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
const categoryId = nextTier === 1
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
const categoryId = resolveEscalationCategoryId(ticket, nextTier);
// Clear claim on escalation
await Ticket.updateOne(
@@ -88,7 +99,7 @@ async function runEscalation(interaction, ticket, nextTier) {
const escalatorName = interaction.member?.displayName || interaction.user.username;
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
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) {
console.error('Escalation email failed (non-fatal):', emailErr.message);
}
@@ -179,9 +190,7 @@ async function handleEscalate(interaction) {
}
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
const categoryId = nextTier === 1
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
const categoryId = resolveEscalationCategoryId(ticket, nextTier);
const configKey = nextTier === 1 ? 'ESCALATED2' : 'ESCALATED3';
if (!categoryId && !interaction.channel.isThread()) {
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 { 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 { handleResponse, handleAutocomplete } = require('./response');
const { handlePanel, handleSignature } = require('./panel');
@@ -266,7 +266,7 @@ async function handleHelp(interaction) {
},
{
name: 'Ticket Management',
value: '`/transfer @staff` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/topic <text>` - Set ticket topic/description'
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',
@@ -282,10 +282,18 @@ async function handleHelp(interaction) {
},
{
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 });
}
@@ -342,5 +350,6 @@ module.exports = {
handleContextMenu,
handleAutocomplete,
runEscalation,
runDeescalation
runDeescalation,
resolveEscalationCategoryId
};

View File

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

View File

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

View File

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

View File

@@ -24,23 +24,22 @@ const ALLOWED_CONFIG_KEYS = new Set([
'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',
'ROLE_ID_TO_PING', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES',
'ADMIN_ID',
// Channel IDs
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
'RENAME_LOG_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',
'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',
// Branding
'LOGO_URL', 'SUPPORT_NAME', 'EMAIL_SIGNATURE', 'GAME_LIST',
// Toggles
'AUTO_CLOSE_ENABLED', 'AUTO_CLOSE_AFTER_HOURS', 'AUTO_UNCLAIM_ENABLED', 'AUTO_UNCLAIM_AFTER_HOURS',
'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',
'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE',
// Limits and thresholds
@@ -140,26 +139,6 @@ const VALIDATORS = {
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: {

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).
* @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} [userId] - Discord user ID for signature (optional)
*/
async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, userId = null) {
async function sendTicketNotificationEmail(ticket, messageBody, userId = null) {
try {
const recipient = resolveCustomerRecipient(ticket, 'sendTicketNotificationEmail');
if (!recipient) return;
const gmail = getGmailClient();
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, {
threadId: ticket.gmailThreadId,

View File

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

View File

@@ -22,9 +22,6 @@ function isStaff(member) {
// --- TEXT PROCESSING ---
const BLOCK_TAG_REGEX =
/<\/(p|div|li|h[1-6]|tr|table|section|article|blockquote)>/gi;
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -40,28 +37,6 @@ function escapeHtml(str) {
.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 ---
function decodeGmailData(p) {
@@ -277,7 +252,6 @@ module.exports = {
escapeHtml,
safeEqual,
isStaff,
htmlToTextWithBlocks,
getCleanBody,
stripEmailQuotes,
stripMobileFooter,

View File

@@ -2,16 +2,36 @@
* Ticket action row builder Close, Claim, Escalate (if tier < 3), Deescalate (if tier >= 2).
* 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');
/**
* 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).
* @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}
*/
function getTicketActionRow(ticket, options = {}) {
function getTicketActionRow(ticket) {
const tier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
const row = new ActionRowBuilder();
@@ -23,8 +43,8 @@ function getTicketActionRow(ticket, options = {}) {
.setStyle(ButtonStyle.Secondary),
new ButtonBuilder()
.setCustomId('claim_ticket')
.setLabel(options.unclaimLabel ?? CONFIG.BUTTON_LABEL_CLAIM)
.setEmoji(options.unclaimEmoji ?? CONFIG.BUTTON_EMOJI_CLAIM)
.setLabel(CONFIG.BUTTON_LABEL_CLAIM)
.setEmoji(CONFIG.BUTTON_EMOJI_CLAIM)
.setStyle(ButtonStyle.Secondary)
);
@@ -48,4 +68,4 @@ function getTicketActionRow(ticket, options = {}) {
return row;
}
module.exports = { getTicketActionRow };
module.exports = { getTicketActionRow, ticketChannelOverwrites };