/** * Per-channel rename rate limiting with queue. * Discord allows 2 channel renames per 10 minutes per channel. * We use a 9-minute window for safety margin. */ const RENAME_WINDOW_MS = 9 * 60 * 1000; const RENAME_LIMIT = 2; const { logWarn } = require('../services/debugLog'); // Per-channel state: { count, windowStart, queue: [{newName, started}], processing } const renameState = new Map(); function getOrInitState(channelId) { let state = renameState.get(channelId); if (!state) { state = { count: 0, windowStart: 0, queue: [], processing: false }; renameState.set(channelId, state); } return state; } async function executeRename(channel, newName) { await channel.setName(newName); } function processQueue(channel, state) { if (state.queue.length === 0 || state.processing) return; const now = Date.now(); const timeUntilExpiry = (state.windowStart + RENAME_WINDOW_MS) - now; if (timeUntilExpiry > 0) { state.processing = true; setTimeout(async () => { state.processing = false; // New window const item = state.queue.shift(); if (!item) return; item.started = true; state.count = 1; state.windowStart = Date.now(); try { await executeRename(channel, item.newName); } catch (err) { logWarn('renameQueue', `Queued rename failed for ${channel.name}: ${err.message || err}`).catch(() => {}); } // Continue processing remaining queue items processQueue(channel, state); }, timeUntilExpiry); } } function enqueueRename(channel, newName) { const state = getOrInitState(channel.id); const now = Date.now(); // Window expired — reset if (now - state.windowStart >= RENAME_WINDOW_MS) { state.count = 1; state.windowStart = now; executeRename(channel, newName).catch((err) => { logWarn('renameQueue', `Immediate rename failed for ${channel.name}: ${err.message || err}`).catch(() => {}); }); return Promise.resolve(); } // Within window and under limit if (state.count < RENAME_LIMIT) { state.count++; executeRename(channel, newName).catch((err) => { logWarn('renameQueue', `Immediate rename failed for ${channel.name}: ${err.message || err}`).catch(() => {}); }); return Promise.resolve(); } // At limit — replace pending rename with latest const isNew = state.queue.length === 0; state.queue[0] = { newName, started: false }; const queuedItem = state.queue[0]; if (isNew) { setTimeout(() => { if (queuedItem.started) return; const estMinutes = Math.ceil(RENAME_WINDOW_MS / 60000); channel.send(`⏳ Channel will be renamed in ~${estMinutes} minute${estMinutes === 1 ? '' : 's'}.`).catch(() => {}); }, 2000); processQueue(channel, state); } return Promise.resolve(); } function enqueueMove(channel, categoryId) { return channel.setParent(categoryId, { lockPermissions: true }); } // 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; } module.exports = { enqueueRename, enqueueMove, enqueueSend };