From 8a4e306f2895852a0fccc3272542eb91ef7e3485 Mon Sep 17 00:00:00 2001 From: indifferentketchup <159190319+indifferentketchup@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:39:00 -0500 Subject: [PATCH] p-queue --- .env.example | 4 +-- .env.test.example | 63 +++++++++++++++++++-------------- config.js | 1 + handlers/buttons.js | 13 ++++--- handlers/commands.js | 41 +++++++++++++--------- handlers/messages.js | 73 +-------------------------------------- package.json | 1 + services/channelQueue.js | 20 +++++++++++ services/guildSettings.js | 5 ++- 9 files changed, 99 insertions(+), 122 deletions(-) create mode 100644 services/channelQueue.js diff --git a/.env.example b/.env.example index 39952d2..6d55482 100644 --- a/.env.example +++ b/.env.example @@ -25,8 +25,8 @@ DISCORD_ESCALATED_CATEGORY_ID= # Fallback escalation category (Discord) EMAIL_ESCALATED_CATEGORY_ID= # Fallback escalation category (email); legacy alias: ESCALATED_CATEGORY_ID DISCORD_ESCALATED2_CHANNEL_ID= # Tier 2 escalation category/channel (Discord) DISCORD_ESCALATED3_CHANNEL_ID= # Tier 3 escalation category/channel (Discord) -EMAIL_ESCALATED2_CHANNEL_ID= # Tier 2 escalation category/channel (email) -EMAIL_ESCALATED3_CHANNEL_ID= # Tier 3 escalation category/channel (email) +EMAIL_ESCALATED2_CHANNEL_ID= # Tier 2 escalation category ID (email tickets; env name says CHANNEL for legacy reasons) +EMAIL_ESCALATED3_CHANNEL_ID= # Tier 3 escalation category ID (email tickets; same naming note as tier 2) # Logging, transcripts, and utility ROLE_ID_TO_PING= # Role ID to ping on new tickets (config also accepts ROLE_TO_PING_ID as alias) diff --git a/.env.test.example b/.env.test.example index 7b660ca..8bc0a11 100644 --- a/.env.test.example +++ b/.env.test.example @@ -2,29 +2,34 @@ # Broccolini Bot – Test environment template (no secrets) # Copy to .env.test and fill with TEST-only values. Run with ENV_FILE=.env.test # so changes are tried here first, then migrated to .env after confirmation. -# See ENV_AND_SECURITY.md. Never commit .env or .env.test. +# See docs/setup/ENV_AND_SECURITY.md. Never commit .env or .env.test. # ============================================================================= # --- Discord: Core (use a test guild / bot if possible) --- -DISCORD_TOKEN= -DISCORD_APPLICATION_ID= -DISCORD_GUILD_ID= +DISCORD_TOKEN= # Bot token (test bot) +DISCORD_APPLICATION_ID= # Application (client) ID +DISCORD_GUILD_ID= # Test server ID # --- Discord: Channel & category IDs (test server) --- +# Ticket creation: set one or both; /panel and /email-routing choose behavior DISCORD_TICKET_CATEGORY_ID= TICKET_CATEGORY_ID= -DISCORD_THREAD_CHANNEL_ID= -EMAIL_THREAD_CHANNEL_ID= +# DISCORD_THREAD_CHANNEL_ID= # Text channel for Discord ticket threads (optional) +# EMAIL_THREAD_CHANNEL_ID= # Text channel for email ticket threads (optional) -# --- Escalation (optional for test) --- -DISCORD_ESCALATED_CATEGORY_ID= -EMAIL_ESCALATED_CATEGORY_ID= +# Overflow categories when main hits 50 channels (comma-separated, optional) +# EMAIL_TICKET_OVERFLOW_CATEGORY_IDS= +# DISCORD_TICKET_OVERFLOW_CATEGORY_IDS= + +# Escalation (optional for test) +# DISCORD_ESCALATED_CATEGORY_ID= +# EMAIL_ESCALATED_CATEGORY_ID= # legacy alias: ESCALATED_CATEGORY_ID DISCORD_ESCALATED2_CHANNEL_ID= DISCORD_ESCALATED3_CHANNEL_ID= -EMAIL_ESCALATED2_CHANNEL_ID= +EMAIL_ESCALATED2_CHANNEL_ID= # Tier 2 category ID (email); env name *_CHANNEL_* is legacy EMAIL_ESCALATED3_CHANNEL_ID= -# --- Logging & utility --- +# --- Logging, transcripts, and utility --- ROLE_ID_TO_PING= TRANSCRIPT_CHANNEL_ID= LOGGING_CHANNEL_ID= @@ -33,7 +38,7 @@ BACKUP_EXPORT_CHANNEL_ID= ACCOUNT_INFO_CHANNEL_ID= DISCORD_CHANNEL_ID= -# --- Buttons / copy --- +# --- Discord: Ticket copy & buttons --- ESCALATION_MESSAGE= BUTTON_LABEL_CLOSE=Close Ticket BUTTON_LABEL_CLAIM=Claim @@ -42,21 +47,27 @@ BUTTON_EMOJI_CLOSE=πŸ”’ BUTTON_EMOJI_CLAIM=πŸ“Œ BUTTON_EMOJI_UNCLAIM=πŸ”“ -# --- Google / Gmail (test inbox or same as prod – your choice) --- +# --- Google / Gmail (test inbox / separate OAuth client optional) --- GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= REFRESH_TOKEN= MY_EMAIL= -# --- Server --- -NGROK_URL= -DISCORD_ONLY_PORT=5001 +# --- Server & URLs --- +# NGROK_URL= # Optional; public URL if you use ngrok for webhooks +DISCORD_ONLY_PORT=5000 +# HEALTHCHECK_HOST= -# --- Database (use a separate test DB or db name to avoid data loss) --- +# --- bOSScord (support cockpit) --- +# BOSSCORD_API_KEY= +# BOSSCORD_CORS_ORIGIN=* + +# --- Database (test cluster or local) --- MONGODB_URI= +# MONGODB_DATABASE= # --- Branding & copy --- -SUPPORT_NAME=Support (Test) +SUPPORT_NAME=Support LOGO_URL= EMAIL_SIGNATURE= TICKET_CLOSE_SUBJECT_PREFIX=[Resolved] @@ -66,7 +77,7 @@ DISCORD_CLOSE_MESSAGE= DISCORD_TRANSCRIPT_MESSAGE= DISCORD_AUTO_CLOSE_MESSAGE= -# --- Limits & permissions --- +# --- Ticket limits & permissions --- GLOBAL_TICKET_LIMIT=5 TICKET_LIMIT_PER_CATEGORY=3 RATE_LIMIT_TICKETS_PER_USER=0 @@ -74,10 +85,12 @@ RATE_LIMIT_WINDOW_MINUTES=60 BLACKLISTED_ROLES= ADDITIONAL_STAFF_ROLES= -# --- Auto-close / reminders --- +# --- Auto-close --- AUTO_CLOSE_ENABLED=false AUTO_CLOSE_AFTER_HOURS=72 AUTO_CLOSE_MESSAGE= + +# --- Reminders --- REMINDER_ENABLED=false REMINDER_AFTER_HOURS=24 REMINDER_MESSAGE= @@ -99,16 +112,16 @@ AUTO_UNCLAIM_ENABLED=false AUTO_UNCLAIM_AFTER_HOURS=24 ALLOW_CLAIM_OVERWRITE=false -# --- Thread (legacy) --- +# --- Thread-style tickets (legacy) --- USE_THREADS=false THREAD_PARENT_CHANNEL= -# --- Game list --- -GAME_LIST=Project Zomboid, Minecraft, ... - -# --- Embed colors --- +# --- Embed colors (hex with 0x prefix) --- EMBED_COLOR_OPEN=0x00FF00 EMBED_COLOR_CLOSED=0xFF0000 EMBED_COLOR_CLAIMED=0xFFFF00 EMBED_COLOR_ESCALATED=0xFF6600 EMBED_COLOR_INFO=0x1e2124 + +# --- Game list (comma-separated; used for detection and tags) --- +GAME_LIST=Project Zomboid, Minecraft diff --git a/config.js b/config.js index da84ccf..00f0fb2 100644 --- a/config.js +++ b/config.js @@ -64,6 +64,7 @@ const CONFIG = { EMAIL_THREAD_CHANNEL_ID: process.env.EMAIL_THREAD_CHANNEL_ID || null, EMAIL_ESCALATED_CATEGORY_ID: process.env.EMAIL_ESCALATED_CATEGORY_ID || process.env.ESCALATED_CATEGORY_ID, DISCORD_ESCALATED_CATEGORY_ID: process.env.DISCORD_ESCALATED_CATEGORY_ID, + // Tier 2/3 email escalation: category IDs where ticket channels are placed (env uses *_CHANNEL_* for legacy naming). EMAIL_ESCALATED2_CHANNEL_ID: process.env.EMAIL_ESCALATED2_CHANNEL_ID || null, DISCORD_ESCALATED2_CHANNEL_ID: process.env.DISCORD_ESCALATED2_CHANNEL_ID || null, EMAIL_ESCALATED3_CHANNEL_ID: process.env.EMAIL_ESCALATED3_CHANNEL_ID || null, diff --git a/handlers/buttons.js b/handlers/buttons.js index 9b5c690..5192011 100644 --- a/handlers/buttons.js +++ b/handlers/buttons.js @@ -20,6 +20,7 @@ const { canRename, makeTicketName, minutesFromMs, pickTicketCategoryId, createDi const { sendTicketClosedEmail } = require('../services/gmail'); const { getTicketActionRow } = require('../utils/ticketComponents'); const { setEmailRouting } = require('../services/guildSettings'); +const { enqueueRename } = require('../services/channelQueue'); const { runEscalation, runDeescalation } = require('./commands'); const { trackInteraction, trackError } = require('./analytics'); @@ -297,7 +298,7 @@ async function handleClaim(interaction, ticket) { guild ); try { - await interaction.channel.setName(newName); + await enqueueRename(interaction.channel, newName); } catch (e) { console.error('Rename error (claim):', e); } @@ -333,8 +334,10 @@ async function handleClaim(interaction, ticket) { .replace(/\{staff_mention\}/g, interaction.user.toString()) .replace(/\{staff_name\}/g, interaction.member?.displayName || interaction.user.username); const claimEmbed = new EmbedBuilder() + .setTitle('βœ… Ticket Claimed') .setDescription(claimText) - .setColor(CONFIG.EMBED_COLOR_INFO); + .setColor(CONFIG.EMBED_COLOR_CLAIMED) + .setFooter({ text: `Claimed by ${claimerLabel}` }); await interaction.followUp({ embeds: [claimEmbed] }); } else { // Unclaim @@ -352,7 +355,7 @@ async function handleClaim(interaction, ticket) { guild ); try { - await interaction.channel.setName(newName); + await enqueueRename(interaction.channel, newName); } catch (e) { console.error('Rename error (unclaim):', e); } @@ -383,8 +386,10 @@ async function handleClaim(interaction, ticket) { .replace(/\{staff_mention\}/g, interaction.user.toString()) .replace(/\{staff_name\}/g, interaction.member?.displayName || interaction.user.username); const unclaimEmbed = new EmbedBuilder() + .setTitle('πŸ”“ Ticket Unclaimed') .setDescription(unclaimText) - .setColor(CONFIG.EMBED_COLOR_INFO); + .setColor(0x808080) + .setFooter({ text: `Unclaimed by ${claimerLabel}` }); await interaction.followUp({ embeds: [unclaimEmbed] }); } } diff --git a/handlers/commands.js b/handlers/commands.js index ef2a2ea..5d58ecc 100644 --- a/handlers/commands.js +++ b/handlers/commands.js @@ -12,11 +12,12 @@ const { } = require('discord.js'); const { mongoose } = require('../db-connection'); const { CONFIG, TICKET_TAGS } = require('../config'); -const { getPriorityEmoji, replaceVariables, escapeRegex } = require('../utils'); +const { getPriorityEmoji, getPriorityColor, replaceVariables, escapeRegex } = require('../utils'); const { canRename, makeTicketName, pickTicketCategoryId, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets'); const { sendTicketNotificationEmail } = require('../services/gmail'); const { getTicketActionRow } = require('../utils/ticketComponents'); const { getEmailRouting } = require('../services/guildSettings'); +const { enqueueRename, enqueueMove } = require('../services/channelQueue'); const { trackInteraction, trackError, getAnalyticsSummary } = require('./analytics'); const { handleAccountInfoCommand } = require('./accountinfo'); const { handleSetupCommand } = require('./setup'); @@ -80,7 +81,7 @@ async function runEscalation(interaction, ticket, nextTier, reason) { interaction.guild ); try { - await interaction.channel.setName(newName); + await enqueueRename(interaction.channel, newName); } catch (e) { console.error('Rename error (escalate):', e); } @@ -93,7 +94,7 @@ async function runEscalation(interaction, ticket, nextTier, reason) { } if (!interaction.channel.isThread() && categoryId) { - await interaction.channel.setParent(categoryId, { lockPermissions: true }); + await enqueueMove(interaction.channel, categoryId); } const pendingEmbed = new EmbedBuilder() @@ -117,8 +118,10 @@ async function runEscalation(interaction, ticket, nextTier, reason) { .replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME) + (reason ? `\n\n**Reason:** ${reason}` : ''); const escalatedEmbed = new EmbedBuilder() + .setTitle(`🚨 Escalated to ${nextTier === 1 ? 'Tier 2' : 'Tier 3'} Support`) .setDescription(escalationBody) - .setColor(CONFIG.EMBED_COLOR_INFO); + .setColor(CONFIG.EMBED_COLOR_ESCALATED) + .setFooter({ text: `Escalated by ${interaction.member?.displayName || interaction.user.username}` }); const updatedTicketForRow = { ...ticket, escalationTier: nextTier, escalated: true }; const escalationRow = getTicketActionRow(updatedTicketForRow); await interaction.channel.send({ @@ -193,7 +196,7 @@ async function runDeescalation(interaction, ticket) { interaction.guild ); try { - await interaction.channel.setName(newName); + await enqueueRename(interaction.channel, newName); } catch (e) { console.error('Rename error (deescalate):', e); } @@ -206,12 +209,16 @@ async function runDeescalation(interaction, ticket) { } if (!interaction.channel.isThread() && categoryId) { - await interaction.channel.setParent(categoryId, { lockPermissions: true }); + await enqueueMove(interaction.channel, categoryId); } const tierLabel = newTier === 0 ? 'normal' : newTier === 1 ? 'tier 2' : 'tier 3'; + const deescalateEmbed = new EmbedBuilder() + .setColor(0x00BFFF) + .setTitle(`βœ… De-escalated to ${tierLabel} Support`) + .setFooter({ text: interaction.member?.displayName || interaction.user.username }); await interaction.reply({ - content: `Ticket de‑escalated to **${tierLabel}** support.`, + embeds: [deescalateEmbed], ephemeral: true }); @@ -737,7 +744,16 @@ async function handleCommand(interaction) { { $set: { priority: level } } ); - await interaction.reply(channelMessage); + const priorityTitle = + newIdx === oldIdx + ? 'Priority Set' + : `Priority ${newIdx > oldIdx ? 'Upgraded' : 'Downgraded'} β†’ ${levelLabel}`; + const priorityEmbed = new EmbedBuilder() + .setTitle(priorityTitle) + .setDescription(channelMessage) + .setColor(getPriorityColor(level)) + .setFooter({ text: interaction.member?.displayName || interaction.user.username }); + await interaction.reply({ embeds: [priorityEmbed] }); if (level === 'high' && ticket.gmailThreadId && !ticket.gmailThreadId.startsWith('discord-')) { await sendTicketNotificationEmail( @@ -1139,11 +1155,4 @@ async function handleAutocomplete(interaction) { } } -module.exports = { - handleCommand, - handleContextMenu, - handleAutocomplete, - runEscalation, - runDeescalation, - hasStaffRole -}; +module.exports = { handleCommand, handleContextMenu, handleAutocomplete, runEscalation, runDeescalation }; diff --git a/handlers/messages.js b/handlers/messages.js index 2f43855..95af410 100644 --- a/handlers/messages.js +++ b/handlers/messages.js @@ -1,91 +1,20 @@ /** - * Discord messageCreate handler – prefix commands and forwards staff replies to Gmail. + * Discord messageCreate handler – forwards staff replies to Gmail. */ -const { ChannelType, AttachmentBuilder } = require('discord.js'); const { mongoose } = require('../db-connection'); const { CONFIG } = require('../config'); const { extractRawEmail } = require('../utils'); const { getGmailClient, sendGmailReply } = require('../services/gmail'); const { updateTicketActivity } = require('../services/tickets'); -const { hasStaffRole } = require('./commands'); -const { trackInteraction, trackError } = require('./analytics'); const Ticket = mongoose.model('Ticket'); -/** - * `!ids` β€” list channel and role IDs (same rules as slash staff-gated commands). - * @returns {Promise} true if the message was consumed - */ -async function tryHandleIdsCommand(m) { - if (m.content?.trim().toLowerCase() !== '!ids') return false; - - if (!m.guild) { - await m.reply('This command can only be used in a server.').catch(() => {}); - return true; - } - - const staffConfigured = - CONFIG.ROLE_ID_TO_PING || (CONFIG.ADDITIONAL_STAFF_ROLES && CONFIG.ADDITIONAL_STAFF_ROLES.length > 0); - if (staffConfigured && !hasStaffRole(m.member)) { - const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'support'; - await m.reply({ - content: `This command is only available to the support team (${roleMention}).`, - allowedMentions: { users: [], roles: [], repliedUser: false } - }).catch(() => {}); - return true; - } - - trackInteraction('commands', 'ids-prefix', m.author.tag); - - try { - await m.guild.channels.fetch().catch(() => {}); - - const channelTypeName = type => - Object.entries(ChannelType).find(([, v]) => v === type)?.[0] ?? String(type); - - const lines = ['**Channels:**']; - const channels = [...m.guild.channels.cache.values()].sort((a, b) => a.rawPosition - b.rawPosition); - for (const ch of channels) { - lines.push(`\`${ch.id}\` β€” ${ch.name} (${channelTypeName(ch.type)})`); - } - lines.push(''); - lines.push('**Roles:**'); - const roles = [...m.guild.roles.cache.values()].sort((a, b) => b.position - a.position); - for (const role of roles) { - lines.push(`\`${role.id}\` β€” ${role.name}`); - } - - const text = lines.join('\n'); - const baseReply = { allowedMentions: { users: [], roles: [], repliedUser: false } }; - if (text.length <= 1900) { - await m.reply({ content: text, ...baseReply }).catch(() => {}); - } else { - const buf = Buffer.from(text, 'utf8'); - await m.reply({ - content: 'List is long; sent as a file.', - files: [new AttachmentBuilder(buf, { name: `guild-ids-${m.guild.id}.txt` })], - ...baseReply - }).catch(() => {}); - } - } catch (err) { - trackError('ids-prefix-command', err, null); - await m.reply({ - content: 'Failed to build ID list.', - allowedMentions: { repliedUser: false } - }).catch(() => {}); - } - - return true; -} - /** * Handle a Discord message in a ticket channel β†’ relay to Gmail (email tickets only). */ async function handleDiscordReply(m) { if (m.author.bot || m.interaction) return; - if (await tryHandleIdsCommand(m)) return; - const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean(); if (!ticket) return; diff --git a/package.json b/package.json index e0487f2..55d47ed 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "dependencies": { + "p-queue": "^6.6.2", "discord.js": "^14.25.1", "dotenv": "^17.2.4", "dotenv-expand": "^11.0.6", diff --git a/services/channelQueue.js b/services/channelQueue.js new file mode 100644 index 0000000..6913121 --- /dev/null +++ b/services/channelQueue.js @@ -0,0 +1,20 @@ +/** + * Serialized channel renames/moves to avoid Discord rate limits (e.g. 2 renames / 10 min per channel). + */ +const PQueue = require('p-queue').default; + +const channelQueue = new PQueue({ + concurrency: 1, + intervalCap: 2, + interval: 10000 +}); + +function enqueueRename(channel, newName) { + return channelQueue.add(() => channel.setName(newName)); +} + +function enqueueMove(channel, categoryId) { + return channelQueue.add(() => channel.setParent(categoryId, { lockPermissions: true })); +} + +module.exports = { channelQueue, enqueueRename, enqueueMove }; diff --git a/services/guildSettings.js b/services/guildSettings.js index 016b0ee..1a3cb4c 100644 --- a/services/guildSettings.js +++ b/services/guildSettings.js @@ -2,20 +2,19 @@ * Guild-specific settings (e.g. email ticket routing). */ const { mongoose } = require('../db-connection'); -const { CONFIG } = require('../config'); const GuildSettings = mongoose.model('GuildSettings'); /** * Get email ticket routing for a guild. Returns 'thread' or 'category'. - * If not set, defaults from CONFIG: thread if EMAIL_THREAD_CHANNEL_ID is set, else category. + * If not set, defaults to 'category'. * @param {string} guildId * @returns {Promise<'thread'|'category'>} */ async function getEmailRouting(guildId) { const doc = await GuildSettings.findOne({ guildId }).select('emailRouting').lean(); if (doc && doc.emailRouting) return doc.emailRouting; - return CONFIG.EMAIL_THREAD_CHANNEL_ID ? 'thread' : 'category'; + return 'category'; } /**