rename path: fix env-var mismatch, gut canRename gate, add primary-bot fallback on 401/403/429

- secondary rename-bot token was set as RENAME_TOKEN in .env but utils/renamer.js reads RENAMER_BOT; silently no-op'd every rename (host .env renamed separately)
- services/tickets.js canRename gutted to an always-ok shim; Mongo 2/10min per-channel gate is redundant since renames flow through RENAMER_BOT's own bucket. Ticket.renameCount / renameWindowStart remain as orphan fields (no migration)
- handlers/buttons.js + commands.js: drop the four "Channel renamed too quickly" else-branches and the rename-countdown label suffix; replace .catch(() => {}) with .catch(err => logError('rename', err)...)
- services/channelQueue.js: executeRename falls back to channel.setName(currentName) when renamer throws err.fallback === true (401/403/429); classifies non-fallback errors as renameQueue:token/permission (401/403) or renameQueue:secondary-bot ratelimited (429)
- utils/renamer.js: on 401/403 throw err.fallback=true immediately; on 429 respect retry_after up to 2000ms then throw err.fallback=true
- docs: align CLAUDE.md, docs/api/DISCORD_API_VALIDATION.md, docs/architecture/CRITICAL_FILES_AND_HOW_IT_WORKS.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 15:56:06 +00:00
parent fcce7c3e86
commit d73422555d
8 changed files with 131 additions and 127 deletions

View File

@@ -16,7 +16,7 @@ const {
} = require('discord.js');
const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config');
const { canRename, makeTicketName, resolveCreatorNickname, minutesFromMs, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit, getSenderLocal, toDiscordSafeName } = require('../services/tickets');
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit, getSenderLocal, toDiscordSafeName } = require('../services/tickets');
const { sendTicketClosedEmail } = require('../services/gmail');
const { getTicketActionRow } = require('../utils/ticketComponents');
const { sanitizeEmbedText, truncateEmbedDescription, truncateEmbedField, enforceEmbedLimit } = require('../utils');
@@ -26,6 +26,7 @@ const { runEscalation, runDeescalation } = require('./commands');
const { trackInteraction, trackError } = require('./analytics');
const { pendingCloses } = require('./pendingCloses');
const { increment } = require('../services/patternStore');
const { logError } = require('../services/debugLog');
const Ticket = mongoose.model('Ticket');
const Transcript = mongoose.model('Transcript');
@@ -348,23 +349,11 @@ async function handleClaim(interaction, ticket) {
const claimerEmoji = CONFIG.STAFF_EMOJIS.get(interaction.user.id) || CONFIG.CLAIMER_EMOJI_FALLBACK;
const creatorNickname = await resolveCreatorNickname(guild, freshTicket);
const renameInfo = await canRename(freshTicket);
if (renameInfo.ok) {
const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed';
const newName = makeTicketName(state, freshTicket, creatorNickname, claimerEmoji);
enqueueRename(interaction.channel, newName).catch(() => {});
} else {
const unlockAtMs = Date.now() + renameInfo.waitMs;
const unlockAtUnix = Math.floor(unlockAtMs / 1000);
await enqueueSend(interaction.channel,
`Channel renamed too quickly. Try again <t:${unlockAtUnix}:R>.`
);
}
const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed';
const newName = makeTicketName(state, freshTicket, creatorNickname, claimerEmoji);
enqueueRename(interaction.channel, newName).catch(err => logError('rename', err).catch(() => {}));
const baseLabel = `Unclaim (${claimerLabel})`;
const label = renameInfo.ok
? baseLabel
: `${baseLabel} rename in ${minutesFromMs(renameInfo.waitMs)}m`;
const label = `Unclaim (${claimerLabel})`;
btnClose
.setCustomId('close_ticket')
@@ -404,16 +393,7 @@ async function handleClaim(interaction, ticket) {
const creatorNicknameUnclaim = await resolveCreatorNickname(guild, freshTicket);
const unclaimState = (freshTicket.escalationTier ?? 0) >= 1 ? 'escalated' : 'unclaimed';
const renameInfo = await canRename(freshTicket);
if (renameInfo.ok) {
enqueueRename(interaction.channel, makeTicketName(unclaimState, freshTicket, creatorNicknameUnclaim)).catch(() => {});
} else {
const unlockAtMs = Date.now() + renameInfo.waitMs;
const unlockAtUnix = Math.floor(unlockAtMs / 1000);
await enqueueSend(interaction.channel,
`Channel renamed too quickly. Try again <t:${unlockAtUnix}:R>.`
);
}
enqueueRename(interaction.channel, makeTicketName(unclaimState, freshTicket, creatorNicknameUnclaim)).catch(err => logError('rename', err).catch(() => {}));
btnClose
.setCustomId('close_ticket')
@@ -731,8 +711,9 @@ async function handleTicketModal(interaction) {
const actionRow = getTicketActionRow({ escalationTier: 0 });
enforceEmbedLimit([welcomeEmbed, infoEmbed, resourcesEmbed]);
let welcomeMsg;
try {
const welcomeMsg = await enqueueSend(channel, {
welcomeMsg = await enqueueSend(channel, {
content: `Hey There ${interaction.user} 🥦`,
embeds: [welcomeEmbed, infoEmbed, resourcesEmbed],
components: [actionRow]

View File

@@ -13,14 +13,14 @@ const {
const { mongoose } = require('../db-connection');
const { CONFIG, TICKET_TAGS } = require('../config');
const { getPriorityEmoji, getPriorityColor, replaceVariables, escapeRegex } = require('../utils');
const { canRename, makeTicketName, resolveCreatorNickname, getSenderLocal, toDiscordSafeName, getOrCreateTicketCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
const { makeTicketName, resolveCreatorNickname, getSenderLocal, toDiscordSafeName, getOrCreateTicketCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
const { sendTicketNotificationEmail } = require('../services/gmail');
const { getTicketActionRow } = require('../utils/ticketComponents');
const { getEmailRouting } = require('../services/guildSettings');
const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channelQueue');
const { setNotifyDm } = require('../services/staffSettings');
const { trackInteraction, trackError, getAnalyticsSummary } = require('./analytics');
const { logTicketEvent, logSecurity } = require('../services/debugLog');
const { logTicketEvent, logSecurity, logError } = require('../services/debugLog');
const { handleAccountInfoCommand } = require('./accountinfo');
const { handleSetupCommand } = require('./setup');
const { pendingCloses } = require('./pendingCloses');
@@ -87,17 +87,8 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
if (ticket.game) increment(`staff_game_escalations:${interaction.user.id}`, ticket.game, 'week');
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const renameInfo = await canRename(ticket);
if (renameInfo.ok) {
const newName = makeTicketName('escalated', ticket, creatorNickname);
enqueueRename(interaction.channel, newName).catch(() => {});
} else {
const unlockAtMs = Date.now() + renameInfo.waitMs;
const unlockAtUnix = Math.floor(unlockAtMs / 1000);
await enqueueSend(interaction.channel,
`Channel renamed too quickly. Try again <t:${unlockAtUnix}:R>.`
);
}
const newName = makeTicketName('escalated', ticket, creatorNickname);
enqueueRename(interaction.channel, newName).catch(err => logError('rename', err).catch(() => {}));
if (!interaction.channel.isThread() && categoryId) {
await enqueueMove(interaction.channel, categoryId);
@@ -116,9 +107,11 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
const heyLine = creatorMention
? `Hey There ${creatorMention} 🥦`
: 'Hey There 🥦';
await enqueueSend(interaction.channel,
`${heyLine}\n**Getting the senior ${roleMention} for you.**`
);
// Creator + role pings are intentional; still block @everyone/@here if somehow interpolated.
await enqueueSend(interaction.channel, {
content: `${heyLine}\n**Getting the senior ${roleMention} for you.**`,
allowedMentions: { parse: ['users', 'roles'] }
});
const escalationBody = CONFIG.ESCALATION_MESSAGE
.replace(/\\n/g, '\n')
@@ -198,16 +191,7 @@ async function runDeescalation(interaction, ticket) {
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const state = newTier === 0 ? 'unclaimed' : 'escalated';
const renameInfo = await canRename(ticket);
if (renameInfo.ok) {
enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname)).catch(() => {});
} else {
const unlockAtMs = Date.now() + renameInfo.waitMs;
const unlockAtUnix = Math.floor(unlockAtMs / 1000);
await enqueueSend(interaction.channel,
`Channel renamed too quickly. Try again <t:${unlockAtUnix}:R>.`
);
}
enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname)).catch(err => logError('rename', err).catch(() => {}));
if (!interaction.channel.isThread()) {
try {
@@ -461,7 +445,7 @@ async function handleCommand(interaction) {
SendMessages: true,
ReadMessageHistory: true
});
await interaction.reply(`Added ${user} to this ticket.`);
await interaction.reply({ 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.', ephemeral: true });
@@ -479,7 +463,7 @@ async function handleCommand(interaction) {
try {
await interaction.channel.permissionOverwrites.delete(user.id);
await interaction.reply(`Removed ${user} from this ticket.`);
await interaction.reply({ 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.', ephemeral: true });
@@ -511,15 +495,18 @@ async function handleCommand(interaction) {
{ $set: { claimedBy: claimerLabel } }
);
await interaction.reply(
`Ticket transferred to ${member} by ${interaction.user}.\nReason: ${reason}`
);
// `reason` is staff-supplied freeform text; gate to user pings so @everyone in it can't mass-ping.
await interaction.reply({
content: `Ticket transferred to ${member} by ${interaction.user}.\nReason: ${reason}`,
allowedMentions: { parse: ['users'] }
});
const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null);
if (logChan) {
await enqueueSend(logChan,
`Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${member.tag}.\nReason: ${reason}`
);
await enqueueSend(logChan, {
content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${member.tag}.\nReason: ${reason}`,
allowedMentions: { parse: ['users'] }
});
}
} catch (err) {
console.error('Transfer error:', err);
@@ -784,7 +771,9 @@ async function handleCommand(interaction) {
const content = replaceVariables(tag.content, context);
await Tag.updateOne({ name }, { $inc: { useCount: 1 } });
await interaction.reply(content);
// Tag bodies are staff-authored but may include variable substitutions from user/ticket data.
// Disable all mention parsing so a `@everyone` in a tag body never pings.
await interaction.reply({ content, allowedMentions: { parse: [] } });
}
else if (subcommand === 'create') {