audit week 1: creator ID tracking, channel-queue migration, deprecation cleanup
QUAL-006 store ticket.creatorId on creation; legacy split-pop returned the
message ID for discord-msg-* tickets, breaking transcript DM, close
log, and channel rename for context-menu-created tickets. Adds the
field to the Ticket schema and writes a one-shot backfill script
(scripts/backfill-creatorId.js, dry-run by default).
QUEUE-001 add enqueueOverwrite + enqueueTopic to services/channelQueue.js
(chain on renameChains alongside enqueueMove). Migrate handleAdd /
handleRemove / handleMove / handleTopic so permissionOverwrites,
setParent, and setTopic no longer race pending renames or sends.
handleMove now uses the existing enqueueMove. Initial overwrites in
handleTicketModal stay inline; channel doesn't exist yet so no race.
DISCORD-001 replace ephemeral: true with flags: MessageFlags.Ephemeral across
broccolini-discord.js, handlers/sharedHelpers.js, handlers/buttons.js,
handlers/commands.js. runDeferred opts now take { flags } directly.
SEC-003 /gmailpoll min interval is 30s. Drop the 5s/10s slash-command
choices and clamp Math.max(30000, ms) in handleGmailPoll for
defense in depth.
QUAL-001 upgrade silent .catch(() => {}) on the lastActivity updateOne in
handlers/messages.js to log via logError, so transient Mongo errors
surface in the debug channel instead of disappearing.
QUAL-002 drop await from logError/logWarn calls in services/staffThread.js
and services/pinMessage.js — fire-and-forget per CLAUDE.md hard rule.
QUAL-003 wrap stray setTimeouts (handleConfirmCloseRequest force-close timer,
runFinalClose channel-delete + overflow-cleanup, checkAutoClose
delete-after-email) in trackTimeout via lazy require so they clear
on shutdown.
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
* Entry point – initializes the Discord bot, wires event handlers,
|
* Entry point – initializes the Discord bot, wires event handlers,
|
||||||
* connects to MongoDB, starts Gmail polling, and runs the Express healthcheck.
|
* connects to MongoDB, starts Gmail polling, and runs the Express healthcheck.
|
||||||
*/
|
*/
|
||||||
const { Client, GatewayIntentBits, Partials } = require('discord.js');
|
const { Client, GatewayIntentBits, Partials, MessageFlags } = require('discord.js');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { connectMongoDB, closeMongoDB } = require('./db-connection');
|
const { connectMongoDB, closeMongoDB } = require('./db-connection');
|
||||||
const { CONFIG } = require('./config');
|
const { CONFIG } = require('./config');
|
||||||
@@ -86,7 +86,7 @@ const client = new Client({
|
|||||||
|
|
||||||
// --- EVENT: interactionCreate ---
|
// --- EVENT: interactionCreate ---
|
||||||
async function safeReplyError(interaction) {
|
async function safeReplyError(interaction) {
|
||||||
const payload = { content: 'Something went wrong.', ephemeral: true };
|
const payload = { content: 'Something went wrong.', flags: MessageFlags.Ephemeral };
|
||||||
if (interaction.deferred || interaction.replied) {
|
if (interaction.deferred || interaction.replied) {
|
||||||
await interaction.followUp(payload).catch(() => {});
|
await interaction.followUp(payload).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
@@ -132,13 +132,13 @@ client.on('interactionCreate', async interaction => {
|
|||||||
|
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: 'Signature settings saved successfully!',
|
content: 'Signature settings saved successfully!',
|
||||||
ephemeral: true
|
flags: MessageFlags.Ephemeral
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Signature modal submit error:', err);
|
console.error('Signature modal submit error:', err);
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: 'Failed to save signature settings.',
|
content: 'Failed to save signature settings.',
|
||||||
ephemeral: true
|
flags: MessageFlags.Ephemeral
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -357,8 +357,6 @@ async function registerCommands() {
|
|||||||
.setDescription('Poll interval')
|
.setDescription('Poll interval')
|
||||||
.setRequired(true)
|
.setRequired(true)
|
||||||
.addChoices(
|
.addChoices(
|
||||||
{ name: '5s', value: '5' },
|
|
||||||
{ name: '10s', value: '10' },
|
|
||||||
{ name: '30s', value: '30' },
|
{ name: '30s', value: '30' },
|
||||||
{ name: '45s', value: '45' },
|
{ name: '45s', value: '45' },
|
||||||
{ name: '1m', value: '60' },
|
{ name: '1m', value: '60' },
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const {
|
|||||||
ButtonStyle,
|
ButtonStyle,
|
||||||
AttachmentBuilder,
|
AttachmentBuilder,
|
||||||
EmbedBuilder,
|
EmbedBuilder,
|
||||||
|
MessageFlags,
|
||||||
PermissionFlagsBits,
|
PermissionFlagsBits,
|
||||||
ModalBuilder,
|
ModalBuilder,
|
||||||
TextInputBuilder,
|
TextInputBuilder,
|
||||||
@@ -121,7 +122,7 @@ async function handleTagDeleteConfirm(interaction) {
|
|||||||
async function handleClaimButton(interaction, ticket) {
|
async function handleClaimButton(interaction, ticket) {
|
||||||
const freshTicket = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId }).lean();
|
const freshTicket = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId }).lean();
|
||||||
if (!freshTicket) {
|
if (!freshTicket) {
|
||||||
return interaction.reply({ content: 'Ticket data missing.', ephemeral: true });
|
return interaction.reply({ content: 'Ticket data missing.', flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
|
|
||||||
const isClaimed = !!freshTicket.claimedBy;
|
const isClaimed = !!freshTicket.claimedBy;
|
||||||
@@ -131,19 +132,19 @@ async function handleClaimButton(interaction, ticket) {
|
|||||||
|
|
||||||
const [row0] = interaction.message.components;
|
const [row0] = interaction.message.components;
|
||||||
if (!row0) {
|
if (!row0) {
|
||||||
return interaction.reply({ content: 'No components to update.', ephemeral: true });
|
return interaction.reply({ content: 'No components to update.', flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = ActionRowBuilder.from(row0);
|
const row = ActionRowBuilder.from(row0);
|
||||||
const [btnClose, btnClaim] = row.components;
|
const [btnClose, btnClaim] = row.components;
|
||||||
if (!btnClose || !btnClaim) {
|
if (!btnClose || !btnClaim) {
|
||||||
return interaction.reply({ content: 'Buttons missing.', ephemeral: true });
|
return interaction.reply({ content: 'Buttons missing.', flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isClaimed && !isClaimedByMe && !CONFIG.ALLOW_CLAIM_OVERWRITE) {
|
if (isClaimed && !isClaimedByMe && !CONFIG.ALLOW_CLAIM_OVERWRITE) {
|
||||||
return interaction.reply({
|
return interaction.reply({
|
||||||
content: `This ticket is already claimed by **${freshTicket.claimedBy}**.`,
|
content: `This ticket is already claimed by **${freshTicket.claimedBy}**.`,
|
||||||
ephemeral: true
|
flags: MessageFlags.Ephemeral
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +278,7 @@ async function handleConfirmCloseRequest(interaction, ticket) {
|
|||||||
const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
|
const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
|
||||||
|
|
||||||
if (pendingCloses.has(interaction.channel.id)) {
|
if (pendingCloses.has(interaction.channel.id)) {
|
||||||
return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true });
|
return interaction.reply({ content: 'A close is already pending for this ticket.', flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
|
|
||||||
const cancelRow = new ActionRowBuilder().addComponents(
|
const cancelRow = new ActionRowBuilder().addComponents(
|
||||||
@@ -289,7 +290,9 @@ async function handleConfirmCloseRequest(interaction, ticket) {
|
|||||||
const channelName = interaction.channel.name;
|
const channelName = interaction.channel.name;
|
||||||
const userTag = interaction.user.tag;
|
const userTag = interaction.user.tag;
|
||||||
|
|
||||||
const timerId = setTimeout(async () => {
|
// Lazy require — broccolini-discord re-exports trackTimeout and we'd otherwise cycle.
|
||||||
|
const { trackTimeout } = require('../broccolini-discord');
|
||||||
|
const timerId = trackTimeout(setTimeout(async () => {
|
||||||
const pending = pendingCloses.get(channelId);
|
const pending = pendingCloses.get(channelId);
|
||||||
pendingCloses.delete(channelId);
|
pendingCloses.delete(channelId);
|
||||||
const freshTicket = await Ticket.findOne({ discordThreadId: channelId }).lean();
|
const freshTicket = await Ticket.findOne({ discordThreadId: channelId }).lean();
|
||||||
@@ -303,7 +306,7 @@ async function handleConfirmCloseRequest(interaction, ticket) {
|
|||||||
|
|
||||||
const effectiveSendEmail = pending?.sendEmail ?? true;
|
const effectiveSendEmail = pending?.sendEmail ?? true;
|
||||||
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, userId: interaction.user.id, username: userTag, sendEmail });
|
||||||
}
|
}
|
||||||
@@ -324,7 +327,7 @@ async function handleCancelCloseRequest(interaction) {
|
|||||||
async function handleEscalatePrompt(interaction, ticket) {
|
async function handleEscalatePrompt(interaction, ticket) {
|
||||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||||
if (currentTier >= 2) {
|
if (currentTier >= 2) {
|
||||||
return interaction.reply({ content: 'This ticket is already at tier 3 support.', ephemeral: true });
|
return interaction.reply({ content: 'This ticket is already at tier 3 support.', flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttons = [];
|
const buttons = [];
|
||||||
@@ -338,7 +341,7 @@ async function handleEscalatePrompt(interaction, ticket) {
|
|||||||
return interaction.reply({
|
return interaction.reply({
|
||||||
content: 'Escalate to which tier?',
|
content: 'Escalate to which tier?',
|
||||||
components: [new ActionRowBuilder().addComponents(buttons)],
|
components: [new ActionRowBuilder().addComponents(buttons)],
|
||||||
ephemeral: true
|
flags: MessageFlags.Ephemeral
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +354,7 @@ async function handleEscalateButton(interaction, ticket) {
|
|||||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||||
|
|
||||||
if (currentTier >= tier) {
|
if (currentTier >= tier) {
|
||||||
return interaction.reply({ content: `This ticket is already at tier ${tier + 1}.`, ephemeral: true });
|
return interaction.reply({ content: `This ticket is already at tier ${tier + 1}.`, flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||||||
@@ -362,7 +365,7 @@ async function handleEscalateButton(interaction, ticket) {
|
|||||||
if (!categoryId && !interaction.channel.isThread()) {
|
if (!categoryId && !interaction.channel.isThread()) {
|
||||||
return interaction.reply({
|
return interaction.reply({
|
||||||
content: `Tier ${tier + 1} (ESCALATED${tier + 1}) is not configured for this ticket type.`,
|
content: `Tier ${tier + 1} (ESCALATED${tier + 1}) is not configured for this ticket type.`,
|
||||||
ephemeral: true
|
flags: MessageFlags.Ephemeral
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,12 +375,12 @@ async function handleEscalateButton(interaction, ticket) {
|
|||||||
async function handleDeescalateButton(interaction, ticket) {
|
async function handleDeescalateButton(interaction, ticket) {
|
||||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||||
if (currentTier === 0) {
|
if (currentTier === 0) {
|
||||||
return interaction.reply({ content: 'This ticket is not escalated.', ephemeral: true });
|
return interaction.reply({ content: 'This ticket is not escalated.', flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
|
|
||||||
await runDeferred(interaction, 'deescalate',
|
await runDeferred(interaction, 'deescalate',
|
||||||
() => runDeescalation(interaction, ticket),
|
() => runDeescalation(interaction, ticket),
|
||||||
{ ephemeral: true }
|
{ flags: MessageFlags.Ephemeral }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,12 +458,14 @@ async function runFinalClose(interaction, ticket, sendEmail = true) {
|
|||||||
const parentCatId = ticket.parentCategoryId;
|
const parentCatId = ticket.parentCategoryId;
|
||||||
const guildRef = interaction.guild;
|
const guildRef = interaction.guild;
|
||||||
|
|
||||||
setTimeout(() => interaction.channel.delete().catch(() => {}), 5000);
|
// Lazy require — same cycle reason as in handleConfirmCloseRequest above.
|
||||||
setTimeout(() => {
|
const { trackTimeout } = require('../broccolini-discord');
|
||||||
|
trackTimeout(setTimeout(() => interaction.channel.delete().catch(() => {}), 5000));
|
||||||
|
trackTimeout(setTimeout(() => {
|
||||||
if (parentCatId && guildRef) {
|
if (parentCatId && guildRef) {
|
||||||
cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME).catch(() => {});
|
cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME).catch(() => {});
|
||||||
}
|
}
|
||||||
}, 6000);
|
}, 6000));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Close ticket error:', e);
|
console.error('Close ticket error:', e);
|
||||||
}
|
}
|
||||||
@@ -494,7 +499,12 @@ function renderTranscriptHeader(channelName, senderEmail, openedStr, closedStr)
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function dmTranscriptToCreator(client, ticket, channelName, transcriptText, openedStr, closedStr) {
|
async function dmTranscriptToCreator(client, ticket, channelName, transcriptText, openedStr, closedStr) {
|
||||||
const creatorId = ticket.gmailThreadId.split('-').pop();
|
// Prefer ticket.creatorId (stored on creation). Fall back to legacy parsing for
|
||||||
|
// pre-creatorId modal tickets only — split-pop returns the wrong value for
|
||||||
|
// discord-msg-* tickets (it yields the message ID, not the user ID).
|
||||||
|
const creatorId = ticket.creatorId
|
||||||
|
|| (ticket.gmailThreadId.startsWith('discord-msg-') ? null : ticket.gmailThreadId.split('-').pop());
|
||||||
|
if (!creatorId) return;
|
||||||
try {
|
try {
|
||||||
const creator = await client.users.fetch(creatorId);
|
const creator = await client.users.fetch(creatorId);
|
||||||
const dmFile = new AttachmentBuilder(Buffer.from(transcriptText), {
|
const dmFile = new AttachmentBuilder(Buffer.from(transcriptText), {
|
||||||
@@ -524,13 +534,15 @@ async function postCloseLogEntry(interaction, ticket, channelName) {
|
|||||||
|
|
||||||
let logMsg;
|
let logMsg;
|
||||||
if (ticket.gmailThreadId?.startsWith('discord-')) {
|
if (ticket.gmailThreadId?.startsWith('discord-')) {
|
||||||
const creatorId = ticket.gmailThreadId.split('-').pop();
|
const creatorId = ticket.creatorId
|
||||||
try {
|
|| (ticket.gmailThreadId.startsWith('discord-msg-') ? null : ticket.gmailThreadId.split('-').pop());
|
||||||
const creator = await interaction.client.users.fetch(creatorId);
|
let creator = null;
|
||||||
logMsg = `Closed ${creator.toString()}'s **${channelName}** by ${closerMention} (${closerDisplayName})`;
|
if (creatorId) {
|
||||||
} catch {
|
creator = await interaction.client.users.fetch(creatorId).catch(() => null);
|
||||||
logMsg = `Closed **${channelName}** by ${closerMention} (${closerDisplayName})`;
|
|
||||||
}
|
}
|
||||||
|
logMsg = creator
|
||||||
|
? `Closed ${creator.toString()}'s **${channelName}** by ${closerMention} (${closerDisplayName})`
|
||||||
|
: `Closed **${channelName}** by ${closerMention} (${closerDisplayName})`;
|
||||||
} else {
|
} else {
|
||||||
logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`;
|
logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`;
|
||||||
}
|
}
|
||||||
@@ -542,7 +554,7 @@ async function postCloseLogEntry(interaction, ticket, channelName) {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
async function handleTicketModal(interaction) {
|
async function handleTicketModal(interaction) {
|
||||||
await interaction.deferReply({ ephemeral: true });
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
const email = interaction.fields.getTextInputValue('ticket_email').trim().toLowerCase();
|
const email = interaction.fields.getTextInputValue('ticket_email').trim().toLowerCase();
|
||||||
const game = interaction.fields.getTextInputValue('ticket_game').trim();
|
const game = interaction.fields.getTextInputValue('ticket_game').trim();
|
||||||
@@ -578,7 +590,10 @@ async function handleTicketModal(interaction) {
|
|||||||
|
|
||||||
let channel;
|
let channel;
|
||||||
try {
|
try {
|
||||||
// TODO(queue-migrate): initial permissionOverwrites here are fine since the channel is just being created, but any later permissionOverwrites mutation on this channel should go through channelQueue.
|
// Initial permissionOverwrites on guild.channels.create are safe-by-construction:
|
||||||
|
// the channel doesn't exist yet, so there's no in-flight rename/send/move to race
|
||||||
|
// against. Any *subsequent* mutation on this channel (add/remove user, move,
|
||||||
|
// topic, rename) must go through services/channelQueue.js.
|
||||||
channel = await guild.channels.create({
|
channel = await guild.channels.create({
|
||||||
name: unclaimedName,
|
name: unclaimedName,
|
||||||
type: ChannelType.GuildText,
|
type: ChannelType.GuildText,
|
||||||
@@ -613,6 +628,7 @@ async function handleTicketModal(interaction) {
|
|||||||
ticketNumber,
|
ticketNumber,
|
||||||
priority,
|
priority,
|
||||||
lastActivity: now,
|
lastActivity: now,
|
||||||
|
creatorId: interaction.user.id,
|
||||||
parentCategoryId: parentCategoryIdForTicket
|
parentCategoryId: parentCategoryIdForTicket
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const {
|
|||||||
ButtonStyle,
|
ButtonStyle,
|
||||||
AttachmentBuilder,
|
AttachmentBuilder,
|
||||||
EmbedBuilder,
|
EmbedBuilder,
|
||||||
|
MessageFlags,
|
||||||
ModalBuilder,
|
ModalBuilder,
|
||||||
TextInputBuilder,
|
TextInputBuilder,
|
||||||
TextInputStyle,
|
TextInputStyle,
|
||||||
@@ -23,7 +24,7 @@ const { getPriorityEmoji, replaceVariables, isStaff } = require('../utils');
|
|||||||
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, checkTicketCreationRateLimit } = require('../services/tickets');
|
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, checkTicketCreationRateLimit } = require('../services/tickets');
|
||||||
const { sendTicketNotificationEmail } = require('../services/gmail');
|
const { sendTicketNotificationEmail } = require('../services/gmail');
|
||||||
const { getTicketActionRow } = require('../utils/ticketComponents');
|
const { getTicketActionRow } = require('../utils/ticketComponents');
|
||||||
const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channelQueue');
|
const { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../services/channelQueue');
|
||||||
const { setNotifyDm } = require('../services/staffSettings');
|
const { setNotifyDm } = require('../services/staffSettings');
|
||||||
const { pinMessage } = require('../services/pinMessage');
|
const { pinMessage } = require('../services/pinMessage');
|
||||||
const { logError, logTicketEvent } = require('../services/debugLog');
|
const { logError, logTicketEvent } = require('../services/debugLog');
|
||||||
@@ -49,7 +50,7 @@ async function requireStaffRole(interaction) {
|
|||||||
const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'support';
|
const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'support';
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: `This command is only available to the support team (${roleMention}).`,
|
content: `This command is only available to the support team (${roleMention}).`,
|
||||||
ephemeral: true
|
flags: MessageFlags.Ephemeral
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -222,10 +223,10 @@ async function handleEscalate(interaction) {
|
|||||||
|
|
||||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||||
if (currentTier >= 2) {
|
if (currentTier >= 2) {
|
||||||
return interaction.reply({ content: 'This ticket is already at tier 3 support.', ephemeral: true });
|
return interaction.reply({ content: 'This ticket is already at tier 3 support.', flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
if (nextTier <= currentTier) {
|
if (nextTier <= currentTier) {
|
||||||
return interaction.reply({ content: 'Ticket is already at or past that tier.', ephemeral: true });
|
return interaction.reply({ content: 'Ticket is already at or past that tier.', flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||||||
@@ -236,7 +237,7 @@ async function handleEscalate(interaction) {
|
|||||||
if (!categoryId && !interaction.channel.isThread()) {
|
if (!categoryId && !interaction.channel.isThread()) {
|
||||||
return interaction.reply({
|
return interaction.reply({
|
||||||
content: `${configKey} is not configured for ${isDiscordTicket ? 'Discord' : 'email'} tickets.`,
|
content: `${configKey} is not configured for ${isDiscordTicket ? 'Discord' : 'email'} tickets.`,
|
||||||
ephemeral: true
|
flags: MessageFlags.Ephemeral
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,12 +252,12 @@ async function handleDeescalate(interaction) {
|
|||||||
|
|
||||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||||
if (currentTier === 0) {
|
if (currentTier === 0) {
|
||||||
return interaction.reply({ content: 'This ticket is not escalated.', ephemeral: true });
|
return interaction.reply({ content: 'This ticket is not escalated.', flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
|
|
||||||
await runDeferred(interaction, 'de-escalate',
|
await runDeferred(interaction, 'de-escalate',
|
||||||
() => runDeescalation(interaction, ticket),
|
() => runDeescalation(interaction, ticket),
|
||||||
{ ephemeral: true }
|
{ flags: MessageFlags.Ephemeral }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,11 +267,11 @@ async function handleNotifyDm(interaction) {
|
|||||||
await setNotifyDm(interaction.user.id, interaction.guildId, setting);
|
await setNotifyDm(interaction.user.id, interaction.guildId, setting);
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: `DM notifications ${setting ? 'enabled ✅' : 'disabled 🔕'}.`,
|
content: `DM notifications ${setting ? 'enabled ✅' : 'disabled 🔕'}.`,
|
||||||
ephemeral: true
|
flags: MessageFlags.Ephemeral
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('notifydm error:', err);
|
console.error('notifydm error:', err);
|
||||||
await interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {});
|
await interaction.reply({ content: 'Failed to update notification setting.', flags: MessageFlags.Ephemeral }).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,8 +281,7 @@ async function handleAdd(interaction) {
|
|||||||
if (!ticket) return;
|
if (!ticket) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO(queue-migrate): permissionOverwrites mutation bypasses channelQueue — could race a pending rename/send on the same channel.
|
await enqueueOverwrite(interaction.channel, user.id, {
|
||||||
await interaction.channel.permissionOverwrites.create(user.id, {
|
|
||||||
ViewChannel: true,
|
ViewChannel: true,
|
||||||
SendMessages: true,
|
SendMessages: true,
|
||||||
ReadMessageHistory: true
|
ReadMessageHistory: true
|
||||||
@@ -289,7 +289,7 @@ async function handleAdd(interaction) {
|
|||||||
await interaction.reply({ content: `Added ${user} to this ticket.`, allowedMentions: { parse: ['users'] } });
|
await interaction.reply({ content: `Added ${user} to this ticket.`, allowedMentions: { parse: ['users'] } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Add user error:', err);
|
console.error('Add user error:', err);
|
||||||
await interaction.reply({ content: 'Failed to add user.', ephemeral: true });
|
await interaction.reply({ content: 'Failed to add user.', flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,12 +299,11 @@ async function handleRemove(interaction) {
|
|||||||
if (!ticket) return;
|
if (!ticket) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO(queue-migrate): permissionOverwrites mutation bypasses channelQueue — could race a pending rename/send on the same channel.
|
await enqueueOverwrite(interaction.channel, user.id, null, 'delete');
|
||||||
await interaction.channel.permissionOverwrites.delete(user.id);
|
|
||||||
await interaction.reply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } });
|
await interaction.reply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Remove user error:', err);
|
console.error('Remove user error:', err);
|
||||||
await interaction.reply({ content: 'Failed to remove user.', ephemeral: true });
|
await interaction.reply({ content: 'Failed to remove user.', flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +317,7 @@ async function handleTransfer(interaction) {
|
|||||||
const guildMember = await interaction.guild.members.fetch(member.id).catch(() => null);
|
const guildMember = await interaction.guild.members.fetch(member.id).catch(() => null);
|
||||||
|
|
||||||
if (!guildMember || !guildMember.roles.cache.has(staffRoleId)) {
|
if (!guildMember || !guildMember.roles.cache.has(staffRoleId)) {
|
||||||
return interaction.reply({ content: 'The target member must have the staff role.', ephemeral: true });
|
return interaction.reply({ content: 'The target member must have the staff role.', flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -344,7 +343,7 @@ async function handleTransfer(interaction) {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Transfer error:', err);
|
console.error('Transfer error:', err);
|
||||||
await interaction.reply({ content: 'Failed to transfer ticket.', ephemeral: true });
|
await interaction.reply({ content: 'Failed to transfer ticket.', flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,8 +353,7 @@ async function handleMove(interaction) {
|
|||||||
if (!ticket) return;
|
if (!ticket) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO(queue-migrate): setParent bypasses channelQueue (enqueueMove) — use enqueueMove so moves serialize with pending renames/sends.
|
await enqueueMove(interaction.channel, category.id);
|
||||||
await interaction.channel.setParent(category.id, { lockPermissions: true });
|
|
||||||
await interaction.reply(`Moved ticket to **${category.name}**.`);
|
await interaction.reply(`Moved ticket to **${category.name}**.`);
|
||||||
|
|
||||||
const logChan = await fetchLoggingChannel(interaction.client);
|
const logChan = await fetchLoggingChannel(interaction.client);
|
||||||
@@ -366,7 +364,7 @@ async function handleMove(interaction) {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Move error:', err);
|
console.error('Move error:', err);
|
||||||
await interaction.reply({ content: 'Failed to move ticket.', ephemeral: true });
|
await interaction.reply({ content: 'Failed to move ticket.', flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,17 +372,17 @@ async function handleStaffThread(interaction) {
|
|||||||
const sub = interaction.options.getSubcommand();
|
const sub = interaction.options.getSubcommand();
|
||||||
if (sub === 'toggle') {
|
if (sub === 'toggle') {
|
||||||
CONFIG.STAFF_THREAD_ENABLED = !CONFIG.STAFF_THREAD_ENABLED;
|
CONFIG.STAFF_THREAD_ENABLED = !CONFIG.STAFF_THREAD_ENABLED;
|
||||||
return interaction.reply({ content: `Staff threads are now **${CONFIG.STAFF_THREAD_ENABLED ? 'enabled' : 'disabled'}**.`, ephemeral: true });
|
return interaction.reply({ content: `Staff threads are now **${CONFIG.STAFF_THREAD_ENABLED ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
if (sub === 'name') {
|
if (sub === 'name') {
|
||||||
const name = interaction.options.getString('thread_name').slice(0, 100);
|
const name = interaction.options.getString('thread_name').slice(0, 100);
|
||||||
CONFIG.STAFF_THREAD_NAME = name;
|
CONFIG.STAFF_THREAD_NAME = name;
|
||||||
return interaction.reply({ content: `Staff thread name set to **${name}**.`, ephemeral: true });
|
return interaction.reply({ content: `Staff thread name set to **${name}**.`, flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
if (sub === 'autorole') {
|
if (sub === 'autorole') {
|
||||||
const enabled = interaction.options.getBoolean('enabled');
|
const enabled = interaction.options.getBoolean('enabled');
|
||||||
CONFIG.STAFF_THREAD_AUTO_ADD_ROLE = enabled;
|
CONFIG.STAFF_THREAD_AUTO_ADD_ROLE = enabled;
|
||||||
return interaction.reply({ content: `Auto-add role to staff thread is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
|
return interaction.reply({ content: `Auto-add role to staff thread is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,28 +391,33 @@ async function handlePinMessages(interaction) {
|
|||||||
const enabled = interaction.options.getBoolean('enabled');
|
const enabled = interaction.options.getBoolean('enabled');
|
||||||
if (sub === 'initial') {
|
if (sub === 'initial') {
|
||||||
CONFIG.PIN_INITIAL_MESSAGE_ENABLED = enabled;
|
CONFIG.PIN_INITIAL_MESSAGE_ENABLED = enabled;
|
||||||
return interaction.reply({ content: `Auto-pin initial message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
|
return interaction.reply({ content: `Auto-pin initial message is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
if (sub === 'escalation') {
|
if (sub === 'escalation') {
|
||||||
CONFIG.PIN_ESCALATION_MESSAGE_ENABLED = enabled;
|
CONFIG.PIN_ESCALATION_MESSAGE_ENABLED = enabled;
|
||||||
return interaction.reply({ content: `Auto-pin escalation message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
|
return interaction.reply({ content: `Auto-pin escalation message is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
if (sub === 'suppress') {
|
if (sub === 'suppress') {
|
||||||
CONFIG.PIN_SUPPRESS_SYSTEM_MESSAGE = enabled;
|
CONFIG.PIN_SUPPRESS_SYSTEM_MESSAGE = enabled;
|
||||||
return interaction.reply({ content: `Suppress pin system message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
|
return interaction.reply({ content: `Suppress pin system message is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleGmailPoll(interaction) {
|
async function handleGmailPoll(interaction) {
|
||||||
const seconds = parseInt(interaction.options.getString('interval'), 10);
|
const requested = parseInt(interaction.options.getString('interval'), 10);
|
||||||
|
// Defense-in-depth: the slash command's addChoices already floors at 30s, but
|
||||||
|
// clamp the resolved ms here too so any future caller (or skewed input) can't
|
||||||
|
// drop below 30s and trip Gmail's per-user quota under sustained load.
|
||||||
|
const ms = Math.max(30000, requested * 1000);
|
||||||
|
const seconds = ms / 1000;
|
||||||
// Lazy require — broccolini-discord re-exports this and we'd otherwise cycle.
|
// Lazy require — broccolini-discord re-exports this and we'd otherwise cycle.
|
||||||
const { setGmailPollInterval } = require('../broccolini-discord');
|
const { setGmailPollInterval } = require('../broccolini-discord');
|
||||||
setGmailPollInterval(seconds * 1000);
|
setGmailPollInterval(ms);
|
||||||
logTicketEvent('Gmail poll interval updated', [
|
logTicketEvent('Gmail poll interval updated', [
|
||||||
{ name: 'Interval', value: `${seconds}s` },
|
{ name: 'Interval', value: `${seconds}s` },
|
||||||
{ name: 'Set by', value: interaction.user.tag }
|
{ name: 'Set by', value: interaction.user.tag }
|
||||||
], interaction).catch(() => {});
|
], interaction).catch(() => {});
|
||||||
return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, ephemeral: true });
|
return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCloseTimer(interaction) {
|
async function handleCloseTimer(interaction) {
|
||||||
@@ -424,13 +427,13 @@ async function handleCloseTimer(interaction) {
|
|||||||
{ name: 'Duration', value: `${seconds}s` },
|
{ name: 'Duration', value: `${seconds}s` },
|
||||||
{ name: 'Set by', value: interaction.user.tag }
|
{ name: 'Set by', value: interaction.user.tag }
|
||||||
], interaction).catch(() => {});
|
], interaction).catch(() => {});
|
||||||
return interaction.reply({ content: `Force-close timer set to ${seconds} seconds.`, ephemeral: true });
|
return interaction.reply({ content: `Force-close timer set to ${seconds} seconds.`, flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCancelClose(interaction) {
|
async function handleCancelClose(interaction) {
|
||||||
const pending = pendingCloses.get(interaction.channel.id);
|
const pending = pendingCloses.get(interaction.channel.id);
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
return interaction.reply({ content: 'No pending close for this channel.', ephemeral: true });
|
return interaction.reply({ content: 'No pending close for this channel.', flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
clearTimeout(pending.timeout);
|
clearTimeout(pending.timeout);
|
||||||
logTicketEvent('Force-close cancelled', [
|
logTicketEvent('Force-close cancelled', [
|
||||||
@@ -439,7 +442,7 @@ async function handleCancelClose(interaction) {
|
|||||||
{ name: 'Original setter', value: pending.username || 'Unknown' }
|
{ name: 'Original setter', value: pending.username || 'Unknown' }
|
||||||
], interaction).catch(() => {});
|
], interaction).catch(() => {});
|
||||||
pendingCloses.delete(interaction.channel.id);
|
pendingCloses.delete(interaction.channel.id);
|
||||||
return interaction.reply({ content: 'Close cancelled.', ephemeral: true });
|
return interaction.reply({ content: 'Close cancelled.', flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleForceClose(interaction) {
|
async function handleForceClose(interaction) {
|
||||||
@@ -447,7 +450,7 @@ async function handleForceClose(interaction) {
|
|||||||
if (!ticket) return;
|
if (!ticket) return;
|
||||||
|
|
||||||
if (pendingCloses.has(interaction.channel.id)) {
|
if (pendingCloses.has(interaction.channel.id)) {
|
||||||
return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true });
|
return interaction.reply({ content: 'A close is already pending for this ticket.', flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
|
|
||||||
const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
|
const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
|
||||||
@@ -529,12 +532,11 @@ async function handleTopic(interaction) {
|
|||||||
if (!ticket) return;
|
if (!ticket) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO(queue-migrate): setTopic bypasses channelQueue — could race a pending rename/send on the same channel.
|
await enqueueTopic(interaction.channel, text);
|
||||||
await interaction.channel.setTopic(text);
|
|
||||||
await interaction.reply('Topic updated successfully.');
|
await interaction.reply('Topic updated successfully.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Topic error:', err);
|
console.error('Topic error:', err);
|
||||||
await interaction.reply({ content: 'Failed to update topic.', ephemeral: true });
|
await interaction.reply({ content: 'Failed to update topic.', flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,7 +561,7 @@ async function handleResponse(interaction) {
|
|||||||
if (interaction.deferred) {
|
if (interaction.deferred) {
|
||||||
await interaction.editReply(errorMsg);
|
await interaction.editReply(errorMsg);
|
||||||
} else {
|
} else {
|
||||||
await interaction.reply({ content: errorMsg, ephemeral: true });
|
await interaction.reply({ content: errorMsg, flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -568,7 +570,7 @@ async function handleResponseSend(interaction) {
|
|||||||
const name = interaction.options.getString('name');
|
const name = interaction.options.getString('name');
|
||||||
const tag = await Tag.findOne({ name }).lean();
|
const tag = await Tag.findOne({ name }).lean();
|
||||||
if (!tag) {
|
if (!tag) {
|
||||||
return interaction.reply({ content: `❌ Tag "${name}" not found.`, ephemeral: true });
|
return interaction.reply({ content: `❌ Tag "${name}" not found.`, flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
|
|
||||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||||
@@ -595,13 +597,13 @@ async function handleResponseCreate(interaction) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await Tag.create({ name, content, createdBy: interaction.user.id });
|
await Tag.create({ name, content, createdBy: interaction.user.id });
|
||||||
await interaction.reply({ content: `✅ Tag "${name}" created successfully.`, ephemeral: true });
|
await interaction.reply({ content: `✅ Tag "${name}" created successfully.`, flags: MessageFlags.Ephemeral });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code === 11000 || err.message?.includes('duplicate')) {
|
if (err.code === 11000 || err.message?.includes('duplicate')) {
|
||||||
await interaction.reply({ content: `❌ Tag "${name}" already exists.`, ephemeral: true });
|
await interaction.reply({ content: `❌ Tag "${name}" already exists.`, flags: MessageFlags.Ephemeral });
|
||||||
} else {
|
} else {
|
||||||
logError('tag-create', err, interaction).catch(() => {});
|
logError('tag-create', err, interaction).catch(() => {});
|
||||||
await interaction.reply({ content: '❌ Failed to create tag.', ephemeral: true });
|
await interaction.reply({ content: '❌ Failed to create tag.', flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -613,13 +615,13 @@ async function handleResponseEdit(interaction) {
|
|||||||
try {
|
try {
|
||||||
const result = await Tag.updateOne({ name }, { $set: { content } });
|
const result = await Tag.updateOne({ name }, { $set: { content } });
|
||||||
if (result.matchedCount === 0) {
|
if (result.matchedCount === 0) {
|
||||||
await interaction.reply({ content: `❌ Tag "${name}" not found.`, ephemeral: true });
|
await interaction.reply({ content: `❌ Tag "${name}" not found.`, flags: MessageFlags.Ephemeral });
|
||||||
} else {
|
} else {
|
||||||
await interaction.reply({ content: `✅ Tag "${name}" updated successfully.`, ephemeral: true });
|
await interaction.reply({ content: `✅ Tag "${name}" updated successfully.`, flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError('tag-edit', err, interaction).catch(() => {});
|
logError('tag-edit', err, interaction).catch(() => {});
|
||||||
await interaction.reply({ content: '❌ Failed to edit tag.', ephemeral: true });
|
await interaction.reply({ content: '❌ Failed to edit tag.', flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,12 +643,12 @@ async function handleResponseDelete(interaction) {
|
|||||||
return interaction.reply({
|
return interaction.reply({
|
||||||
content: `⚠️ Are you sure you want to delete the tag "${name}"? This action cannot be undone.`,
|
content: `⚠️ Are you sure you want to delete the tag "${name}"? This action cannot be undone.`,
|
||||||
components: [confirmRow],
|
components: [confirmRow],
|
||||||
ephemeral: true
|
flags: MessageFlags.Ephemeral
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleResponseList(interaction) {
|
async function handleResponseList(interaction) {
|
||||||
await interaction.deferReply({ ephemeral: true });
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
const tags = await Tag.find().sort({ useCount: -1 }).select('name useCount').lean();
|
const tags = await Tag.find().sort({ useCount: -1 }).select('name useCount').lean();
|
||||||
if (!tags || tags.length === 0) {
|
if (!tags || tags.length === 0) {
|
||||||
@@ -703,7 +705,7 @@ async function handleSignature(interaction) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Signature command error:', err);
|
console.error('Signature command error:', err);
|
||||||
if (!interaction.replied && !interaction.deferred) {
|
if (!interaction.replied && !interaction.deferred) {
|
||||||
await interaction.reply({ content: 'Failed to open signature settings.', ephemeral: true }).catch(() => {});
|
await interaction.reply({ content: 'Failed to open signature settings.', flags: MessageFlags.Ephemeral }).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -740,7 +742,7 @@ async function handleHelp(interaction) {
|
|||||||
])
|
])
|
||||||
.setFooter({ text: 'Click buttons on ticket messages to claim/close' });
|
.setFooter({ text: 'Click buttons on ticket messages to claim/close' });
|
||||||
|
|
||||||
await interaction.reply({ embeds: [embed], ephemeral: true });
|
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePanel(interaction) {
|
async function handlePanel(interaction) {
|
||||||
@@ -761,10 +763,10 @@ async function handlePanel(interaction) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await enqueueSend(channel, { embeds: [embed], components: [row] });
|
await enqueueSend(channel, { embeds: [embed], components: [row] });
|
||||||
await interaction.reply({ content: `Panel created in ${channel}!`, ephemeral: true });
|
await interaction.reply({ content: `Panel created in ${channel}!`, flags: MessageFlags.Ephemeral });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Panel creation error:', err);
|
console.error('Panel creation error:', err);
|
||||||
await interaction.reply({ content: 'Failed to create panel.', ephemeral: true });
|
await interaction.reply({ content: 'Failed to create panel.', flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -815,7 +817,7 @@ function buildPanelButtonRow(panelType) {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
async function handleCreateTicketFromMessage(interaction) {
|
async function handleCreateTicketFromMessage(interaction) {
|
||||||
await interaction.deferReply({ ephemeral: true });
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
const rateLimit = checkTicketCreationRateLimit(interaction.user.id);
|
const rateLimit = checkTicketCreationRateLimit(interaction.user.id);
|
||||||
if (!rateLimit.allowed) {
|
if (!rateLimit.allowed) {
|
||||||
@@ -879,6 +881,7 @@ async function handleCreateTicketFromMessage(interaction) {
|
|||||||
ticketNumber,
|
ticketNumber,
|
||||||
priority: 'normal',
|
priority: 'normal',
|
||||||
lastActivity: now,
|
lastActivity: now,
|
||||||
|
creatorId: message.author.id,
|
||||||
parentCategoryId: parentCategoryIdForTicket
|
parentCategoryId: parentCategoryIdForTicket
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -920,7 +923,7 @@ async function handleCreateTicketFromMessage(interaction) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleViewUserTickets(interaction) {
|
async function handleViewUserTickets(interaction) {
|
||||||
await interaction.deferReply({ ephemeral: true });
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const targetUser = interaction.targetUser;
|
const targetUser = interaction.targetUser;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const { extractRawEmail, isStaff } = require('../utils');
|
|||||||
const { getGmailClient, sendGmailReply } = require('../services/gmail');
|
const { getGmailClient, sendGmailReply } = require('../services/gmail');
|
||||||
const { updateTicketActivity } = require('../services/tickets');
|
const { updateTicketActivity } = require('../services/tickets');
|
||||||
const { getNotifyDm } = require('../services/staffSettings');
|
const { getNotifyDm } = require('../services/staffSettings');
|
||||||
|
const { logError } = require('../services/debugLog');
|
||||||
|
|
||||||
const Ticket = mongoose.model('Ticket');
|
const Ticket = mongoose.model('Ticket');
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ async function handleDiscordReply(m) {
|
|||||||
Ticket.updateOne(
|
Ticket.updateOne(
|
||||||
{ discordThreadId: m.channel.id },
|
{ discordThreadId: m.channel.id },
|
||||||
{ $set: { lastActivity: new Date() } }
|
{ $set: { lastActivity: new Date() } }
|
||||||
).catch(() => {});
|
).catch(err => logError('updateActivity', err).catch(() => {}));
|
||||||
|
|
||||||
// DM the claimer if they have notifydm on and a non-staff user replied.
|
// DM the claimer if they have notifydm on and a non-staff user replied.
|
||||||
if (ticket.claimerId && !isStaffMember && m.author.id !== ticket.claimerId) {
|
if (ticket.claimerId && !isStaffMember && m.author.id !== ticket.claimerId) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* Both handlers/commands.js and handlers/buttons.js use these to avoid
|
* Both handlers/commands.js and handlers/buttons.js use these to avoid
|
||||||
* repeating the lookup-and-defer-and-try-catch pattern across 30+ branches.
|
* repeating the lookup-and-defer-and-try-catch pattern across 30+ branches.
|
||||||
*/
|
*/
|
||||||
|
const { MessageFlags } = require('discord.js');
|
||||||
const { mongoose } = require('../db-connection');
|
const { mongoose } = require('../db-connection');
|
||||||
const { logError } = require('../services/debugLog');
|
const { logError } = require('../services/debugLog');
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ const Ticket = mongoose.model('Ticket');
|
|||||||
async function findTicketForChannel(interaction, missingMessage = 'This channel is not linked to a ticket.') {
|
async function findTicketForChannel(interaction, missingMessage = 'This channel is not linked to a ticket.') {
|
||||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
await interaction.reply({ content: missingMessage, ephemeral: true });
|
await interaction.reply({ content: missingMessage, flags: MessageFlags.Ephemeral });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return ticket;
|
return ticket;
|
||||||
@@ -34,18 +35,18 @@ async function findTicketForChannel(interaction, missingMessage = 'This channel
|
|||||||
* @param {import('discord.js').Interaction} interaction
|
* @param {import('discord.js').Interaction} interaction
|
||||||
* @param {string} verb
|
* @param {string} verb
|
||||||
* @param {() => Promise<void>} fn
|
* @param {() => Promise<void>} fn
|
||||||
* @param {{ ephemeral?: boolean }} [opts]
|
* @param {{ flags?: number }} [opts] - pass `MessageFlags.Ephemeral` for ephemeral defer
|
||||||
*/
|
*/
|
||||||
async function runDeferred(interaction, verb, fn, { ephemeral = false } = {}) {
|
async function runDeferred(interaction, verb, fn, { flags } = {}) {
|
||||||
try {
|
try {
|
||||||
await interaction.deferReply({ ephemeral });
|
await interaction.deferReply(flags ? { flags } : {});
|
||||||
await fn();
|
await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`${verb} error:`, err);
|
console.error(`${verb} error:`, err);
|
||||||
logError(verb, err, interaction).catch(() => {});
|
logError(verb, err, interaction).catch(() => {});
|
||||||
const msg = `Failed to ${verb} this ticket.`;
|
const msg = `Failed to ${verb} this ticket.`;
|
||||||
await interaction.editReply({ content: msg }).catch(() =>
|
await interaction.editReply({ content: msg }).catch(() =>
|
||||||
interaction.followUp({ content: msg, ephemeral: true }).catch(() => {})
|
interaction.followUp({ content: msg, flags: MessageFlags.Ephemeral }).catch(() => {})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const ticketSchema = new mongoose.Schema({
|
|||||||
lastActivity: Date,
|
lastActivity: Date,
|
||||||
welcomeMessageId: String,
|
welcomeMessageId: String,
|
||||||
claimerId: String,
|
claimerId: String,
|
||||||
|
creatorId: String,
|
||||||
parentCategoryId: String,
|
parentCategoryId: String,
|
||||||
pendingDelete: { type: Boolean, default: false }
|
pendingDelete: { type: Boolean, default: false }
|
||||||
});
|
});
|
||||||
|
|||||||
88
scripts/backfill-creatorId.js
Normal file
88
scripts/backfill-creatorId.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* One-shot backfill for Ticket.creatorId on Discord-originated tickets.
|
||||||
|
*
|
||||||
|
* Modal-created tickets (`discord-${ts}-${userId}`): tail segment is the user ID — extract it.
|
||||||
|
* Context-menu tickets (`discord-msg-${ts}-${msgId}`): tail segment is the *message* ID, not the
|
||||||
|
* user ID. Set creatorId = null and let runtime code fall through to the default-name path.
|
||||||
|
* Recovering these would require a Discord API fetch per message, which is unreliable for
|
||||||
|
* already-deleted ticket channels.
|
||||||
|
*
|
||||||
|
* Idempotent: skips tickets that already have creatorId set.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/backfill-creatorId.js # dry-run, prints summary only
|
||||||
|
* node scripts/backfill-creatorId.js --apply # writes
|
||||||
|
*/
|
||||||
|
|
||||||
|
require('dotenv').config();
|
||||||
|
const { connectMongoDB, closeMongoDB, mongoose } = require('../db-connection');
|
||||||
|
|
||||||
|
const APPLY = process.argv.includes('--apply');
|
||||||
|
const MODAL_RE = /^discord-\d+-(\d{17,20})$/;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!process.env.MONGODB_URI) {
|
||||||
|
console.error('MONGODB_URI not set');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
await connectMongoDB(process.env.MONGODB_URI);
|
||||||
|
|
||||||
|
const Ticket = mongoose.model('Ticket');
|
||||||
|
|
||||||
|
const candidates = await Ticket.find({
|
||||||
|
gmailThreadId: /^discord-/,
|
||||||
|
creatorId: { $in: [null, undefined, ''] }
|
||||||
|
}).select('gmailThreadId creatorId').lean();
|
||||||
|
|
||||||
|
let modalHits = 0;
|
||||||
|
let msgSkipped = 0;
|
||||||
|
let unknown = 0;
|
||||||
|
const ops = [];
|
||||||
|
|
||||||
|
for (const t of candidates) {
|
||||||
|
const id = t.gmailThreadId;
|
||||||
|
const modalMatch = id.match(MODAL_RE);
|
||||||
|
if (modalMatch) {
|
||||||
|
modalHits++;
|
||||||
|
ops.push({
|
||||||
|
updateOne: {
|
||||||
|
filter: { _id: t._id },
|
||||||
|
update: { $set: { creatorId: modalMatch[1] } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (id.startsWith('discord-msg-')) {
|
||||||
|
msgSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
unknown++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Scanned ${candidates.length} Discord-originated tickets without creatorId.`);
|
||||||
|
console.log(` Modal-pattern recoverable: ${modalHits}`);
|
||||||
|
console.log(` Context-menu (unrecoverable, leaving null): ${msgSkipped}`);
|
||||||
|
console.log(` Unknown shape: ${unknown}`);
|
||||||
|
|
||||||
|
if (!APPLY) {
|
||||||
|
console.log('\nDry-run only. Re-run with --apply to write changes.');
|
||||||
|
await closeMongoDB();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ops.length === 0) {
|
||||||
|
console.log('Nothing to write.');
|
||||||
|
await closeMongoDB();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await Ticket.bulkWrite(ops, { ordered: false });
|
||||||
|
console.log(`Wrote ${res.modifiedCount} updates.`);
|
||||||
|
await closeMongoDB();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -113,6 +113,81 @@ function enqueueMove(channel, categoryId) {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shares renameChains so a permissionOverwrite mutation serializes with pending
|
||||||
|
// renames/moves on the same channel. Mode 'create' calls
|
||||||
|
// `channel.permissionOverwrites.create(id, perms)`; 'delete' calls
|
||||||
|
// `channel.permissionOverwrites.delete(id)`. No coalescing.
|
||||||
|
function enqueueOverwrite(channel, id, perms, mode = 'create') {
|
||||||
|
let entry = renameChains.get(channel.id);
|
||||||
|
if (!entry) {
|
||||||
|
entry = { chain: Promise.resolve(), pendingName: null };
|
||||||
|
renameChains.set(channel.id, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = entry.chain.catch(() => {}).then(() =>
|
||||||
|
mode === 'delete'
|
||||||
|
? channel.permissionOverwrites.delete(id)
|
||||||
|
: channel.permissionOverwrites.create(id, perms)
|
||||||
|
);
|
||||||
|
entry.chain = next;
|
||||||
|
|
||||||
|
next.catch((err) => {
|
||||||
|
logWarn('overwriteQueue', `Overwrite ${mode} failed for ${channel.name}: ${err && err.message || err}`).catch(() => {});
|
||||||
|
const status = err && err.status;
|
||||||
|
const msg = (err && err.message) || String(err);
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
logError(
|
||||||
|
'overwriteQueue:token/permission',
|
||||||
|
new Error(`${status} channel=${channel.id} target=${id} mode=${mode}: ${msg}`)
|
||||||
|
).catch(() => {});
|
||||||
|
} else if (status === 429) {
|
||||||
|
logError(
|
||||||
|
'overwriteQueue:ratelimited',
|
||||||
|
new Error(`429 channel=${channel.id} target=${id} mode=${mode}: ${msg}`)
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
}).finally(() => {
|
||||||
|
if (renameChains.get(channel.id) === entry && entry.chain === next && entry.pendingName == null) {
|
||||||
|
renameChains.delete(channel.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shares renameChains so setTopic serializes with pending renames/moves.
|
||||||
|
function enqueueTopic(channel, text) {
|
||||||
|
let entry = renameChains.get(channel.id);
|
||||||
|
if (!entry) {
|
||||||
|
entry = { chain: Promise.resolve(), pendingName: null };
|
||||||
|
renameChains.set(channel.id, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = entry.chain.catch(() => {}).then(() => channel.setTopic(text));
|
||||||
|
entry.chain = next;
|
||||||
|
|
||||||
|
next.catch((err) => {
|
||||||
|
logWarn('topicQueue', `Topic set failed for ${channel.name}: ${err && err.message || err}`).catch(() => {});
|
||||||
|
const status = err && err.status;
|
||||||
|
const msg = (err && err.message) || String(err);
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
logError(
|
||||||
|
'topicQueue:token/permission',
|
||||||
|
new Error(`${status} channel=${channel.id}: ${msg}`)
|
||||||
|
).catch(() => {});
|
||||||
|
} else if (status === 429) {
|
||||||
|
logError(
|
||||||
|
'topicQueue:ratelimited',
|
||||||
|
new Error(`429 channel=${channel.id}: ${msg}`)
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
}).finally(() => {
|
||||||
|
if (renameChains.get(channel.id) === entry && entry.chain === next && entry.pendingName == null) {
|
||||||
|
renameChains.delete(channel.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
// Per-channel promise chain for send ordering and to prevent interleaving.
|
// Per-channel promise chain for send ordering and to prevent interleaving.
|
||||||
const sendChains = new Map();
|
const sendChains = new Map();
|
||||||
|
|
||||||
@@ -157,4 +232,4 @@ function enqueueDelete(channel) {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { enqueueRename, enqueueMove, enqueueSend, enqueueDelete };
|
module.exports = { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend, enqueueDelete };
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ async function pinMessage(message, client) {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code === 30003) {
|
if (err.code === 30003) {
|
||||||
await logWarn('pinMessage', `Max pins reached in channel #${message.channel.name} — could not pin message.`, client).catch(() => {});
|
logWarn('pinMessage', `Max pins reached in channel #${message.channel.name} — could not pin message.`, client).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
await logWarn('pinMessage', `Failed to pin message in #${message.channel.name}: ${err.message}`, client).catch(() => {});
|
logWarn('pinMessage', `Failed to pin message in #${message.channel.name}: ${err.message}`, client).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ async function createStaffThread(channel, client) {
|
|||||||
if (err.code === 50024 || err.code === 160004) {
|
if (err.code === 50024 || err.code === 160004) {
|
||||||
logWarn('staffThread', `Cannot create private thread in ${channel.name}: server may lack Community features or required boost level (code ${err.code}).`).catch(() => {});
|
logWarn('staffThread', `Cannot create private thread in ${channel.name}: server may lack Community features or required boost level (code ${err.code}).`).catch(() => {});
|
||||||
}
|
}
|
||||||
await logError('staffThread:create', err, null, client).catch(() => {});
|
logError('staffThread:create', err, null, client).catch(() => {});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,7 @@ async function addRoleMembersToThread(thread, guild, client) {
|
|||||||
await new Promise(r => setTimeout(r, 300));
|
await new Promise(r => setTimeout(r, 300));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await logError('staffThread:addMembers', err, null, client).catch(() => {});
|
logError('staffThread:addMembers', err, null, client).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,12 @@ function toDiscordSafeName(str) {
|
|||||||
*/
|
*/
|
||||||
async function resolveCreatorNickname(guild, ticket) {
|
async function resolveCreatorNickname(guild, ticket) {
|
||||||
if (ticket.gmailThreadId.startsWith('discord-')) {
|
if (ticket.gmailThreadId.startsWith('discord-')) {
|
||||||
const creatorUserId = ticket.gmailThreadId.split('-').pop();
|
// Prefer ticket.creatorId (stored on creation). Legacy fallback parses the
|
||||||
|
// tail segment, which is correct for discord-${ts}-${userId} but returns
|
||||||
|
// the message ID for discord-msg-${ts}-${msgId} — skip the parse for those.
|
||||||
|
const creatorUserId = ticket.creatorId
|
||||||
|
|| (ticket.gmailThreadId.startsWith('discord-msg-') ? null : ticket.gmailThreadId.split('-').pop());
|
||||||
|
if (!creatorUserId) return getSenderLocal(ticket.senderEmail);
|
||||||
try {
|
try {
|
||||||
const member = await guild.members.fetch(creatorUserId);
|
const member = await guild.members.fetch(creatorUserId);
|
||||||
return member.displayName;
|
return member.displayName;
|
||||||
@@ -305,14 +310,16 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
|
|||||||
|
|
||||||
await sendTicketClosedEmail(ticket, 'Auto-Close System', null);
|
await sendTicketClosedEmail(ticket, 'Auto-Close System', null);
|
||||||
|
|
||||||
setTimeout(() => {
|
// Lazy require — broccolini-discord re-exports trackTimeout; cycle-safe.
|
||||||
|
const { trackTimeout } = require('../broccolini-discord');
|
||||||
|
trackTimeout(setTimeout(() => {
|
||||||
enqueueDelete(channel).then(() => {
|
enqueueDelete(channel).then(() => {
|
||||||
withRetry(() => Ticket.updateOne(
|
withRetry(() => Ticket.updateOne(
|
||||||
{ gmailThreadId: ticket.gmailThreadId },
|
{ gmailThreadId: ticket.gmailThreadId },
|
||||||
{ $unset: { pendingDelete: '' } }
|
{ $unset: { pendingDelete: '' } }
|
||||||
)).catch(() => {});
|
)).catch(() => {});
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, 5000);
|
}, 5000));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error);
|
console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error);
|
||||||
|
|||||||
Reference in New Issue
Block a user