From fcce7c3e86714c8223b90d4e98133d04ad4ae5d3 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Mon, 20 Apr 2026 14:56:55 +0000 Subject: [PATCH] changes --- .claude/settings.local.json | 19 +++++- services/channelQueue.js | 115 ++++++++++++------------------------ utils/renamer.js | 62 +++++++++++++++++++ 3 files changed, 117 insertions(+), 79 deletions(-) create mode 100644 utils/renamer.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 850f46d..f55aaf6 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -25,7 +25,24 @@ "Bash(node --check handlers/messages.js)", "Bash(node *)", "Bash(npm info *)", - "Bash(npm ls *)" + "Bash(npm ls *)", + "Bash(curl -sk http://127.0.0.1:12752/css/main.css)", + "Bash(curl -sk http://127.0.0.1:12752/js/app.js)", + "Bash(curl -sk -o /dev/null -w \"HTTP %{http_code}\\\\n\" http://127.0.0.1:12752/css/main.css)", + "Bash(curl -sk -o /dev/null -w \"HTTP %{http_code}\\\\n\" http://127.0.0.1:12752/js/app.js)", + "Bash(docker compose *)", + "Bash(curl -sI http://100.114.205.53:12752/healthz)", + "Bash(curl -s http://100.114.205.53:12752/healthz)", + "mcp__plugin_context7_context7__query-docs", + "Bash(curl -sI http://100.114.205.53:12752/api/config)", + "Bash(curl -sI http://100.114.205.53:12752/)", + "Bash(curl -sI http://100.114.205.53:12752/login)", + "Bash(curl -sI http://100.114.205.53:12752/some/nonexistent/path)", + "Bash(curl -sI http://100.114.205.53:12752/js/app.js)", + "Bash(curl -sI http://100.114.205.53:12752/js/util.js)", + "Bash(curl -sI http://100.114.205.53:12752/js/notifications.js)", + "Bash(docker exec *)", + "Bash(curl -sI http://100.114.205.53:12752/api/notifications/alerts)" ] } } diff --git a/services/channelQueue.js b/services/channelQueue.js index 917422e..855dd2e 100644 --- a/services/channelQueue.js +++ b/services/channelQueue.js @@ -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) { diff --git a/utils/renamer.js b/utils/renamer.js new file mode 100644 index 0000000..1b2d250 --- /dev/null +++ b/utils/renamer.js @@ -0,0 +1,62 @@ +/** + * Secondary-token channel rename helper. + * + * Routes channel/thread renames through a second bot token (RENAMER_BOT) + * so renames don't consume the primary bot's per-channel 2/10min budget. + * + * The secondary bot must be invited to the guild with Manage Channels + * and Manage Threads. + * + * Not called directly from feature code — invoked by services/channelQueue.js + * so all channel ops continue to flow through the queue. + */ + +const { logWarn } = require('../services/debugLog'); + +const DISCORD_API = 'https://discord.com/api/v10'; + +async function renameChannel(channelId, newName) { + const token = (process.env.RENAMER_BOT || '').trim(); + if (!token) { + throw new Error('RENAMER_BOT is not set; cannot rename via secondary token'); + } + + const res = await fetch(`${DISCORD_API}/channels/${channelId}`, { + method: 'PATCH', + headers: { + 'Authorization': `Bot ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ name: newName }) + }); + + const text = await res.text(); + let body; + try { + body = text ? JSON.parse(text) : null; + } catch (_) { + body = text; + } + + if (res.status === 429) { + const retryAfter = (body && typeof body === 'object' && body.retry_after) || null; + logWarn('renamer', `429 rename channel=${channelId} retry_after=${retryAfter}`).catch(() => {}); + const err = new Error(`rename 429: retry_after=${retryAfter}`); + err.status = 429; + err.retryAfter = retryAfter; + err.body = body; + throw err; + } + + if (!res.ok) { + const bodyStr = typeof body === 'string' ? body : JSON.stringify(body); + const err = new Error(`rename failed: status=${res.status} body=${bodyStr}`); + err.status = res.status; + err.body = body; + throw err; + } + + return body; +} + +module.exports = { renameChannel };