Compare commits

..

8 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
a565450e2d buttons: allow non-staff to close tickets (countdown still applies)
After the previous TICKET_BUTTON_HANDLERS gate, ticket creators and
/add'd members were locked out of every ticket button — including
close_ticket on their own ticket. Add a PUBLIC_TICKET_BUTTONS set so
the close flow (close_ticket / confirm_close / confirm_close_with_email
/ confirm_close_no_email / cancel_close) skips the staff check.

Claim, escalate, and de-escalate remain staff-only. The 60s
FORCE_CLOSE_TIMER countdown, the transcript archive, and the optional
customer-closure email all continue to fire on the existing
runFinalClose path — nothing about the close behavior changes, only
who is allowed to click the button.

cancel_close is intentionally public too: anyone in the channel can
abort a pending close, including the original setter, staff, or the
creator. The pendingCloses entry stores who set it, but the abort path
doesn't gate on that — kept permissive to match the rest of the close
flow.
2026-05-19 22:15:38 +00:00
837fd10984 escalation: drop dead 'reason' param — never populated, always logged as null
The /escalate slash command never had a reason option in its definition
(commands/register.js only takes a 'level' option), so handleEscalate
hardcoded reason=null. The escalate button path passed null explicitly.
The log line wrote it verbatim as "Reason: null" on every escalate.

Remove the dead surface:
- runEscalation signature drops the reason parameter.
- The customer-facing email body drops the conditional reason suffix
  (`reason ? `\n\nReason: ${reason}` : ''`) — always-false branch.
- The logging-channel post drops "\nReason: ${reason}".
- handleEscalate drops the `const reason = null;` line and the call-site arg.
- handleEscalateButton (handlers/buttons.js) drops the trailing `null` arg.

If we ever want to capture a reason, the slash command would need a
StringOption('reason') and an escalate-modal for the button path —
neither exists today.
2026-05-19 20:20:03 +00:00
2152544d09 escalation/de-escalation: keep ticket creator and /add'd users on the channel
enqueueMove called channel.setParent(categoryId, { lockPermissions: true }).
discord.js's default for setParent is also true. With lockPermissions: true,
Discord re-syncs the channel's permission overwrites to match the new
parent category — so the explicit per-user allows set at ticket creation
(creator) and via /add (extra members) got wiped on every escalate,
de-escalate, and /move. The creator literally lost View Channel on their
own ticket the moment staff hit Escalate.

Flip to lockPermissions: false so the existing channel-level overwrites
are preserved across the parent change. Inheritance still applies for
anything the channel doesn't override — and the deny-@everyone /
role-allow set at creation continues to gate access correctly.

Affects every caller of enqueueMove:
  - handlers/commands/escalation.js runEscalation
  - handlers/commands/escalation.js runDeescalation
  - handlers/commands/index.js handleMove (/move)
2026-05-19 20:09:58 +00:00
c79463fc2a security: gate /help, signature modal submit, and cancel_delete_tag on staff role
Closes the remaining non-broccolini interaction paths after the prior
TICKET_BUTTON_HANDLERS gate. After this commit, every bot interaction is
staff-only except the panel buttons (open_ticket / open_ticket_thread /
open_ticket_channel) and their ticket-creation modal submit — those have
to stay public because they're how members and customers open tickets.

Specific changes:

- handlers/commands/index.js: handleCommand no longer has the
  `!== 'help'` carve-out. /help now goes through requireStaffRole like
  every other slash command. Non-staff get the same ephemeral
  "only available to the support team" reply.

- broccolini-discord.js: the signature_modal_* modal-submit handler now
  calls requireStaffRole before writing to StaffSignature. /signature
  already gates the modal display via the slash-command staff check;
  this is defense in depth against directly crafted submissions.

- handlers/buttons.js: cancel_delete_tag moved out of
  FREE_BUTTON_HANDLERS and gated alongside confirm_delete_tag::*. The
  dialog is only shown ephemerally to the staff who triggered
  /response delete, so non-staff can't reach it in normal flow; gating
  keeps the button surface consistent.

Kept public (by design — these are the customer entry points):
  open_ticket / open_ticket_thread / open_ticket_channel buttons
  ticket_modal / ticket_modal_thread / ticket_modal_channel submits
2026-05-19 19:58:41 +00:00
e8e114e4ad security: gate ticket buttons + tag-delete confirm on staff role
handleButton routed claim_ticket, close_ticket, confirm_close /
confirm_close_with_email / confirm_close_no_email, cancel_close,
escalate_ticket, escalate_to_tier2, escalate_to_tier3, deescalate_ticket,
and confirm_delete_tag::* straight to their handlers without any staff
check. Any non-staff member with View Channel on a ticket — the ticket
creator themselves, or anyone /add'd to it — could click those buttons
and mutate ticket state (claim, escalate, close, delete saved-response
tags).

The slash-command dispatcher in handlers/commands/index.js already
calls requireStaffRole before invoking any handler; the button
dispatcher needed the same gate. Now:

  - confirm_delete_tag::<name>  → requireStaffRole, then proceed.
  - TICKET_BUTTON_HANDLERS dispatch → requireStaffRole, then proceed.
  - FREE_BUTTON_HANDLERS (open_ticket* panel buttons, cancel_delete_tag)
    remain ungated — those are public-facing by design.

requireStaffRole replies ephemerally ("This command is only available
to the support team (<@&role>)") and returns true when the caller
should bail, matching the slash-command behavior.
2026-05-19 19:55:01 +00:00
452f005aea silence secondary-bot 429 fallback noise from debug channel
Two paired logWarn calls used to post to DEBUGGING_CHANNEL_ID every time
the RENAMER_BOT secondary token hit Discord's per-channel rename quota:

  Warning: renamer        — "429 rename channel=… retry_after=…"
  Warning: renameQueue    — "secondary-bot 429; falling back to primary…"

Both fire on the recoverable path: the channelQueue immediately falls
back to the primary discord.js client, and that client's REST handler
transparently waits out the retry_after and retries — the rename lands
without operator action. Posting these to the debug channel was pure
noise; staff were reading them as failures when nothing had failed.

Demoted both to console.warn so they still appear in `docker logs
broccolini` for diagnostic purposes but no longer post to Discord.

Kept untouched:
- utils/renamer.js:64 — 401/403 logWarn on secondary-bot auth/permission
  errors (real config problems; the operator does need to know).
- services/channelQueue.js next.catch logError for status 401/403/429 —
  only fires when the fallback itself also failed (rare and worth a
  debug-channel post).
2026-05-19 18:38:18 +00:00
6 changed files with 133 additions and 35 deletions

View File

@@ -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');

View File

@@ -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.'

View File

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

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

View File

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

View File

@@ -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) {