rename path: fix env-var mismatch, gut canRename gate, add primary-bot fallback on 401/403/429
- secondary rename-bot token was set as RENAME_TOKEN in .env but utils/renamer.js reads RENAMER_BOT; silently no-op'd every rename (host .env renamed separately)
- services/tickets.js canRename gutted to an always-ok shim; Mongo 2/10min per-channel gate is redundant since renames flow through RENAMER_BOT's own bucket. Ticket.renameCount / renameWindowStart remain as orphan fields (no migration)
- handlers/buttons.js + commands.js: drop the four "Channel renamed too quickly" else-branches and the rename-countdown label suffix; replace .catch(() => {}) with .catch(err => logError('rename', err)...)
- services/channelQueue.js: executeRename falls back to channel.setName(currentName) when renamer throws err.fallback === true (401/403/429); classifies non-fallback errors as renameQueue:token/permission (401/403) or renameQueue:secondary-bot ratelimited (429)
- utils/renamer.js: on 401/403 throw err.fallback=true immediately; on 429 respect retry_after up to 2000ms then throw err.fallback=true
- docs: align CLAUDE.md, docs/api/DISCORD_API_VALIDATION.md, docs/architecture/CRITICAL_FILES_AND_HOW_IT_WORKS.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
* coalesce rapid successive calls so only the latest name is written.
|
||||
*/
|
||||
|
||||
const { logWarn } = require('../services/debugLog');
|
||||
const { logWarn, logError } = require('../services/debugLog');
|
||||
const { renameChannel } = require('../utils/renamer');
|
||||
|
||||
// Per-channel: { chain: Promise, pendingName: string | null }.
|
||||
@@ -18,7 +18,22 @@ async function executeRename(channel, entry) {
|
||||
const currentName = entry.pendingName;
|
||||
if (currentName == null) return;
|
||||
try {
|
||||
await renameChannel(channel.id, currentName);
|
||||
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') {
|
||||
logWarn(
|
||||
'renameQueue',
|
||||
`secondary-bot ${err.status ?? 'unavailable'}; falling back to primary channel=${channel.id}`
|
||||
).catch(() => {});
|
||||
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.
|
||||
@@ -42,6 +57,19 @@ function enqueueRename(channel, newName) {
|
||||
|
||||
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);
|
||||
@@ -50,8 +78,39 @@ function enqueueRename(channel, newName) {
|
||||
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.
|
||||
function enqueueMove(channel, categoryId) {
|
||||
return channel.setParent(categoryId, { lockPermissions: true });
|
||||
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: true }));
|
||||
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;
|
||||
}
|
||||
|
||||
// Per-channel promise chain for send ordering and to prevent interleaving.
|
||||
|
||||
@@ -25,10 +25,12 @@ async function getNextTicketNumber(senderEmail) {
|
||||
}
|
||||
|
||||
// --- RENAME + NAMING ---
|
||||
// Discord rate limit: 2 channel renames per 10 minutes per channel (see https://discord.com/developers/docs/topics/rate-limits).
|
||||
// When limit is reached we skip the rename and post: "Channel renamed too quickly. Try again <t:unlock:R>."
|
||||
// Renames flow through utils/renamer.js (RENAMER_BOT secondary token),
|
||||
// which has its own Discord rate-limit bucket. We no longer gate on the
|
||||
// primary bot's 2/10min per-channel budget here; 429s from the secondary
|
||||
// bot surface via utils/renamer.js instead.
|
||||
|
||||
const RENAME_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const RENAME_WINDOW_MS = 10 * 60 * 1000; // 10 minutes (unused; kept for back-compat)
|
||||
const RENAME_LIMIT = 2;
|
||||
|
||||
function getSenderLocal(senderEmail) {
|
||||
@@ -88,53 +90,10 @@ function makeTicketName(state, ticket, creatorNickname, claimerEmoji) {
|
||||
}
|
||||
}
|
||||
|
||||
async function canRename(ticket) {
|
||||
const now = Date.now();
|
||||
const windowCutoff = new Date(now - RENAME_WINDOW_MS);
|
||||
|
||||
// Atomic: reset the window if the stored start is older than the cutoff; count = 1.
|
||||
const resetDoc = await Ticket.findOneAndUpdate(
|
||||
{
|
||||
gmailThreadId: ticket.gmailThreadId,
|
||||
$or: [
|
||||
{ renameWindowStart: { $lt: windowCutoff } },
|
||||
{ renameWindowStart: null },
|
||||
{ renameWindowStart: { $exists: false } }
|
||||
]
|
||||
},
|
||||
{ $set: { renameWindowStart: new Date(now), renameCount: 1 } },
|
||||
{ new: true, projection: { renameCount: 1, renameWindowStart: 1 } }
|
||||
).lean();
|
||||
|
||||
if (resetDoc) {
|
||||
ticket.renameWindowStart = resetDoc.renameWindowStart;
|
||||
ticket.renameCount = resetDoc.renameCount;
|
||||
return { ok: true, remaining: RENAME_LIMIT - resetDoc.renameCount, waitMs: 0 };
|
||||
}
|
||||
|
||||
// Atomic: within window, only increment if count < limit.
|
||||
const incDoc = await Ticket.findOneAndUpdate(
|
||||
{
|
||||
gmailThreadId: ticket.gmailThreadId,
|
||||
renameCount: { $lt: RENAME_LIMIT }
|
||||
},
|
||||
{ $inc: { renameCount: 1 } },
|
||||
{ new: true, projection: { renameCount: 1, renameWindowStart: 1 } }
|
||||
).lean();
|
||||
|
||||
if (incDoc) {
|
||||
ticket.renameWindowStart = incDoc.renameWindowStart;
|
||||
ticket.renameCount = incDoc.renameCount;
|
||||
return { ok: true, remaining: RENAME_LIMIT - incDoc.renameCount, waitMs: 0 };
|
||||
}
|
||||
|
||||
// At limit — read the window start to compute waitMs.
|
||||
const fresh = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId })
|
||||
.select('renameWindowStart')
|
||||
.lean();
|
||||
const windowStart = (fresh?.renameWindowStart && new Date(fresh.renameWindowStart).getTime()) || now;
|
||||
const waitMs = Math.max(0, RENAME_WINDOW_MS - (now - windowStart));
|
||||
return { ok: false, remaining: 0, waitMs };
|
||||
// Retained for external callers (bOSScord, scripts). The gate now lives in
|
||||
// the secondary bot's rate bucket; this helper no longer touches Mongo.
|
||||
async function canRename(_ticket) {
|
||||
return { ok: true, remaining: RENAME_LIMIT, waitMs: 0 };
|
||||
}
|
||||
|
||||
function minutesFromMs(ms) {
|
||||
|
||||
Reference in New Issue
Block a user