Compare commits
9 Commits
3c13e55dad
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a388d99fdf | |||
| 3212004fc9 | |||
| a565450e2d | |||
| 837fd10984 | |||
| 2152544d09 | |||
| c79463fc2a | |||
| e8e114e4ad | |||
| 452f005aea | |||
| 76279b703a |
@@ -11,6 +11,7 @@ const { mongoose } = require('./db-connection');
|
||||
// Handlers
|
||||
const { handleButton, handleTicketModal } = require('./handlers/buttons');
|
||||
const { handleCommand, handleContextMenu, handleAutocomplete } = require('./handlers/commands');
|
||||
const { requireStaffRole } = require('./handlers/commands/helpers');
|
||||
const { handleDiscordReply } = require('./handlers/messages');
|
||||
|
||||
// Services & jobs
|
||||
@@ -110,6 +111,9 @@ client.on('interactionCreate', async interaction => {
|
||||
}
|
||||
|
||||
if (interaction.isModalSubmit() && interaction.customId.startsWith('signature_modal_')) {
|
||||
// Staff-only: /signature shows this modal, which is gated; double-gate the
|
||||
// submit path in case an attacker crafts the submission directly.
|
||||
if (await requireStaffRole(interaction)) return;
|
||||
// Handle signature modal submit
|
||||
try {
|
||||
const valediction = interaction.fields.getTextInputValue('valediction');
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
*/
|
||||
const {
|
||||
ChannelType,
|
||||
|
||||
EmbedBuilder
|
||||
EmbedBuilder,
|
||||
PermissionFlagsBits
|
||||
} = require('discord.js');
|
||||
const { mongoose, withRetry } = require('./db-connection');
|
||||
const { CONFIG } = require('./config');
|
||||
@@ -150,7 +150,22 @@ async function findOrCreateTicketChannel(guild, parsed, number) {
|
||||
const channel = await guild.channels.create({
|
||||
name: chanName,
|
||||
type: ChannelType.GuildText,
|
||||
parent: parentCategoryId
|
||||
parent: parentCategoryId,
|
||||
// Email tickets have no Discord creator — the customer is reachable
|
||||
// only by email. So the only per-channel allow is the staff role; we
|
||||
// still explicitly deny @everyone in case the category permissions
|
||||
// are ever misconfigured to grant View Channel server-wide.
|
||||
permissionOverwrites: [
|
||||
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||||
...(CONFIG.ROLE_ID_TO_PING ? [{
|
||||
id: CONFIG.ROLE_ID_TO_PING,
|
||||
allow: [
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.SendMessages,
|
||||
PermissionFlagsBits.ReadMessageHistory
|
||||
]
|
||||
}] : [])
|
||||
]
|
||||
});
|
||||
return { channel, parentCategoryId };
|
||||
} catch (createErr) {
|
||||
|
||||
@@ -34,6 +34,7 @@ const { addMemberToStaffThread, createStaffThread } = require('../services/staff
|
||||
const { pinMessage } = require('../services/pinMessage');
|
||||
const { logError, logTicketEvent } = require('../services/debugLog');
|
||||
const { findTicketForChannel, runDeferred } = require('./sharedHelpers');
|
||||
const { requireStaffRole } = require('./commands/helpers');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
const Transcript = mongoose.model('Transcript');
|
||||
@@ -369,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) {
|
||||
@@ -706,12 +707,15 @@ async function postTicketWelcomeEmbeds(channel, interaction, email, game, descri
|
||||
// Dispatch tables
|
||||
// ============================================================
|
||||
|
||||
/** Buttons that don't depend on a ticket-bound channel. */
|
||||
/**
|
||||
* Public-facing buttons that don't require a staff role: the panel buttons
|
||||
* that any member uses to open a ticket. Customer-facing entry points stay
|
||||
* here. cancel_delete_tag is staff-only and gated separately in handleButton.
|
||||
*/
|
||||
const FREE_BUTTON_HANDLERS = {
|
||||
open_ticket: handleOpenTicketModal,
|
||||
open_ticket_thread: handleOpenTicketModal,
|
||||
open_ticket_channel: handleOpenTicketModal,
|
||||
cancel_delete_tag: handleTagDeleteCancel
|
||||
open_ticket_channel: handleOpenTicketModal
|
||||
};
|
||||
|
||||
/** Buttons that fire inside a ticket channel. The dispatcher does the lookup. */
|
||||
@@ -728,20 +732,52 @@ 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;
|
||||
|
||||
// Tag-delete confirm has a dynamic id (`confirm_delete_tag::<name>`).
|
||||
// Mutates the Tag collection — staff only.
|
||||
if (customId.startsWith('confirm_delete_tag::')) {
|
||||
if (await requireStaffRole(interaction)) return;
|
||||
return handleTagDeleteConfirm(interaction);
|
||||
}
|
||||
|
||||
// Tag-delete cancel: paired with the staff-only delete flow; gate to keep
|
||||
// the button surface consistent (non-staff can't reach the dialog anyway).
|
||||
if (customId === 'cancel_delete_tag') {
|
||||
if (await requireStaffRole(interaction)) return;
|
||||
return handleTagDeleteCancel(interaction);
|
||||
}
|
||||
|
||||
// FREE_BUTTON_HANDLERS are the public-facing panel buttons (open_ticket*).
|
||||
// Customers/members must be able to click these to open a ticket.
|
||||
const freeHandler = FREE_BUTTON_HANDLERS[customId];
|
||||
if (freeHandler) return freeHandler(interaction);
|
||||
|
||||
const ticketHandler = TICKET_BUTTON_HANDLERS[customId];
|
||||
if (!ticketHandler) 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,
|
||||
'This channel is not linked to a ticket, or the ticket could not be found.'
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,11 +321,11 @@ const CONTEXT_MENU_HANDLERS = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Slash-command dispatcher. /help is open to everyone; everything else
|
||||
* requires the staff role.
|
||||
* Slash-command dispatcher. Every command is staff-only — including /help,
|
||||
* which previously bypassed the role check.
|
||||
*/
|
||||
async function handleCommand(interaction) {
|
||||
if (interaction.commandName !== 'help' && (await requireStaffRole(interaction))) return;
|
||||
if (await requireStaffRole(interaction)) return;
|
||||
const handler = COMMAND_HANDLERS[interaction.commandName];
|
||||
if (handler) await handler(interaction);
|
||||
}
|
||||
|
||||
@@ -25,10 +25,13 @@ async function executeRename(channel, entry) {
|
||||
// (403), or no token configured — fall back to the primary Discord.js client.
|
||||
// Non-fallback errors rethrow so enqueueRename's catch can classify/log.
|
||||
if (err && err.fallback === true && channel && typeof channel.setName === 'function') {
|
||||
logWarn(
|
||||
'renameQueue',
|
||||
`secondary-bot ${err.status ?? 'unavailable'}; falling back to primary channel=${channel.id}`
|
||||
).catch(() => {});
|
||||
// Local log only; discord.js's REST client transparently handles 429s
|
||||
// on the primary fallback, so this used to post a paired warning to
|
||||
// the debug channel for every secondary-bot quota event with no
|
||||
// operator action required. Keep the visibility in container logs.
|
||||
console.warn(
|
||||
`[renameQueue] secondary-bot ${err.status ?? 'unavailable'}; falling back to primary channel=${channel.id}`
|
||||
);
|
||||
await channel.setName(currentName);
|
||||
} else {
|
||||
throw err;
|
||||
@@ -80,6 +83,12 @@ function enqueueRename(channel, newName) {
|
||||
|
||||
// Shares renameChains so a move+rename pair on the same channel executes in
|
||||
// call order. No coalescing: every move is a distinct chain link.
|
||||
//
|
||||
// lockPermissions: false preserves the channel's existing permission overwrites
|
||||
// across the parent change. With the default (true), Discord re-syncs the
|
||||
// channel's overwrites to match the new category and wipes per-user grants —
|
||||
// in practice that kicked the ticket creator and any /add'd users off the
|
||||
// channel on every escalate / de-escalate / /move.
|
||||
function enqueueMove(channel, categoryId) {
|
||||
let entry = renameChains.get(channel.id);
|
||||
if (!entry) {
|
||||
@@ -87,7 +96,7 @@ function enqueueMove(channel, categoryId) {
|
||||
renameChains.set(channel.id, entry);
|
||||
}
|
||||
|
||||
const next = entry.chain.catch(() => {}).then(() => channel.setParent(categoryId, { lockPermissions: true }));
|
||||
const next = entry.chain.catch(() => {}).then(() => channel.setParent(categoryId, { lockPermissions: false }));
|
||||
entry.chain = next;
|
||||
|
||||
next.catch((err) => {
|
||||
|
||||
@@ -41,7 +41,10 @@ async function renameChannel(channelId, newName) {
|
||||
if (res.status === 429) {
|
||||
const retryAfterSec = (body && typeof body === 'object' && body.retry_after) || null;
|
||||
const retryAfterMs = retryAfterSec != null ? Math.ceil(Number(retryAfterSec) * 1000) : null;
|
||||
logWarn('renamer', `429 rename channel=${channelId} retry_after=${retryAfterSec}`).catch(() => {});
|
||||
// Local log only; the channelQueue fallback path handles recovery
|
||||
// transparently via discord.js's built-in 429 retry. Posting these to
|
||||
// the debug channel was non-actionable noise.
|
||||
console.warn(`[renamer] 429 rename channel=${channelId} retry_after=${retryAfterSec}`);
|
||||
|
||||
// Respect retry_after up to 2000ms; otherwise fail over immediately.
|
||||
if (retryAfterMs != null && retryAfterMs > 0 && retryAfterMs <= 2000) {
|
||||
|
||||
Reference in New Issue
Block a user