Merge: dead-code removal + close/escalation dedup
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
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>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>
|
||||||
|
|
||||||
|
|||||||
26
utils.js
26
utils.js
@@ -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, ''');
|
.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 ---
|
// --- 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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user