diff --git a/handlers/buttons.js b/handlers/buttons.js index 0bee532..c408202 100644 --- a/handlers/buttons.js +++ b/handlers/buttons.js @@ -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,