Compare commits
4 Commits
2152544d09
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a388d99fdf | |||
| 3212004fc9 | |||
| a565450e2d | |||
| 837fd10984 |
@@ -370,7 +370,7 @@ async function handleEscalateButton(interaction, ticket) {
|
||||
});
|
||||
}
|
||||
|
||||
await runDeferred(interaction, 'escalate', () => runEscalation(interaction, ticket, tier, null));
|
||||
await runDeferred(interaction, 'escalate', () => runEscalation(interaction, ticket, tier));
|
||||
}
|
||||
|
||||
async function handleDeescalateButton(interaction, ticket) {
|
||||
@@ -732,6 +732,20 @@ const TICKET_BUTTON_HANDLERS = {
|
||||
deescalate_ticket: handleDeescalateButton
|
||||
};
|
||||
|
||||
/**
|
||||
* TICKET_BUTTON_HANDLERS entries that any user with channel access may
|
||||
* invoke — not just staff. Ticket creators and /add'd users get to close
|
||||
* their own ticket (with the 60s countdown still in place) and cancel a
|
||||
* pending close. Claim/escalate/de-escalate stay staff-only.
|
||||
*/
|
||||
const PUBLIC_TICKET_BUTTONS = new Set([
|
||||
'close_ticket',
|
||||
'confirm_close',
|
||||
'confirm_close_with_email',
|
||||
'confirm_close_no_email',
|
||||
'cancel_close'
|
||||
]);
|
||||
|
||||
async function handleButton(interaction) {
|
||||
const { customId } = interaction;
|
||||
|
||||
@@ -757,13 +771,12 @@ async function handleButton(interaction) {
|
||||
const ticketHandler = TICKET_BUTTON_HANDLERS[customId];
|
||||
if (!ticketHandler) return;
|
||||
|
||||
// Every TICKET_BUTTON_HANDLERS entry mutates ticket state
|
||||
// (claim/close/confirm_close*/cancel_close/escalate*/deescalate). The slash
|
||||
// command dispatcher in handlers/commands/index.js gates these via
|
||||
// requireStaffRole; the button dispatcher must do the same — non-staff
|
||||
// members with view access to the ticket channel (creator, /add'd users)
|
||||
// could otherwise click Claim, Escalate, Close, etc.
|
||||
if (await requireStaffRole(interaction)) return;
|
||||
// Claim / escalate / de-escalate mutate staff-owned ticket state and stay
|
||||
// staff-only. Close-related buttons (close_ticket, confirm_close*,
|
||||
// cancel_close) are public so a ticket creator can close their own ticket;
|
||||
// the 60s force-close countdown still applies, and the cancel button is
|
||||
// intentionally visible to anyone in the channel so any party can abort.
|
||||
if (!PUBLIC_TICKET_BUTTONS.has(customId) && (await requireStaffRole(interaction))) return;
|
||||
|
||||
const ticket = await findTicketForChannel(
|
||||
interaction,
|
||||
|
||||
@@ -23,7 +23,7 @@ const Ticket = mongoose.model('Ticket');
|
||||
* Run escalation to a target tier (1 = tier 2, 2 = tier 3). Caller must
|
||||
* validate ticket and currentTier < nextTier, and have already deferred.
|
||||
*/
|
||||
async function runEscalation(interaction, ticket, nextTier, reason) {
|
||||
async function runEscalation(interaction, ticket, nextTier) {
|
||||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||||
const categoryId = nextTier === 1
|
||||
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
|
||||
@@ -87,7 +87,7 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
||||
try {
|
||||
const escalatorName = interaction.member?.displayName || interaction.user.username;
|
||||
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
|
||||
const emailBody = `${escalatorName} escalated this ticket to ${tierLabel}.${reason ? `\n\nReason: ${reason}` : ''}`;
|
||||
const emailBody = `${escalatorName} escalated this ticket to ${tierLabel}.`;
|
||||
await sendTicketNotificationEmail(ticket, null, emailBody, interaction.user.id);
|
||||
} catch (emailErr) {
|
||||
console.error('Escalation email failed (non-fatal):', emailErr.message);
|
||||
@@ -108,7 +108,7 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
||||
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
|
||||
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
|
||||
await enqueueSend(logChan,
|
||||
`${ticketType} ticket ${interaction.channel} escalated to ${tierLabel} by ${interaction.user.tag}.\nReason: ${reason}`
|
||||
`${ticketType} ticket ${interaction.channel} escalated to ${tierLabel} by ${interaction.user.tag}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -164,7 +164,6 @@ async function runDeescalation(interaction, ticket) {
|
||||
}
|
||||
|
||||
async function handleEscalate(interaction) {
|
||||
const reason = null;
|
||||
const level = interaction.options.getString('level');
|
||||
const nextTier = level === '3' ? 2 : 1;
|
||||
|
||||
@@ -192,7 +191,7 @@ async function handleEscalate(interaction) {
|
||||
}
|
||||
|
||||
await runDeferred(interaction, 'escalate', () =>
|
||||
runEscalation(interaction, ticket, nextTier, reason)
|
||||
runEscalation(interaction, ticket, nextTier)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
const { EmbedBuilder, MessageFlags } = require('discord.js');
|
||||
const { mongoose } = require('../../db-connection');
|
||||
const { CONFIG } = require('../../config');
|
||||
const { isStaff } = require('../../utils');
|
||||
const { setNotifyDm } = require('../../services/staffSettings');
|
||||
const { enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../../services/channelQueue');
|
||||
const { logTicketEvent } = require('../../services/debugLog');
|
||||
const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets');
|
||||
const { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../../services/channelQueue');
|
||||
const { logError, logTicketEvent } = require('../../services/debugLog');
|
||||
const { findTicketForChannel } = require('../sharedHelpers');
|
||||
|
||||
const { requireStaffRole, fetchLoggingChannel } = require('./helpers');
|
||||
@@ -54,16 +56,20 @@ async function handleAdd(interaction) {
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
// Defer up front: enqueueOverwrite serializes behind any pending rename/move
|
||||
// on this channel and can exceed Discord's 3s interaction-token window.
|
||||
await interaction.deferReply();
|
||||
|
||||
try {
|
||||
await enqueueOverwrite(interaction.channel, user.id, {
|
||||
ViewChannel: true,
|
||||
SendMessages: true,
|
||||
ReadMessageHistory: true
|
||||
});
|
||||
await interaction.reply({ content: `Added ${user} to this ticket.`, allowedMentions: { parse: ['users'] } });
|
||||
await interaction.editReply({ content: `Added ${user} to this ticket.`, allowedMentions: { parse: ['users'] } });
|
||||
} catch (err) {
|
||||
console.error('Add user error:', err);
|
||||
await interaction.reply({ content: 'Failed to add user.', flags: MessageFlags.Ephemeral });
|
||||
await interaction.editReply({ content: 'Failed to add user.' }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,12 +78,15 @@ async function handleRemove(interaction) {
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
// Defer up front — same reason as handleAdd.
|
||||
await interaction.deferReply();
|
||||
|
||||
try {
|
||||
await enqueueOverwrite(interaction.channel, user.id, null, 'delete');
|
||||
await interaction.reply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } });
|
||||
await interaction.editReply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } });
|
||||
} catch (err) {
|
||||
console.error('Remove user error:', err);
|
||||
await interaction.reply({ content: 'Failed to remove user.', flags: MessageFlags.Ephemeral });
|
||||
await interaction.editReply({ content: 'Failed to remove user.' }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,23 +96,54 @@ async function handleTransfer(interaction) {
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
const staffRoleId = CONFIG.ROLE_TO_PING_ID;
|
||||
const guildMember = await interaction.guild.members.fetch(member.id).catch(() => null);
|
||||
// Cache-first member resolution; falls back to a fetch if not in cache.
|
||||
// GuildMembers intent keeps the cache warm in normal operation.
|
||||
const guildMember = interaction.guild.members.cache.get(member.id)
|
||||
|| await interaction.guild.members.fetch(member.id).catch(() => null);
|
||||
|
||||
if (!guildMember || !guildMember.roles.cache.has(staffRoleId)) {
|
||||
return interaction.reply({ content: 'The target member must have the staff role.', flags: MessageFlags.Ephemeral });
|
||||
// Reject self-transfers and bots; require the target to satisfy isStaff(),
|
||||
// which covers ROLE_ID_TO_PING + ADDITIONAL_STAFF_ROLES — the same staff
|
||||
// definition used by every other gate in the bot. The previous check only
|
||||
// looked at ROLE_TO_PING_ID, missing additional staff roles.
|
||||
if (!guildMember || guildMember.user.bot || !isStaff(guildMember)) {
|
||||
return interaction.reply({
|
||||
content: 'The target member must have the staff role.',
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
if (guildMember.id === interaction.user.id) {
|
||||
return interaction.reply({
|
||||
content: 'You cannot transfer the ticket to yourself.',
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
|
||||
// Defer before the DB write + rename so the interaction token survives.
|
||||
await interaction.deferReply();
|
||||
|
||||
try {
|
||||
const claimerLabel = guildMember.displayName || guildMember.user.username;
|
||||
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { claimedBy: claimerLabel } }
|
||||
{ $set: { claimedBy: claimerLabel, claimerId: guildMember.id } }
|
||||
);
|
||||
ticket.claimedBy = claimerLabel;
|
||||
ticket.claimerId = guildMember.id;
|
||||
|
||||
// Rename the channel to reflect the new claimer — mirrors the /claim
|
||||
// button flow (applyClaim in handlers/buttons.js). Picks the new
|
||||
// claimer's emoji from STAFF_EMOJIS and uses the escalated-claimed
|
||||
// variant when tier >= 1.
|
||||
const claimerEmoji = CONFIG.STAFF_EMOJIS[guildMember.id] || CONFIG.CLAIMER_EMOJI_FALLBACK;
|
||||
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
|
||||
const tier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
const state = tier >= 1 ? 'escalated-claimed' : 'claimed';
|
||||
enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname, claimerEmoji))
|
||||
.catch(err => logError('rename', err).catch(() => {}));
|
||||
|
||||
// `reason` is staff-supplied freeform text; gate to user pings so @everyone in it can't mass-ping.
|
||||
await interaction.reply({
|
||||
await interaction.editReply({
|
||||
content: `Ticket transferred to ${member} by ${interaction.user}.\nReason: ${reason}`,
|
||||
allowedMentions: { parse: ['users'] }
|
||||
});
|
||||
@@ -117,7 +157,7 @@ async function handleTransfer(interaction) {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Transfer error:', err);
|
||||
await interaction.reply({ content: 'Failed to transfer ticket.', flags: MessageFlags.Ephemeral });
|
||||
await interaction.editReply({ content: 'Failed to transfer ticket.' }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,9 +166,13 @@ async function handleMove(interaction) {
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
// Defer up front — enqueueMove serializes behind any pending rename and
|
||||
// setParent itself can take a moment on busy channels.
|
||||
await interaction.deferReply();
|
||||
|
||||
try {
|
||||
await enqueueMove(interaction.channel, category.id);
|
||||
await interaction.reply(`Moved ticket to **${category.name}**.`);
|
||||
await interaction.editReply(`Moved ticket to **${category.name}**.`);
|
||||
|
||||
const logChan = await fetchLoggingChannel(interaction.client);
|
||||
if (logChan) {
|
||||
@@ -138,7 +182,7 @@ async function handleMove(interaction) {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Move error:', err);
|
||||
await interaction.reply({ content: 'Failed to move ticket.', flags: MessageFlags.Ephemeral });
|
||||
await interaction.editReply({ content: 'Failed to move ticket.' }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,12 +191,15 @@ async function handleTopic(interaction) {
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
// Defer up front — enqueueTopic serializes behind any pending rename/move.
|
||||
await interaction.deferReply();
|
||||
|
||||
try {
|
||||
await enqueueTopic(interaction.channel, text);
|
||||
await interaction.reply('Topic updated successfully.');
|
||||
await interaction.editReply('Topic updated successfully.');
|
||||
} catch (err) {
|
||||
console.error('Topic error:', err);
|
||||
await interaction.reply({ content: 'Failed to update topic.', flags: MessageFlags.Ephemeral });
|
||||
await interaction.editReply({ content: 'Failed to update topic.' }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user