Compare commits

..

2 Commits

Author SHA1 Message Date
a388d99fdf /transfer: validate target via isStaff() — covers ADDITIONAL_STAFF_ROLES
The transfer-target check previously matched only against
CONFIG.ROLE_TO_PING_ID, so a member with one of
CONFIG.ADDITIONAL_STAFF_ROLES (a recognized staff role everywhere else
in the bot, including requireStaffRole and the messages.js claimer-DM
path) was rejected as a transfer target. Switch to isStaff() so the
transfer-target gate matches the rest of the codebase's staff
definition.

Also:
- Reject bots as transfer targets (guildMember.user.bot).
- Reject self-transfer (transferring to interaction.user.id) — the
  rename + DB write would no-op but the log line claimed a transfer
  that didn't happen.
- Resolve the target member cache-first to avoid an unnecessary REST
  round-trip when the GuildMembers intent has the user cached.
2026-05-24 05:04:40 +00:00
3212004fc9 /transfer: rename the channel + fix 10062 Unknown interaction errors
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.
2026-05-24 05:02:59 +00:00

View File

@@ -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(() => {});
}
}