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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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] });
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
37
services/transcript.js
Normal 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 };
|
||||
@@ -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>
|
||||
|
||||
|
||||
26
utils.js
26
utils.js
@@ -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, ''');
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(str) {
|
||||
if (!str) return '';
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user