/** * Per-channel rename serialization with coalescing. * Renames route through utils/renamer.js (secondary bot token, RENAMER_BOT), * which has its own Discord-side rate bucket — no in-process throttle needed. * We serialize per channel so concurrent PATCHes don't land out of order, and * coalesce rapid successive calls so only the latest name is written. */ const { logWarn, logError } = require('../services/debugLog'); const { renameChannel } = require('../utils/renamer'); // Per-channel: { chain: Promise, pendingName: string | null }. // enqueueRename updates pendingName synchronously (latest wins) and chains an // executeRename link. executeRename reads the latest pendingName at start. const renameChains = new Map(); async function executeRename(channel, entry) { const currentName = entry.pendingName; if (currentName == null) return; try { try { await renameChannel(channel.id, currentName); } catch (err) { // Secondary bot rate-limited (429), unauthorized (401), missing permission // (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') { // 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; } } } finally { // Clear only if no newer call arrived during the PATCH. If pendingName // has changed, leave it — the link queued by that newer call picks it up. if (entry.pendingName === currentName) { entry.pendingName = null; } } } function enqueueRename(channel, newName) { let entry = renameChains.get(channel.id); if (!entry) { entry = { chain: Promise.resolve(), pendingName: newName }; renameChains.set(channel.id, entry); } else { entry.pendingName = newName; } const next = entry.chain.catch(() => {}).then(() => executeRename(channel, entry)); entry.chain = next; next.catch((err) => { logWarn('renameQueue', `Rename failed for ${channel.name}: ${err && err.message || err}`).catch(() => {}); const status = err && err.status; const msg = (err && err.message) || String(err); if (status === 401 || status === 403) { logError( 'renameQueue:token/permission', new Error(`secondary-bot ${status} channel=${channel.id} name=${channel.name}: ${msg}`) ).catch(() => {}); } else if (status === 429) { logError( 'renameQueue:secondary-bot ratelimited', new Error(`429 channel=${channel.id} name=${channel.name}: ${msg}`) ).catch(() => {}); } }).finally(() => { if (renameChains.get(channel.id) === entry && entry.chain === next && entry.pendingName == null) { renameChains.delete(channel.id); } }); return next; } // 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) { entry = { chain: Promise.resolve(), pendingName: null }; renameChains.set(channel.id, entry); } const next = entry.chain.catch(() => {}).then(() => channel.setParent(categoryId, { lockPermissions: false })); entry.chain = next; next.catch((err) => { logWarn('moveQueue', `Move failed for ${channel.name}: ${err && err.message || err}`).catch(() => {}); const status = err && err.status; const msg = (err && err.message) || String(err); if (status === 401 || status === 403) { logError( 'moveQueue:token/permission', new Error(`${status} channel=${channel.id} categoryId=${categoryId}: ${msg}`) ).catch(() => {}); } else if (status === 429) { logError( 'moveQueue:ratelimited', new Error(`429 channel=${channel.id} categoryId=${categoryId}: ${msg}`) ).catch(() => {}); } }).finally(() => { if (renameChains.get(channel.id) === entry && entry.chain === next && entry.pendingName == null) { renameChains.delete(channel.id); } }); return next; } // Shares renameChains so a permissionOverwrite mutation serializes with pending // renames/moves on the same channel. Mode 'create' calls // `channel.permissionOverwrites.create(id, perms)`; 'delete' calls // `channel.permissionOverwrites.delete(id)`. No coalescing. function enqueueOverwrite(channel, id, perms, mode = 'create') { let entry = renameChains.get(channel.id); if (!entry) { entry = { chain: Promise.resolve(), pendingName: null }; renameChains.set(channel.id, entry); } const next = entry.chain.catch(() => {}).then(() => mode === 'delete' ? channel.permissionOverwrites.delete(id) : channel.permissionOverwrites.create(id, perms) ); entry.chain = next; next.catch((err) => { logWarn('overwriteQueue', `Overwrite ${mode} failed for ${channel.name}: ${err && err.message || err}`).catch(() => {}); const status = err && err.status; const msg = (err && err.message) || String(err); if (status === 401 || status === 403) { logError( 'overwriteQueue:token/permission', new Error(`${status} channel=${channel.id} target=${id} mode=${mode}: ${msg}`) ).catch(() => {}); } else if (status === 429) { logError( 'overwriteQueue:ratelimited', new Error(`429 channel=${channel.id} target=${id} mode=${mode}: ${msg}`) ).catch(() => {}); } }).finally(() => { if (renameChains.get(channel.id) === entry && entry.chain === next && entry.pendingName == null) { renameChains.delete(channel.id); } }); return next; } // Shares renameChains so setTopic serializes with pending renames/moves. function enqueueTopic(channel, text) { let entry = renameChains.get(channel.id); if (!entry) { entry = { chain: Promise.resolve(), pendingName: null }; renameChains.set(channel.id, entry); } const next = entry.chain.catch(() => {}).then(() => channel.setTopic(text)); entry.chain = next; next.catch((err) => { logWarn('topicQueue', `Topic set failed for ${channel.name}: ${err && err.message || err}`).catch(() => {}); const status = err && err.status; const msg = (err && err.message) || String(err); if (status === 401 || status === 403) { logError( 'topicQueue:token/permission', new Error(`${status} channel=${channel.id}: ${msg}`) ).catch(() => {}); } else if (status === 429) { logError( 'topicQueue:ratelimited', new Error(`429 channel=${channel.id}: ${msg}`) ).catch(() => {}); } }).finally(() => { if (renameChains.get(channel.id) === entry && entry.chain === next && entry.pendingName == null) { renameChains.delete(channel.id); } }); return next; } // Per-channel promise chain for send ordering and to prevent interleaving. const sendChains = new Map(); function enqueueSend(channel, ...args) { if (!channel || typeof channel.send !== 'function') { return Promise.reject(new Error('enqueueSend: invalid channel')); } const prev = sendChains.get(channel.id) || Promise.resolve(); const next = prev.catch(() => {}).then(() => channel.send(...args)); sendChains.set(channel.id, next); next.catch(() => {}).finally(() => { if (sendChains.get(channel.id) === next) sendChains.delete(channel.id); }); return next; } // Delete a channel only after every in-flight send/rename/move on it has drained. // Chains on both renameChains and sendChains so "pending send in-flight, delete // racing it" can no longer hit Discord's unknown-channel 10003. function enqueueDelete(channel) { if (!channel || typeof channel.delete !== 'function') { return Promise.reject(new Error('enqueueDelete: invalid channel')); } const renameEntry = renameChains.get(channel.id); const prevRename = renameEntry ? renameEntry.chain : Promise.resolve(); const prevSend = sendChains.get(channel.id) || Promise.resolve(); const next = Promise.all([ prevRename.catch(() => {}), prevSend.catch(() => {}) ]).then(() => channel.delete().catch(() => {})); if (renameEntry) renameEntry.chain = next; sendChains.set(channel.id, next); next.finally(() => { if (renameEntry && renameChains.get(channel.id) === renameEntry && renameEntry.chain === next) { renameChains.delete(channel.id); } if (sendChains.get(channel.id) === next) sendChains.delete(channel.id); }); return next; } module.exports = { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend, enqueueDelete };