enqueueMove called channel.setParent(categoryId, { lockPermissions: true }).
discord.js's default for setParent is also true. With lockPermissions: true,
Discord re-syncs the channel's permission overwrites to match the new
parent category — so the explicit per-user allows set at ticket creation
(creator) and via /add (extra members) got wiped on every escalate,
de-escalate, and /move. The creator literally lost View Channel on their
own ticket the moment staff hit Escalate.
Flip to lockPermissions: false so the existing channel-level overwrites
are preserved across the parent change. Inheritance still applies for
anything the channel doesn't override — and the deny-@everyone /
role-allow set at creation continues to gate access correctly.
Affects every caller of enqueueMove:
- handlers/commands/escalation.js runEscalation
- handlers/commands/escalation.js runDeescalation
- handlers/commands/index.js handleMove (/move)
245 lines
9.3 KiB
JavaScript
245 lines
9.3 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') {
|
|
// Local log only; discord.js's REST client transparently handles 429s
|
|
// on the primary fallback, so this used to post a paired warning to
|
|
// the debug channel for every secondary-bot quota event with no
|
|
// operator action required. Keep the visibility in container logs.
|
|
console.warn(
|
|
`[renameQueue] secondary-bot ${err.status ?? 'unavailable'}; falling back to primary channel=${channel.id}`
|
|
);
|
|
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.
|
|
//
|
|
// lockPermissions: false preserves the channel's existing permission overwrites
|
|
// across the parent change. With the default (true), Discord re-syncs the
|
|
// channel's overwrites to match the new category and wipes per-user grants —
|
|
// in practice that kicked the ticket creator and any /add'd users off the
|
|
// channel on every escalate / de-escalate / /move.
|
|
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: false }));
|
|
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 };
|