From 3212004fc962ef057d145890cb1bed9960ed9bac Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sun, 24 May 2026 05:02:59 +0000 Subject: [PATCH] /transfer: rename the channel + fix 10062 Unknown interaction errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real bugs in handleTransfer plus a class issue across all the channel-mod handlers. /transfer didn't rename handleTransfer set claimedBy but never called enqueueRename, so the channel name stayed at whatever the previous claimer left it as. /claim (applyClaim in handlers/buttons.js) does the rename via makeTicketName + STAFF_EMOJIS; /transfer now does the same, plus writes claimerId (was only writing claimedBy). Uses 'escalated-claimed' state when tier >= 1, 'claimed' otherwise. DiscordAPIError 10062 (Unknown interaction) handleAdd / handleRemove / handleTransfer / handleMove / handleTopic all called interaction.reply() at the end after awaiting one or more channelQueue ops. Those ops serialize behind any pending rename or move on the same channel — easily exceeding Discord's 3s interaction- token window. The reply then 404s with code 10062. Production logs showed handleRemove failing this way (the visible 'Remove user error: DiscordAPIError[10062]' lines); transfer had the same pattern. Switch each handler to deferReply() up front + editReply() at the end + editReply() in the catch (with .catch(() => {}) to swallow the rare case where even the deferred reply context is gone). handleTransfer keeps the up-front isStaff role check as a reply() because that path is synchronous and the token is fresh. --- handlers/commands/index.js | 57 +++++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/handlers/commands/index.js b/handlers/commands/index.js index 5546932..de7fe2e 100644 --- a/handlers/commands/index.js +++ b/handlers/commands/index.js @@ -18,8 +18,9 @@ const { EmbedBuilder, MessageFlags } = require('discord.js'); const { mongoose } = require('../../db-connection'); const { CONFIG } = require('../../config'); 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 +55,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 +77,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(() => {}); } } @@ -94,16 +102,32 @@ async function handleTransfer(interaction) { return interaction.reply({ content: 'The target member must have the staff role.', 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 +141,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 +150,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 +166,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 +175,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(() => {}); } }