Files
broccolini-bot/services/channelQueue.js
indifferentketchup cdf85f6364 audit week 1: creator ID tracking, channel-queue migration, deprecation cleanup
QUAL-006  store ticket.creatorId on creation; legacy split-pop returned the
          message ID for discord-msg-* tickets, breaking transcript DM, close
          log, and channel rename for context-menu-created tickets. Adds the
          field to the Ticket schema and writes a one-shot backfill script
          (scripts/backfill-creatorId.js, dry-run by default).

QUEUE-001 add enqueueOverwrite + enqueueTopic to services/channelQueue.js
          (chain on renameChains alongside enqueueMove). Migrate handleAdd /
          handleRemove / handleMove / handleTopic so permissionOverwrites,
          setParent, and setTopic no longer race pending renames or sends.
          handleMove now uses the existing enqueueMove. Initial overwrites in
          handleTicketModal stay inline; channel doesn't exist yet so no race.

DISCORD-001 replace ephemeral: true with flags: MessageFlags.Ephemeral across
            broccolini-discord.js, handlers/sharedHelpers.js, handlers/buttons.js,
            handlers/commands.js. runDeferred opts now take { flags } directly.

SEC-003   /gmailpoll min interval is 30s. Drop the 5s/10s slash-command
          choices and clamp Math.max(30000, ms) in handleGmailPoll for
          defense in depth.

QUAL-001  upgrade silent .catch(() => {}) on the lastActivity updateOne in
          handlers/messages.js to log via logError, so transient Mongo errors
          surface in the debug channel instead of disappearing.

QUAL-002  drop await from logError/logWarn calls in services/staffThread.js
          and services/pinMessage.js — fire-and-forget per CLAUDE.md hard rule.

QUAL-003  wrap stray setTimeouts (handleConfirmCloseRequest force-close timer,
          runFinalClose channel-delete + overflow-cleanup, checkAutoClose
          delete-after-email) in trackTimeout via lazy require so they clear
          on shutdown.
2026-05-08 20:19:14 +00:00

236 lines
8.6 KiB
JavaScript

/**
* 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') {
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.
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.
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: 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;
}
// 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 };