[SEC-004] services/staffThread.js — addRoleMembersToThread previously called
the unscoped guild.members.fetch() on every ticket creation, chunking every
member of the guild. With STAFF_THREAD_AUTO_ADD_ROLE on and a 50-member
staff role, the 300ms-per-add loop also blocked ticket creation for ~15s.
- Read role.members directly (computed from guild.members.cache, kept in
sync via the GuildMembers gateway intent set on the client). Skip the
explicit unscoped fetch in the hot path.
- Cache-cold fallback: one scoped guild.members.fetch({ withPresences:
false }) — irrelevant presence sync stays off the wire.
- createStaffThread no longer awaits the add-loop. Wraps the call in
setImmediate(...) so ticket creation returns immediately while the
rate-limited add-loop runs in the background.
[SEC-005] services/debugLog.js — stacks/messages posted to the debug
channel could leak customer email addresses (interpolated through ticket
errors) and Discord member/channel IDs. Add a redactPII helper applied to
both logError's message + stack and logWarn's body:
- Email regex /[\w.+-]+@[\w.-]+\.\w+/g → [EMAIL_REDACTED]
- Discord snowflake /\b\d{18,20}\b/g → [ID_REDACTED]
interaction.user.tag in the User: line is intentionally not redacted —
it's needed for triage and is not PII (Discord usernames are public).
113 lines
4.3 KiB
JavaScript
113 lines
4.3 KiB
JavaScript
/**
|
|
* Staff discussion threads — creates a private thread on each ticket channel
|
|
* for staff-only communication.
|
|
*
|
|
* Notes:
|
|
* - The bot requires CREATE_PRIVATE_THREADS and SEND_MESSAGES_IN_THREADS
|
|
* permissions on every ticket category.
|
|
* - Private threads (ChannelType.PrivateThread) require the server to have Community features
|
|
* OR the channel to be in a server with Boost level that unlocks private
|
|
* threads. If thread creation fails with code 50024 or 160004, a warning
|
|
* is logged via logWarn.
|
|
* - invitable: false means only staff with MANAGE_THREADS can add additional
|
|
* members — this is intentional for privacy.
|
|
* - addRoleMembersToThread reads from role.members (cache-derived) and only
|
|
* falls back to a scoped guild.members.fetch on cache miss. The 300ms
|
|
* delay between adds avoids the thread member add rate limit (~5/sec).
|
|
* It runs via setImmediate so it doesn't block ticket creation.
|
|
*/
|
|
const { ChannelType } = require('discord.js');
|
|
const { CONFIG } = require('../config');
|
|
const { logError, logWarn } = require('./debugLog');
|
|
|
|
/**
|
|
* Create a private staff thread on a ticket channel.
|
|
* @param {import('discord.js').TextChannel} channel
|
|
* @param {import('discord.js').Client} client
|
|
* @returns {Promise<import('discord.js').ThreadChannel|null>}
|
|
*/
|
|
async function createStaffThread(channel, client) {
|
|
if (!CONFIG.STAFF_THREAD_ENABLED) return null;
|
|
|
|
try {
|
|
const threadName = CONFIG.STAFF_THREAD_NAME.slice(0, 100);
|
|
|
|
const thread = await channel.threads.create({
|
|
name: threadName,
|
|
type: ChannelType.PrivateThread,
|
|
invitable: false,
|
|
reason: 'Staff discussion thread for ticket'
|
|
});
|
|
|
|
if (CONFIG.STAFF_THREAD_AUTO_ADD_ROLE && CONFIG.STAFF_THREAD_ROLE_ID) {
|
|
// Run off the critical path — the add loop is rate-limited at 300ms per
|
|
// member and would block ticket creation for ~15s on a 50-member role.
|
|
setImmediate(() => {
|
|
addRoleMembersToThread(thread, channel.guild, client).catch(() => {});
|
|
});
|
|
}
|
|
|
|
return thread;
|
|
} catch (err) {
|
|
// Detect permission / channel type errors
|
|
if (err.code === 50024 || err.code === 160004) {
|
|
logWarn('staffThread', `Cannot create private thread in ${channel.name}: server may lack Community features or required boost level (code ${err.code}).`).catch(() => {});
|
|
}
|
|
logError('staffThread:create', err, null, client).catch(() => {});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add all members of the staff role to the thread.
|
|
*
|
|
* Prefers role.members (computed from guild.members.cache, kept in sync via
|
|
* the GuildMembers gateway intent — see broccolini-discord.js intents). Only
|
|
* falls back to a scoped guild.members.fetch on cache miss (e.g. cold cache
|
|
* just after restart). Previously called the unscoped guild.members.fetch()
|
|
* on every ticket creation, which chunked all members of the guild — wasted
|
|
* gateway/REST budget and added ~15s to ticket creation on busy guilds.
|
|
*/
|
|
async function addRoleMembersToThread(thread, guild, client) {
|
|
try {
|
|
const role = await guild.roles.fetch(CONFIG.STAFF_THREAD_ROLE_ID).catch(() => null);
|
|
if (!role) return;
|
|
|
|
let members = role.members.filter(m => !m.user.bot);
|
|
if (members.size === 0) {
|
|
// Cache cold (first ticket after restart). withPresences: false skips
|
|
// the presence sync, which is irrelevant for thread-add and expensive.
|
|
await guild.members.fetch({ withPresences: false }).catch(() => {});
|
|
members = role.members.filter(m => !m.user.bot);
|
|
}
|
|
|
|
for (const [, member] of members) {
|
|
await thread.members.add(member.id).catch(() => {});
|
|
await new Promise(r => setTimeout(r, 300));
|
|
}
|
|
} catch (err) {
|
|
logError('staffThread:addMembers', err, null, client).catch(() => {});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a single member to the staff thread for a ticket channel.
|
|
* Call this when a ticket is claimed.
|
|
*/
|
|
async function addMemberToStaffThread(channel, memberId) {
|
|
if (!CONFIG.STAFF_THREAD_ENABLED) return;
|
|
|
|
try {
|
|
const threads = await channel.threads.fetchActive();
|
|
const staffThread = threads.threads.find(t =>
|
|
t.name === CONFIG.STAFF_THREAD_NAME && t.type === ChannelType.PrivateThread
|
|
);
|
|
if (!staffThread) return;
|
|
await staffThread.members.add(memberId);
|
|
} catch {
|
|
// non-critical, ignore
|
|
}
|
|
}
|
|
|
|
module.exports = { createStaffThread, addMemberToStaffThread };
|