This commit is contained in:
2026-04-20 14:56:55 +00:00
parent 8a45b59b28
commit fcce7c3e86
3 changed files with 117 additions and 79 deletions

View File

@@ -1,94 +1,53 @@
/**
* 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.
* 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 RENAME_WINDOW_MS = 9 * 60 * 1000;
const RENAME_LIMIT = 2;
const { logWarn } = require('../services/debugLog');
const { renameChannel } = require('../utils/renamer');
// Per-channel state: { count, windowStart, queue: [{newName, started}], processing }
const renameState = new Map();
// 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();
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);
async function executeRename(channel, entry) {
const currentName = entry.pendingName;
if (currentName == null) return;
try {
await renameChannel(channel.id, currentName);
} 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) {
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();
let entry = renameChains.get(channel.id);
if (!entry) {
entry = { chain: Promise.resolve(), pendingName: newName };
renameChains.set(channel.id, entry);
} else {
entry.pendingName = newName;
}
// 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();
}
const next = entry.chain.catch(() => {}).then(() => executeRename(channel, entry));
entry.chain = next;
// 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();
next.catch((err) => {
logWarn('renameQueue', `Rename failed for ${channel.name}: ${err && err.message || err}`).catch(() => {});
}).finally(() => {
if (renameChains.get(channel.id) === entry && entry.chain === next && entry.pendingName == null) {
renameChains.delete(channel.id);
}
});
return next;
}
function enqueueMove(channel, categoryId) {