Files
broccolini-bot/services/staffThread.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

98 lines
3.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.
* - guild.members.fetch() in addRoleMembersToThread can be slow on large
* servers. The 300ms delay between adds avoids the thread member add rate
* limit (approximately 5/second).
*/
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) {
await addRoleMembersToThread(thread, channel.guild, client);
}
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.
*/
async function addRoleMembersToThread(thread, guild, client) {
try {
const role = await guild.roles.fetch(CONFIG.STAFF_THREAD_ROLE_ID).catch(() => null);
if (!role) return;
await guild.members.fetch();
const members = guild.members.cache.filter(m =>
m.roles.cache.has(CONFIG.STAFF_THREAD_ROLE_ID) && !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 };