From f3ee27ed7ac99b5ebc7c696ea970b80d3c4ed5a0 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Tue, 21 Apr 2026 17:24:03 +0000 Subject: [PATCH] more mvp strip --- .env.example | 6 - broccolini-discord.js | 26 -- commands/register.js | 14 - config.js | 6 +- git | 0 gmail-poll.js | 39 +- handlers/buttons.js | 107 ++--- handlers/commands.js | 111 ++--- handlers/messages.js | 3 - handlers/messages.js.bak3-20260421 | 106 +++++ handlers/setup.js | 656 ----------------------------- models.js | 6 - services/configSchema.js | 1 - services/gmail.js | 50 +-- services/gmail.js.bak3-20260421 | 346 +++++++++++++++ services/guildSettings.js | 33 -- services/tickets.js | 176 ++------ 17 files changed, 598 insertions(+), 1088 deletions(-) delete mode 100644 git create mode 100644 handlers/messages.js.bak3-20260421 delete mode 100644 handlers/setup.js create mode 100644 services/gmail.js.bak3-20260421 delete mode 100644 services/guildSettings.js diff --git a/.env.example b/.env.example index 003e3f2..8147bcb 100644 --- a/.env.example +++ b/.env.example @@ -13,8 +13,6 @@ DISCORD_GUILD_ID= # Server (guild) ID where the bot runs # Ticket creation: set one or both; /panel and /email-routing choose behavior DISCORD_TICKET_CATEGORY_ID= # Category for Discord-originated ticket channels TICKET_CATEGORY_ID= # Category for email-originated ticket channels -DISCORD_THREAD_CHANNEL_ID= # Text channel for Discord ticket threads (optional) -EMAIL_THREAD_CHANNEL_ID= # Text channel for email ticket threads (optional) # Category display names (primary must match the category name in Discord; overflow folders are created as "{name} (Overflow 1)", etc.) TICKET_CATEGORY_NAME=Open Tickets @@ -137,10 +135,6 @@ SETTINGS_DOMAIN=tickets.indifferentketchup.com # Domain for the settings site ( INTERNAL_API_PORT=12753 # Internal port for bot<->settings IPC (not exposed externally) INTERNAL_API_SECRET= # Shared secret between bot and settings site (generate a random string) -# --- Thread-style tickets (legacy) --- -USE_THREADS=false -THREAD_PARENT_CHANNEL= - # --- Game list (comma-separated; used for detection and tags) --- GAME_LIST=Project Zomboid, Minecraft, ... diff --git a/broccolini-discord.js b/broccolini-discord.js index 1daaff2..626be6c 100644 --- a/broccolini-discord.js +++ b/broccolini-discord.js @@ -11,7 +11,6 @@ const { mongoose } = require('./db-connection'); // Handlers const { handleButton, handleTicketModal } = require('./handlers/buttons'); const { handleCommand, handleContextMenu, handleAutocomplete } = require('./handlers/commands'); -const { handleSetupButton, handleSetupModal, handleSetupSelect, PREFIX_BUTTON: SETUP_BUTTON_PREFIX, PREFIX_MODAL: SETUP_MODAL_PREFIX, PREFIX_SELECT: SETUP_SELECT_PREFIX } = require('./handlers/setup'); const { handleDiscordReply } = require('./handlers/messages'); // Services & jobs @@ -113,30 +112,10 @@ async function runHandler(name, interaction, fn) { } client.on('interactionCreate', async interaction => { - if (interaction.isButton() && interaction.customId.startsWith(SETUP_BUTTON_PREFIX)) { - try { - const handled = await handleSetupButton(interaction); - if (handled) return; - } catch (err) { - console.error('Setup button error:', err); - logError('handleSetupButton', err, null, client).catch(() => {}); - await interaction.reply({ - content: `Setup error: ${err.message}. Try \`/setup\` again.`, - ephemeral: true - }).catch(() => {}); - return; - } - } - if (interaction.isButton()) { return runHandler('handleButton', interaction, () => handleButton(interaction)); } - if (interaction.isModalSubmit() && interaction.customId.startsWith(SETUP_MODAL_PREFIX)) { - const handled = await runHandler('handleSetupModal', interaction, () => handleSetupModal(interaction)); - if (handled) return; - } - if (interaction.isModalSubmit() && interaction.customId.startsWith('signature_modal_')) { // Handle signature modal submit try { @@ -176,11 +155,6 @@ client.on('interactionCreate', async interaction => { return runHandler('handleTicketModal', interaction, () => handleTicketModal(interaction)); } - if (interaction.customId?.startsWith(SETUP_SELECT_PREFIX) && (interaction.isRoleSelectMenu() || interaction.isChannelSelectMenu())) { - const handled = await runHandler('handleSetupSelect', interaction, () => handleSetupSelect(interaction)); - if (handled) return; - } - if (interaction.isChatInputCommand()) { return runHandler('handleCommand', interaction, () => handleCommand(interaction)); } diff --git a/commands/register.js b/commands/register.js index 436b5cb..8fb247e 100644 --- a/commands/register.js +++ b/commands/register.js @@ -205,13 +205,6 @@ async function registerCommands() { InteractionContextType.PrivateChannel ]), - new SlashCommandBuilder() - .setName('setup') - .setDescription('Run the panel setup wizard (name, support role, category, transcript channel, panel channel)') - .setContexts([InteractionContextType.Guild]) - .setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), - new SlashCommandBuilder() .setName('panel') .setDescription('Create a ticket panel for users to open Discord tickets') @@ -253,13 +246,6 @@ async function registerCommands() { .setRequired(false) ), - new SlashCommandBuilder() - .setName('email-routing') - .setDescription('Switch where new email tickets are created: threads or category channels') - .setContexts([InteractionContextType.Guild]) - .setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), - new SlashCommandBuilder() .setName('notifydm') .setDescription('Toggle DM notifications when your ticket receives a customer reply.') diff --git a/config.js b/config.js index dca6b7c..89ec59e 100644 --- a/config.js +++ b/config.js @@ -53,12 +53,12 @@ const CONFIG = { MY_EMAIL: (process.env.MY_EMAIL || '').toLowerCase(), LOGO_URL: process.env.LOGO_URL, SUPPORT_NAME: process.env.SUPPORT_NAME || 'Support', + STAFF_EMOJIS: Object.fromEntries((process.env.STAFF_EMOJIS||'').split(',').map(s=>s.trim()).filter(Boolean).map(p=>{const i=p.indexOf(':');return i===-1?null:[p.slice(0,i).trim(),p.slice(i+1).trim()];}).filter(Boolean)), + CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫', PORT: toInt(process.env.DISCORD_ONLY_PORT, 5000), HEALTHCHECK_HOST: process.env.HEALTHCHECK_HOST || null, // null = listen on all interfaces; set to 127.0.0.1 for local-only SIGNATURE: (process.env.EMAIL_SIGNATURE || '').trim().replace(/\\n/g, '\n'), GAME_LIST: process.env.GAME_LIST || '', - DISCORD_THREAD_CHANNEL_ID: process.env.DISCORD_THREAD_CHANNEL_ID || null, - EMAIL_THREAD_CHANNEL_ID: process.env.EMAIL_THREAD_CHANNEL_ID || null, // Tier 2/3 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, @@ -96,8 +96,6 @@ const CONFIG = { AUTO_UNCLAIM_ENABLED: process.env.AUTO_UNCLAIM_ENABLED === 'true', AUTO_UNCLAIM_AFTER_HOURS: toInt(process.env.AUTO_UNCLAIM_AFTER_HOURS, 24), ALLOW_CLAIM_OVERWRITE: process.env.ALLOW_CLAIM_OVERWRITE === 'true', - USE_THREADS: process.env.USE_THREADS === 'true', - THREAD_PARENT_CHANNEL: process.env.THREAD_PARENT_CHANNEL || process.env.EMAIL_THREAD_CHANNEL_ID || null, BUTTON_LABEL_CLOSE: process.env.BUTTON_LABEL_CLOSE || 'Close Ticket', BUTTON_LABEL_CLAIM: process.env.BUTTON_LABEL_CLAIM || 'Claim', BUTTON_LABEL_UNCLAIM: process.env.BUTTON_LABEL_UNCLAIM || 'Unclaim', diff --git a/git b/git deleted file mode 100644 index e69de29..0000000 diff --git a/gmail-poll.js b/gmail-poll.js index 00b1877..04d529f 100644 --- a/gmail-poll.js +++ b/gmail-poll.js @@ -20,8 +20,7 @@ const { sanitizeEmbedText } = require('./utils'); const { getGmailClient } = require('./services/gmail'); -const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, createEmailTicketAsThread, toDiscordSafeName, getSenderLocal } = require('./services/tickets'); -const { getEmailRouting } = require('./services/guildSettings'); +const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDiscordSafeName, getSenderLocal } = require('./services/tickets'); const { logError, logGmail, logAutomation } = require('./services/debugLog'); const { enqueueSend } = require('./services/channelQueue'); @@ -192,27 +191,21 @@ async function poll(client) { const chanName = toDiscordSafeName(`unclaimed-${creatorNickname}-${number}`); try { - const routing = await getEmailRouting(guild.id); - if (routing === 'thread' && CONFIG.EMAIL_THREAD_CHANNEL_ID) { - ticketChan = await createEmailTicketAsThread(guild, number, chanName); - parentCategoryIdForTicket = ticketChan.parent?.parentId ?? null; - } else { - const parentId = await getOrCreateTicketCategory( - guild, - CONFIG.TICKET_CATEGORY_ID, - CONFIG.TICKET_CATEGORY_NAME - ); - parentCategoryIdForTicket = parentId; - try { - ticketChan = await guild.channels.create({ - name: chanName, - type: ChannelType.GuildText, - parent: parentId - }); - } catch (createErr) { - console.error('Channel create error (email ticket):', createErr); - throw createErr; - } + const parentId = await getOrCreateTicketCategory( + guild, + CONFIG.TICKET_CATEGORY_ID, + CONFIG.TICKET_CATEGORY_NAME + ); + parentCategoryIdForTicket = parentId; + try { + ticketChan = await guild.channels.create({ + name: chanName, + type: ChannelType.GuildText, + parent: parentId + }); + } catch (createErr) { + console.error('Channel create error (email ticket):', createErr); + throw createErr; } } catch (err) { console.error('Channel create error (payload):', { diff --git a/handlers/buttons.js b/handlers/buttons.js index 7656f41..a61eccc 100644 --- a/handlers/buttons.js +++ b/handlers/buttons.js @@ -16,11 +16,10 @@ const { } = require('discord.js'); const { mongoose } = require('../db-connection'); const { CONFIG } = require('../config'); -const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit, getSenderLocal, toDiscordSafeName } = require('../services/tickets'); +const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, getSenderLocal, toDiscordSafeName } = require('../services/tickets'); const { sendTicketClosedEmail } = require('../services/gmail'); const { getTicketActionRow } = require('../utils/ticketComponents'); const { sanitizeEmbedText, truncateEmbedDescription, truncateEmbedField, enforceEmbedLimit } = require('../utils'); -const { setEmailRouting } = require('../services/guildSettings'); const { enqueueRename, enqueueSend } = require('../services/channelQueue'); const { runEscalation, runDeescalation } = require('./commands'); const { pendingCloses } = require('./pendingCloses'); @@ -78,26 +77,6 @@ async function handleButton(interaction) { return await interaction.showModal(modal); } - // --- Email routing (no ticket required) --- - if (interaction.customId === 'email_routing_thread' || interaction.customId === 'email_routing_category') { - const value = interaction.customId === 'email_routing_thread' ? 'thread' : 'category'; - try { - await setEmailRouting(interaction.guild.id, value); - const label = value === 'thread' ? '**threads**' : '**channels in a category**'; - await interaction.reply({ - content: `Done. New email tickets will now be created as ${label}.`, - ephemeral: true - }); - } catch (err) { - logError('email-routing-button', err, interaction).catch(() => {}); - await interaction.reply({ - content: 'Failed to update email routing.', - ephemeral: true - }).catch(() => {}); - } - return; - } - // --- Ticket-scoped buttons (need ticket lookup) --- const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); if (!ticket) { @@ -339,7 +318,7 @@ async function handleClaim(interaction, ticket) { freshTicket.claimedBy = claimerLabel; freshTicket.claimerId = interaction.user.id; - const claimerEmoji = '🎫'; + const claimerEmoji = CONFIG.STAFF_EMOJIS[interaction.user.id] || CONFIG.CLAIMER_EMOJI_FALLBACK; const creatorNickname = await resolveCreatorNickname(guild, freshTicket); const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed'; @@ -590,10 +569,6 @@ async function handleTicketModal(interaction) { const subject = game ? `[${game}] ${description.slice(0, 60)}` : description.slice(0, 80); const priority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal'; - const useThread = - interaction.customId === 'ticket_modal_thread' || - (interaction.customId === 'ticket_modal' && !!CONFIG.DISCORD_THREAD_CHANNEL_ID); - const rateLimit = checkTicketCreationRateLimit(interaction.user.id); if (!rateLimit.allowed) { const mins = Math.ceil((rateLimit.retryAfterMs || 0) / 60000); @@ -610,51 +585,39 @@ async function handleTicketModal(interaction) { let channel; let parentCategoryIdForTicket = null; - if (useThread && CONFIG.DISCORD_THREAD_CHANNEL_ID) { - try { - channel = await createDiscordTicketAsThread(guild, ticketNumber, interaction.user.id); - parentCategoryIdForTicket = channel.parent?.parentId ?? null; - } catch (err) { - console.error('Discord ticket thread create failed:', err.message); - return interaction.editReply('Could not create ticket thread. Check DISCORD_THREAD_CHANNEL_ID and try again.'); - } - } else if (useThread && !CONFIG.DISCORD_THREAD_CHANNEL_ID) { - return interaction.editReply('Thread tickets are not configured (DISCORD_THREAD_CHANNEL_ID is not set). Use a channel panel or set the env variable.'); - } else { - let parentId; - try { - parentId = await getOrCreateTicketCategory( - guild, - CONFIG.DISCORD_TICKET_CATEGORY_ID, - CONFIG.TICKET_CATEGORY_NAME - ); - } catch (err) { - console.error('getOrCreateTicketCategory (ticket modal):', err); - return interaction.editReply('Discord ticket category could not be resolved. Contact an administrator.'); - } - parentCategoryIdForTicket = parentId; - try { - // TODO(queue-migrate): initial permissionOverwrites here are fine since the channel is just being created, but any later permissionOverwrites mutation on this channel should go through channelQueue. - channel = await guild.channels.create({ - name: unclaimedName, - type: ChannelType.GuildText, - parent: parentId, - permissionOverwrites: [ - { id: guild.id, deny: [PermissionFlagsBits.ViewChannel] }, - { - id: interaction.user.id, - allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] - }, - { - id: CONFIG.ROLE_ID_TO_PING, - allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] - } - ] - }); - } catch (err) { - console.error('guild.channels.create (ticket modal):', err); - return interaction.editReply('Failed to create ticket channel. Contact an administrator.'); - } + let parentId; + try { + parentId = await getOrCreateTicketCategory( + guild, + CONFIG.DISCORD_TICKET_CATEGORY_ID, + CONFIG.TICKET_CATEGORY_NAME + ); + } catch (err) { + console.error('getOrCreateTicketCategory (ticket modal):', err); + return interaction.editReply('Discord ticket category could not be resolved. Contact an administrator.'); + } + parentCategoryIdForTicket = parentId; + try { + // TODO(queue-migrate): initial permissionOverwrites here are fine since the channel is just being created, but any later permissionOverwrites mutation on this channel should go through channelQueue. + channel = await guild.channels.create({ + name: unclaimedName, + type: ChannelType.GuildText, + parent: parentId, + permissionOverwrites: [ + { id: guild.id, deny: [PermissionFlagsBits.ViewChannel] }, + { + id: interaction.user.id, + allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] + }, + { + id: CONFIG.ROLE_ID_TO_PING, + allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] + } + ] + }); + } catch (err) { + console.error('guild.channels.create (ticket modal):', err); + return interaction.editReply('Failed to create ticket channel. Contact an administrator.'); } const gmailThreadId = `discord-${Date.now()}-${interaction.user.id}`; diff --git a/handlers/commands.js b/handlers/commands.js index 282805a..0b18279 100644 --- a/handlers/commands.js +++ b/handlers/commands.js @@ -13,14 +13,12 @@ const { const { mongoose } = require('../db-connection'); const { CONFIG } = require('../config'); const { getPriorityEmoji, replaceVariables } = require('../utils'); -const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets'); +const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, checkTicketCreationRateLimit } = require('../services/tickets'); const { sendTicketNotificationEmail } = require('../services/gmail'); const { getTicketActionRow } = require('../utils/ticketComponents'); -const { getEmailRouting } = require('../services/guildSettings'); const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channelQueue'); const { setNotifyDm } = require('../services/staffSettings'); const { logTicketEvent, logSecurity, logError } = require('../services/debugLog'); -const { handleSetupCommand } = require('./setup'); const { pendingCloses } = require('./pendingCloses'); const Ticket = mongoose.model('Ticket'); @@ -221,39 +219,6 @@ async function handleCommand(interaction) { // Only /help can be used by everyone; all other commands require staff role (ROLE_ID_TO_PING / ADDITIONAL_STAFF_ROLES) if (interaction.commandName !== 'help' && (await requireStaffRole(interaction))) return; - // /setup - if (interaction.commandName === 'setup') { - return handleSetupCommand(interaction); - } - - // /email-routing – switch where new email tickets are created (thread vs category) - if (interaction.commandName === 'email-routing') { - await interaction.deferReply({ ephemeral: true }); - try { - const current = await getEmailRouting(interaction.guild.id); - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('email_routing_thread') - .setLabel('Threads') - .setStyle(ButtonStyle.Primary) - .setEmoji('🧡'), - new ButtonBuilder() - .setCustomId('email_routing_category') - .setLabel('Category channels') - .setStyle(ButtonStyle.Primary) - .setEmoji('πŸ“') - ); - await interaction.editReply({ - content: `Email ticket routing: **${current}**. Choose where new email tickets should be created:`, - components: [row] - }); - } catch (err) { - logError('email-routing-command', err, interaction).catch(() => {}); - await interaction.editReply('Failed to load routing options.').catch(() => {}); - } - return; - } - // /escalate (tier 2 or 3 via level; works for both email and Discord). Always unclaims on escalate. if (interaction.commandName === 'escalate') { const reason = null; @@ -926,48 +891,38 @@ async function handleContextMenu(interaction) { let channel; let parentCategoryIdForTicket = null; - if (CONFIG.DISCORD_THREAD_CHANNEL_ID) { - try { - channel = await createDiscordTicketAsThread(guild, ticketNumber, message.author.id); - parentCategoryIdForTicket = channel.parent?.parentId ?? null; - } catch (err) { - console.error('Discord ticket thread create (from message) failed:', err.message); - return interaction.editReply('❌ Could not create ticket thread. Check DISCORD_THREAD_CHANNEL_ID.'); - } - } else { - let parentId; - try { - parentId = await getOrCreateTicketCategory( - guild, - CONFIG.DISCORD_TICKET_CATEGORY_ID, - CONFIG.TICKET_CATEGORY_NAME - ); - } catch (err) { - console.error('getOrCreateTicketCategory (context menu ticket):', err); - return interaction.editReply('❌ Discord ticket category could not be resolved. Contact an administrator.'); - } - parentCategoryIdForTicket = parentId; - try { - channel = await guild.channels.create({ - name: `ticket-${ticketNumber}`, - type: ChannelType.GuildText, - parent: parentId, - permissionOverwrites: [ - { id: guild.id, deny: [PermissionFlagsBits.ViewChannel] }, - { - id: message.author.id, - allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] - }, - { - id: CONFIG.ROLE_ID_TO_PING, - allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] - } - ] - }); - } catch (err) { - console.error('guild.channels.create (context menu ticket):', err); - return interaction.editReply('❌ Failed to create ticket channel. Contact an administrator.'); - } + let parentId; + try { + parentId = await getOrCreateTicketCategory( + guild, + CONFIG.DISCORD_TICKET_CATEGORY_ID, + CONFIG.TICKET_CATEGORY_NAME + ); + } catch (err) { + console.error('getOrCreateTicketCategory (context menu ticket):', err); + return interaction.editReply('❌ Discord ticket category could not be resolved. Contact an administrator.'); + } + parentCategoryIdForTicket = parentId; + try { + channel = await guild.channels.create({ + name: `ticket-${ticketNumber}`, + type: ChannelType.GuildText, + parent: parentId, + permissionOverwrites: [ + { id: guild.id, deny: [PermissionFlagsBits.ViewChannel] }, + { + id: message.author.id, + allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] + }, + { + id: CONFIG.ROLE_ID_TO_PING, + allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] + } + ] + }); + } catch (err) { + console.error('guild.channels.create (context menu ticket):', err); + return interaction.editReply('❌ Failed to create ticket channel. Contact an administrator.'); } const gmailThreadId = `discord-msg-${Date.now()}-${message.id}`; diff --git a/handlers/messages.js b/handlers/messages.js index f7cc6a8..b2e4b59 100644 --- a/handlers/messages.js +++ b/handlers/messages.js @@ -43,8 +43,6 @@ async function handleDiscordReply(m) { } } - const discordUser = m.member?.displayName || m.author.username; - if (ticket.gmailThreadId.startsWith('discord-')) { return; } @@ -88,7 +86,6 @@ async function handleDiscordReply(m) { m.content, recipientEmail, subject, - discordUser, msgId, m.author.id ); diff --git a/handlers/messages.js.bak3-20260421 b/handlers/messages.js.bak3-20260421 new file mode 100644 index 0000000..0bd7666 --- /dev/null +++ b/handlers/messages.js.bak3-20260421 @@ -0,0 +1,106 @@ +/** + * Discord messageCreate handler – forwards staff replies to Gmail. + */ +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 { getNotifyDm } = require('../services/staffSettings'); + +const Ticket = mongoose.model('Ticket'); + +/** + * 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; + + const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean(); + if (!ticket) return; + + // Track whether last message is from staff or customer + const memberForCheck = await m.guild.members.fetch(m.author.id).catch(() => null); + const isStaffMember = memberForCheck && CONFIG.ROLE_ID_TO_PING && memberForCheck.roles.cache.has(CONFIG.ROLE_ID_TO_PING); + Ticket.updateOne( + { discordThreadId: m.channel.id }, + { $set: { lastMessageAuthorIsStaff: !!isStaffMember, lastActivity: new Date() } } + ).catch(() => {}); + + // DM the claimer if they have notifydm on and a non-staff user replied. + if (ticket.claimerId && !isStaffMember && m.author.id !== ticket.claimerId) { + const dmEnabled = await getNotifyDm(ticket.claimerId); + if (dmEnabled) { + const staffMember = await m.guild.members.fetch(ticket.claimerId).catch(() => null); + if (staffMember) { + const jumpLink = `https://discord.com/channels/${m.guild.id}/${m.channel.id}/${m.id}`; + await staffMember + .send( + `New customer reply in **${m.channel.name}**:\n> ${m.content.slice(0, 300)}\n[Jump to message](${jumpLink})` + ) + .catch(() => {}); + } + } + } + + const authorName = + m.member?.displayName || + m.member?.nickname || + m.author.globalName || + m.author.username; + + if (ticket.gmailThreadId.startsWith('discord-')) { + return; + } + + // Email tickets: send reply via Gmail. + try { + const gmail = getGmailClient(); + const thread = await gmail.users.threads.get({ + userId: 'me', + id: ticket.gmailThreadId + }); + + const last = [...thread.data.messages].reverse().find(msg => { + const from = + msg.payload.headers.find(h => h.name === 'From')?.value || ''; + return !from.toLowerCase().includes(CONFIG.MY_EMAIL); + }); + + if (!last) return; + + let recipient = + last.payload.headers.find(h => h.name === 'From')?.value || ''; + const replyTo = + last.payload.headers.find(h => h.name === 'Reply-To')?.value; + if (replyTo) recipient = replyTo; + + const subject = + last.payload.headers.find(h => h.name === 'Subject')?.value || + 'Support'; + const msgId = + last.payload.headers.find(h => h.name === 'Message-ID')?.value; + + const recipientEmail = extractRawEmail(recipient).toLowerCase(); + if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) { + console.warn('Bad recipient for reply:', recipientEmail); + return; + } + + await sendGmailReply( + ticket.gmailThreadId, + m.content, + recipientEmail, + subject, + authorName, + msgId, + m.author.id + ); + + await updateTicketActivity(ticket.gmailThreadId); + } catch (e) { + console.error('REPLY ERROR:', e); + } +} + +module.exports = { handleDiscordReply }; diff --git a/handlers/setup.js b/handlers/setup.js deleted file mode 100644 index c40031f..0000000 --- a/handlers/setup.js +++ /dev/null @@ -1,656 +0,0 @@ -/** - * /setup wizard – multi-step panel configuration (panel name, support role, - * ticket category, transcript channel, panel channel). - */ -const { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, - EmbedBuilder, - ChannelType, - ModalBuilder, - TextInputBuilder, - TextInputStyle, - RoleSelectMenuBuilder, - ChannelSelectMenuBuilder -} = require('discord.js'); -const { CONFIG } = require('../config'); -const { enqueueSend } = require('../services/channelQueue'); - -const TOTAL_STEPS = 5; -const WIZARD_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes - -/** @type {Map} */ -const setupState = new Map(); - -const PREFIX = 'setup_'; -const PREFIX_BUTTON = PREFIX; -const PREFIX_MODAL = PREFIX + 'modal_'; -const PREFIX_SELECT = PREFIX + 'select_'; - -function getState(userId) { - const s = setupState.get(userId); - if (!s) return null; - if (Date.now() - s.createdAt > WIZARD_TIMEOUT_MS) { - setupState.delete(userId); - return null; - } - return s; -} - -function setState(userId, data) { - const existing = setupState.get(userId) || { createdAt: Date.now() }; - setupState.set(userId, { ...existing, ...data }); -} - -function clearState(userId) { - setupState.delete(userId); -} - -function step1Embed(panelName) { - const embed = new EmbedBuilder() - .setColor(0x2ecc71) - .setTitle('Step 1/5 Set the panel name') - .setDescription( - 'Use the button to set the panel name and continue.\n(This can be changed later.)' - ) - .addFields({ name: 'Current Name', value: panelName ? `\`${panelName}\`` : 'Not set' }); - - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(PREFIX_BUTTON + 'setname') - .setLabel('Set name') - .setStyle(ButtonStyle.Primary) - .setEmoji('βš™οΈ'), - new ButtonBuilder() - .setCustomId(PREFIX_BUTTON + 'continue_1') - .setLabel('Save & Continue') - .setStyle(ButtonStyle.Success) - .setDisabled(!panelName) - ); - return { embeds: [embed], components: [row] }; -} - -function step2Embed(roleLabels) { - const embed = new EmbedBuilder() - .setColor(0x2ecc71) - .setTitle('Step 2/5 Select the support team role(s)') - .setDescription( - 'The support roles will be automatically added to this panel\'s tickets so they can assist people as needed.\n' + - 'Use the dropdown to select roles.\n' + - 'Not seeing your role? Try searching for it inside the dropdown.' - ) - .addFields({ - name: 'Selected Role(s)', - value: roleLabels && roleLabels.length ? roleLabels.join(', ') : 'None selected' - }); - - const select = new RoleSelectMenuBuilder() - .setCustomId(PREFIX_SELECT + 'roles') - .setPlaceholder('Select all the roles for your support team') - .setMinValues(1) - .setMaxValues(5); - - const row1 = new ActionRowBuilder().addComponents(select); - const row2 = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(PREFIX_BUTTON + 'back_2') - .setLabel('Back') - .setStyle(ButtonStyle.Secondary) - .setEmoji('⬅️'), - new ButtonBuilder() - .setCustomId(PREFIX_BUTTON + 'continue_2') - .setLabel('Save & Continue') - .setStyle(ButtonStyle.Success) - .setDisabled(!roleLabels || roleLabels.length === 0) - ); - return { embeds: [embed], components: [row1, row2] }; -} - -function step3Embed(state) { - const ticketType = state.ticketType; - const categoryName = state.categoryName; - const threadChannelName = state.threadChannelName; - - if (!ticketType) { - const embed = new EmbedBuilder() - .setColor(0x2ecc71) - .setTitle('Step 3/5 How should tickets be created?') - .setDescription( - '**Channels:** Each ticket is a channel in a category (classic layout).\n' + - '**Threads:** Each ticket is a private thread under a text channel (compact).\n' + - '**Both:** Create one panel with two buttons (thread + category).' - ) - .addFields({ name: 'Choice', value: 'Select below' }); - - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(PREFIX_BUTTON + 'tickettype_channel') - .setLabel('Channels in category') - .setStyle(ButtonStyle.Primary) - .setEmoji('πŸ“'), - new ButtonBuilder() - .setCustomId(PREFIX_BUTTON + 'tickettype_thread') - .setLabel('Private threads') - .setStyle(ButtonStyle.Primary) - .setEmoji('🧡'), - new ButtonBuilder() - .setCustomId(PREFIX_BUTTON + 'tickettype_both') - .setLabel('Both (thread + category)') - .setStyle(ButtonStyle.Primary) - .setEmoji('πŸ“‹'), - new ButtonBuilder() - .setCustomId(PREFIX_BUTTON + 'back_3') - .setLabel('Back') - .setStyle(ButtonStyle.Secondary) - .setEmoji('⬅️') - ); - return { embeds: [embed], components: [row] }; - } - - if (ticketType === 'both') { - const embed = new EmbedBuilder() - .setColor(0x2ecc71) - .setTitle('Step 3/5 Select category and thread channel (both)') - .setDescription( - 'The panel will have two buttons: one creates ticket **threads**, one creates ticket **channels**.\n' + - 'Select the category for channels and the text channel for threads.' - ) - .addFields( - { name: 'Category (for channels)', value: categoryName ? `\`${categoryName}\`` : 'None selected', inline: true }, - { name: 'Channel (for threads)', value: threadChannelName ? `\`${threadChannelName}\`` : 'None selected', inline: true } - ); - - const row1 = new ActionRowBuilder().addComponents( - new ChannelSelectMenuBuilder() - .setCustomId(PREFIX_SELECT + 'category') - .setPlaceholder('Select category for channels') - .addChannelTypes(ChannelType.GuildCategory) - .setMaxValues(1) - ); - const row2 = new ActionRowBuilder().addComponents( - new ChannelSelectMenuBuilder() - .setCustomId(PREFIX_SELECT + 'thread_channel') - .setPlaceholder('Select channel for threads') - .addChannelTypes(ChannelType.GuildText) - .setMaxValues(1) - ); - const row3 = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(PREFIX_BUTTON + 'tickettype_clear_both_channel') - .setLabel('Channels only') - .setStyle(ButtonStyle.Secondary), - new ButtonBuilder() - .setCustomId(PREFIX_BUTTON + 'tickettype_clear_thread') - .setLabel('Threads only') - .setStyle(ButtonStyle.Secondary), - new ButtonBuilder() - .setCustomId(PREFIX_BUTTON + 'back_3') - .setLabel('Back') - .setStyle(ButtonStyle.Secondary) - .setEmoji('⬅️'), - new ButtonBuilder() - .setCustomId(PREFIX_BUTTON + 'continue_3') - .setLabel('Save & Continue') - .setStyle(ButtonStyle.Success) - .setDisabled(!(categoryName && threadChannelName)) - ); - return { embeds: [embed], components: [row1, row2, row3] }; - } - - if (ticketType === 'channel') { - const embed = new EmbedBuilder() - .setColor(0x2ecc71) - .setTitle('Step 3/5 Select the ticket category') - .setDescription( - 'The selected category is where ticket **channels** will be created.\n' + - 'Use the dropdown to select the category.' - ) - .addFields({ name: 'Selected Category', value: categoryName ? `\`${categoryName}\`` : 'None selected' }); - - const select = new ChannelSelectMenuBuilder() - .setCustomId(PREFIX_SELECT + 'category') - .setPlaceholder('Select a category') - .addChannelTypes(ChannelType.GuildCategory) - .setMaxValues(1); - - const row1 = new ActionRowBuilder().addComponents(select); - const row2 = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(PREFIX_BUTTON + 'tickettype_clear') - .setLabel('Change to Threads') - .setStyle(ButtonStyle.Secondary), - new ButtonBuilder() - .setCustomId(PREFIX_BUTTON + 'back_3') - .setLabel('Back') - .setStyle(ButtonStyle.Secondary) - .setEmoji('⬅️'), - new ButtonBuilder() - .setCustomId(PREFIX_BUTTON + 'continue_3') - .setLabel('Save & Continue') - .setStyle(ButtonStyle.Success) - .setDisabled(!categoryName) - ); - return { embeds: [embed], components: [row1, row2] }; - } - - // ticketType === 'thread' - const embed = new EmbedBuilder() - .setColor(0x2ecc71) - .setTitle('Step 3/5 Select the channel for ticket threads') - .setDescription( - 'Ticket **threads** will be created as private threads under the selected text channel.\n' + - 'Use the dropdown to select the channel.' - ) - .addFields({ name: 'Selected Channel', value: threadChannelName ? `\`${threadChannelName}\`` : 'None selected' }); - - const select = new ChannelSelectMenuBuilder() - .setCustomId(PREFIX_SELECT + 'thread_channel') - .setPlaceholder('Select a text channel') - .addChannelTypes(ChannelType.GuildText) - .setMaxValues(1); - - const row1 = new ActionRowBuilder().addComponents(select); - const row2 = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(PREFIX_BUTTON + 'tickettype_clear') - .setLabel('Change to Channels') - .setStyle(ButtonStyle.Secondary), - new ButtonBuilder() - .setCustomId(PREFIX_BUTTON + 'back_3') - .setLabel('Back') - .setStyle(ButtonStyle.Secondary) - .setEmoji('⬅️'), - new ButtonBuilder() - .setCustomId(PREFIX_BUTTON + 'continue_3') - .setLabel('Save & Continue') - .setStyle(ButtonStyle.Success) - .setDisabled(!threadChannelName) - ); - return { embeds: [embed], components: [row1, row2] }; -} - -function step4Embed(channelName) { - const embed = new EmbedBuilder() - .setColor(0x2ecc71) - .setTitle('Step 4/5 Select the transcript channel') - .setDescription( - 'The selected channel is where transcripts will be saved when tickets are closed.\n' + - 'Use the dropdown to select the channel.\n' + - 'Not seeing your channel? Try searching for it inside the dropdown.' - ) - .addFields({ - name: 'Selected Channel', - value: channelName ? `\`${channelName}\`` : 'Not selected' - }); - - const select = new ChannelSelectMenuBuilder() - .setCustomId(PREFIX_SELECT + 'transcript') - .setPlaceholder('Select a channel') - .addChannelTypes(ChannelType.GuildText) - .setMaxValues(1); - - const row1 = new ActionRowBuilder().addComponents(select); - const row2 = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(PREFIX_BUTTON + 'back_4') - .setLabel('Back') - .setStyle(ButtonStyle.Secondary) - .setEmoji('⬅️'), - new ButtonBuilder() - .setCustomId(PREFIX_BUTTON + 'continue_4') - .setLabel('Save & Continue') - .setStyle(ButtonStyle.Success) - .setDisabled(!channelName) - ); - return { embeds: [embed], components: [row1, row2] }; -} - -function step5Embed(channelName) { - const embed = new EmbedBuilder() - .setColor(0x2ecc71) - .setTitle('Step 5/5 Send the panel into a channel') - .setDescription( - 'The ticket creation panel is what the community will use to create tickets.\n' + - 'Use the dropdown to select the channel to send the panel into.\n' + - 'Not seeing your channel? Try searching for it inside the dropdown.\n' + - 'Sending not working? Run `/panel` in the channel directly.' - ) - .addFields({ - name: 'Selected Channel', - value: channelName ? `\`${channelName}\`` : 'Not selected' - }); - - const select = new ChannelSelectMenuBuilder() - .setCustomId(PREFIX_SELECT + 'panel_channel') - .setPlaceholder('Select a channel') - .addChannelTypes(ChannelType.GuildText) - .setMaxValues(1); - - const row1 = new ActionRowBuilder().addComponents(select); - const row2 = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(PREFIX_BUTTON + 'back_5') - .setLabel('Back') - .setStyle(ButtonStyle.Secondary) - .setEmoji('⬅️'), - new ButtonBuilder() - .setCustomId(PREFIX_BUTTON + 'finish') - .setLabel('Finish') - .setStyle(ButtonStyle.Success) - .setDisabled(!channelName) - ); - return { embeds: [embed], components: [row1, row2] }; -} - -/** - * Handle /setup slash command – send Step 1. - */ -async function handleSetupCommand(interaction) { - await interaction.deferReply({ ephemeral: true }); - setState(interaction.user.id, { step: 1, panelName: null }); - const payload = step1Embed(null); - await interaction.editReply(payload); -} - -/** - * Handle setup button (Set name, Back, Save & Continue, Finish). - */ -async function handleSetupButton(interaction) { - const customId = interaction.customId; - if (!customId.startsWith(PREFIX_BUTTON)) return false; - - const userId = interaction.user.id; - const state = getState(userId); - if (!state) { - await interaction.reply({ - content: 'This setup session has expired. Run `/setup` again.', - ephemeral: true - }).catch(() => {}); - return true; - } - - // Set name β†’ show modal - if (customId === PREFIX_BUTTON + 'setname') { - const modal = new ModalBuilder() - .setCustomId(PREFIX_MODAL + 'name') - .setTitle('Panel name'); - - const input = new TextInputBuilder() - .setCustomId('panel_name') - .setLabel('Panel name') - .setStyle(TextInputStyle.Short) - .setPlaceholder('e.g. New Panel') - .setRequired(true) - .setMaxLength(100); - if (state.panelName) input.setValue(state.panelName); - modal.addComponents(new ActionRowBuilder().addComponents(input)); - await interaction.showModal(modal); - return true; - } - - // Back - if (customId.startsWith(PREFIX_BUTTON + 'back_')) { - const step = parseInt(customId.replace(PREFIX_BUTTON + 'back_', ''), 10); - const nextStep = step - 1; - setState(userId, { step: nextStep }); - let payload; - if (nextStep === 1) payload = step1Embed(state.panelName); - else if (nextStep === 2) payload = step2Embed(state.roleLabels); - else if (nextStep === 3) payload = step3Embed(state); - else if (nextStep === 4) payload = step4Embed(state.transcriptChannelName); - else payload = step5Embed(state.panelChannelName); - await interaction.update(payload); - return true; - } - - // Save & Continue (steps 1–4) - if (customId === PREFIX_BUTTON + 'continue_1') { - setState(userId, { step: 2 }); - await interaction.update(step2Embed(state.roleLabels)); - return true; - } - if (customId === PREFIX_BUTTON + 'continue_2') { - setState(userId, { step: 3 }); - await interaction.update(step3Embed({ ...state, step: 3 })); - return true; - } - if (customId === PREFIX_BUTTON + 'tickettype_channel') { - setState(userId, { ticketType: 'channel', categoryId: null, categoryName: null, threadChannelId: null, threadChannelName: null }); - await interaction.update(step3Embed(getState(userId))); - return true; - } - if (customId === PREFIX_BUTTON + 'tickettype_thread') { - setState(userId, { ticketType: 'thread', categoryId: null, categoryName: null, threadChannelId: null, threadChannelName: null }); - await interaction.update(step3Embed(getState(userId))); - return true; - } - if (customId === PREFIX_BUTTON + 'tickettype_both') { - setState(userId, { ticketType: 'both', categoryId: null, categoryName: null, threadChannelId: null, threadChannelName: null }); - await interaction.update(step3Embed(getState(userId))); - return true; - } - if (customId === PREFIX_BUTTON + 'tickettype_clear') { - setState(userId, { ticketType: null, categoryId: null, categoryName: null, threadChannelId: null, threadChannelName: null }); - await interaction.update(step3Embed(getState(userId))); - return true; - } - if (customId === PREFIX_BUTTON + 'tickettype_clear_thread') { - setState(userId, { ticketType: 'thread', categoryId: null, categoryName: null }); - await interaction.update(step3Embed(getState(userId))); - return true; - } - if (customId === PREFIX_BUTTON + 'tickettype_clear_both_channel') { - setState(userId, { ticketType: 'channel', threadChannelId: null, threadChannelName: null }); - await interaction.update(step3Embed(getState(userId))); - return true; - } - if (customId === PREFIX_BUTTON + 'continue_3') { - setState(userId, { step: 4 }); - await interaction.update(step4Embed(state.transcriptChannelName)); - return true; - } - if (customId === PREFIX_BUTTON + 'continue_4') { - setState(userId, { step: 5 }); - await interaction.update(step5Embed(state.panelChannelName)); - return true; - } - - // Finish - if (customId === PREFIX_BUTTON + 'finish') { - const hasTicketTarget = - (state.ticketType === 'channel' && state.categoryId) || - (state.ticketType === 'thread' && state.threadChannelId) || - (state.ticketType === 'both' && state.categoryId && state.threadChannelId); - if (!state.panelChannelId || !hasTicketTarget || !state.roleIds?.length) { - await interaction.reply({ - content: 'Please complete all steps (panel name, support role, ticket type + category/channel, transcript channel, panel channel).', - ephemeral: true - }).catch(() => {}); - return true; - } - - try { - const channel = await interaction.client.channels.fetch(state.panelChannelId); - const title = state.panelName || 'Indifferent Broccoli Tickets'; - const description = 'Need help? Click below to create a ticket. 🎟'; - - const embed = new EmbedBuilder() - .setTitle(title) - .setDescription(description) - .setColor(0x2ecc71) - .setThumbnail(CONFIG.LOGO_URL || null) - .setFooter({ text: 'Indifferent Broccoli Tickets' }); - - let row; - if (state.ticketType === 'both') { - row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('open_ticket_thread') - .setLabel('Create ticket (thread)') - .setStyle(ButtonStyle.Success) - .setEmoji('🧡'), - new ButtonBuilder() - .setCustomId('open_ticket_channel') - .setLabel('Create ticket (channel)') - .setStyle(ButtonStyle.Success) - .setEmoji('πŸ“') - ); - } else { - row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('open_ticket') - .setLabel('Create ticket') - .setStyle(ButtonStyle.Success) - .setEmoji('βœ…') - ); - } - - await enqueueSend(channel, { embeds: [embed], components: [row] }); - - const envLines = state.ticketType === 'both' - ? [`DISCORD_THREAD_CHANNEL_ID=${state.threadChannelId}`, `DISCORD_TICKET_CATEGORY_ID=${state.categoryId}`] - : [state.ticketType === 'thread' - ? `DISCORD_THREAD_CHANNEL_ID=${state.threadChannelId}` - : `DISCORD_TICKET_CATEGORY_ID=${state.categoryId}`]; - const envSnippet = [ - '**Add these to your `.env` file** (optional – only if you want to use these values for new Discord tickets):', - '```', - ...envLines, - `ROLE_ID_TO_PING=${state.roleIds[0]}`, - `TRANSCRIPT_CHANNEL_ID=${state.transcriptChannelId}`, - `LOGGING_CHANNEL_ID=${state.transcriptChannelId}`, - '```' - ].join('\n'); - - await interaction.update({ - embeds: [ - new EmbedBuilder() - .setColor(0x2ecc71) - .setTitle('Setup complete') - .setDescription( - `Panel **${title}** has been sent to ${channel}.\n\n` + - envSnippet - ) - ], - components: [] - }); - } catch (err) { - console.error('Setup finish error:', err); - await interaction.reply({ - content: `Failed to send panel: ${err.message}`, - ephemeral: true - }).catch(() => {}); - } - clearState(userId); - return true; - } - - return false; -} - -/** - * Handle setup modal submit (panel name). - */ -async function handleSetupModal(interaction) { - if (!interaction.customId.startsWith(PREFIX_MODAL)) return false; - - const userId = interaction.user.id; - const state = getState(userId); - if (!state) { - await interaction.reply({ - content: 'This setup session has expired. Run `/setup` again.', - ephemeral: true - }).catch(() => {}); - return true; - } - - if (interaction.customId === PREFIX_MODAL + 'name') { - const panelName = interaction.fields.getTextInputValue('panel_name').trim(); - setState(userId, { panelName, step: 1 }); - await interaction.deferReply({ ephemeral: true }); - const payload = step1Embed(panelName); - await interaction.editReply(payload); - return true; - } - - return false; -} - -/** - * Handle setup select menus (roles, category, transcript channel, panel channel). - */ -async function handleSetupSelect(interaction) { - const customId = interaction.customId; - if (!customId.startsWith(PREFIX_SELECT)) return false; - - const userId = interaction.user.id; - const state = getState(userId); - if (!state) { - await interaction.reply({ - content: 'This setup session has expired. Run `/setup` again.', - ephemeral: true - }).catch(() => {}); - return true; - } - - if (customId === PREFIX_SELECT + 'roles') { - const roles = interaction.roles; - const roleIds = [...roles.keys()]; - const roleLabels = [...roles.values()].map(r => r.name); - setState(userId, { roleIds, roleLabels }); - await interaction.update(step2Embed(roleLabels)); - return true; - } - - if (customId === PREFIX_SELECT + 'category') { - const channel = interaction.channels.first(); - setState(userId, { - categoryId: channel?.id, - categoryName: channel?.name - }); - await interaction.update(step3Embed(getState(userId))); - return true; - } - if (customId === PREFIX_SELECT + 'thread_channel') { - const channel = interaction.channels.first(); - setState(userId, { - threadChannelId: channel?.id, - threadChannelName: channel?.name - }); - await interaction.update(step3Embed(getState(userId))); - return true; - } - - if (customId === PREFIX_SELECT + 'transcript') { - const channel = interaction.channels.first(); - setState(userId, { - transcriptChannelId: channel?.id, - transcriptChannelName: channel?.name - }); - await interaction.update(step4Embed(channel?.name)); - return true; - } - - if (customId === PREFIX_SELECT + 'panel_channel') { - const channel = interaction.channels.first(); - setState(userId, { - panelChannelId: channel?.id, - panelChannelName: channel?.name - }); - await interaction.update(step5Embed(channel?.name)); - return true; - } - - return false; -} - -module.exports = { - PREFIX_BUTTON, - PREFIX_MODAL, - PREFIX_SELECT, - handleSetupCommand, - handleSetupButton, - handleSetupModal, - handleSetupSelect -}; diff --git a/models.js b/models.js index c76f8b5..fbd7eb9 100644 --- a/models.js +++ b/models.js @@ -54,12 +54,6 @@ mongoose.model('Tag', new mongoose.Schema({ useCount: { type: Number, default: 0 } })); -mongoose.model('GuildSettings', new mongoose.Schema({ - guildId: { type: String, required: true, unique: true }, - emailRouting: { type: String, enum: ['thread', 'category'], default: 'category' }, - updatedAt: { type: Date, default: Date.now } -})); - mongoose.model('StaffSettings', new mongoose.Schema({ userId: { type: String, required: true, unique: true }, guildId: { type: String, required: true }, diff --git a/services/configSchema.js b/services/configSchema.js index 70cce77..0d7d1cc 100644 --- a/services/configSchema.js +++ b/services/configSchema.js @@ -21,7 +21,6 @@ const ALLOWED_CONFIG_KEYS = new Set([ // Ticket settings 'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'TICKET_T2_CATEGORY_NAME', 'TICKET_T3_CATEGORY_NAME', 'EMAIL_TICKET_OVERFLOW_CATEGORY_IDS', 'DISCORD_TICKET_CATEGORY_ID', 'DISCORD_TICKET_OVERFLOW_CATEGORY_IDS', - 'DISCORD_THREAD_CHANNEL_ID', 'EMAIL_THREAD_CHANNEL_ID', 'THREAD_PARENT_CHANNEL', 'USE_THREADS', // Escalation categories 'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID', 'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID', diff --git a/services/gmail.js b/services/gmail.js index 5a91ceb..064af23 100644 --- a/services/gmail.js +++ b/services/gmail.js @@ -239,16 +239,14 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro * @param {string} replyText - Reply text * @param {string} recipientEmail - Recipient email * @param {string} subject - Subject line - * @param {string} discordUser - Discord user name * @param {string} messageId - Message ID (optional) - * @param {string} userId - Discord user ID for signature (optional) + * @param {string} userId - Discord user ID for optional personal valediction/tagline (optional) */ async function sendGmailReply( threadId, replyText, recipientEmail, subject, - discordUser, messageId, userId = null ) { @@ -265,50 +263,44 @@ async function sendGmailReply( const utf8Subject = `=?utf-8?B?${Buffer.from( safeSubject ).toString('base64')}?=`; - const safeUser = escapeHtml(discordUser); const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || ''); - const companySignatureText = (CONFIG.SIGNATURE || '').replace(/
/g, '\n'); - - // Get staff signature if userId provided + let signatureBlocks = { text: '', html: '' }; if (userId) { signatureBlocks = await getStaffSignatureBlocks(userId); } - // signatureBlocks.html must arrive pre-escaped; do not inject raw HTML here. const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '
') : ''; const safeStaffSigText = signatureBlocks.text; - const safeCompanySigHtml = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '
'); const htmlBody = `

${escapeHtml(replyText).replace(/\n/g, '
')}

${safeStaffSigHtml ? `

${safeStaffSigHtml}

` : ''} -
- - - - - -
- ${safeLogoUrl ? `` : ''} - -

${safeUser}

-
${safeCompanySigHtml}
-
+
+ ${safeLogoUrl ? `Indifferent Broccoli
` : ''} + Indifferent Broccoli Support
+ https://indifferentbroccoli.com/
+ Join us on Discord
+
+ "We eat lag for breakfast. Whatever." +
`; const boundary = '000000000000' + Date.now().toString(16); const plainBody = []; - plainBody.push(replyText); - if (safeStaffSigText) { - plainBody.push(safeStaffSigText); - } - plainBody.push(''); - plainBody.push('------------------------------'); - plainBody.push(''); - plainBody.push(companySignatureText); + plainBody.push(replyText); + if (safeStaffSigText) { + plainBody.push(''); + plainBody.push(safeStaffSigText); + } + plainBody.push(''); + plainBody.push('Indifferent Broccoli Support'); + plainBody.push('https://indifferentbroccoli.com/'); + plainBody.push('Join us on Discord: https://discord.gg/2vmfrrtvJY'); + plainBody.push(''); + plainBody.push('"We eat lag for breakfast. Whatever."'); const raw = Buffer.from([ `From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`, diff --git a/services/gmail.js.bak3-20260421 b/services/gmail.js.bak3-20260421 new file mode 100644 index 0000000..ee41a33 --- /dev/null +++ b/services/gmail.js.bak3-20260421 @@ -0,0 +1,346 @@ +/** + * Gmail service – OAuth client, send reply, send ticket-closed email. + */ +const { google } = require('googleapis'); +const { CONFIG } = require('../config'); +const { extractRawEmail, escapeHtml } = require('../utils'); +const { getStaffSignatureBlocks } = require('./staffSignature'); +const { logError } = require('./debugLog'); +const { readEnvFile } = require('./configPersistence'); + +function sanitizeHeaderValue(v) { return String(v || '').replace(/[\r\n]+/g, ' ').trim(); } +const EMAIL_RE = /^[^@\s]+@[^@\s]+$/; + +function getGmailClient() { + const auth = new google.auth.OAuth2( + process.env.GOOGLE_CLIENT_ID, + process.env.GOOGLE_CLIENT_SECRET + ); + auth.setCredentials({ refresh_token: CONFIG.REFRESH_TOKEN }); + return google.gmail({ version: 'v1', auth }); +} + +/** + * Re-read REFRESH_TOKEN from .env, update in-memory config, and probe Google. + * Used by the internal /gmail/reload endpoint so the weekly reauth chore does + * not require a full container restart. + * + * Throws if the env file is missing the token, or if the probe call (getProfile) + * fails β€” the caller surfaces the error so the UI can see why. + * + * @returns {Promise<{emailAddress: string}>} + */ +async function reloadGmailClient() { + const envMap = readEnvFile(); + const newToken = envMap.get('REFRESH_TOKEN'); + if (!newToken) { + const err = new Error('REFRESH_TOKEN not set in .env'); + err.code = 'ENOTOKEN'; + throw err; + } + process.env.REFRESH_TOKEN = newToken; + CONFIG.REFRESH_TOKEN = newToken; + const gmail = getGmailClient(); + const profile = await gmail.users.getProfile({ userId: 'me' }); + return { emailAddress: profile.data.emailAddress }; +} + +async function sendTicketClosedEmail(ticket, discordDisplayName) { + try { + const gmail = getGmailClient(); + + // Send to the ticket sender (customer), not derived from thread (which can be support) + const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase(); + if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return; + if (!EMAIL_RE.test(recipientEmail)) { + logError('sendTicketClosedEmail: invalid recipient', new Error(`Rejected: ${recipientEmail}`)).catch(() => {}); + return; + } + + let subjectHeader = ticket.subject || 'Support'; + let msgId = null; + try { + const thread = await gmail.users.threads.get({ + userId: 'me', + id: ticket.gmailThreadId + }); + const messages = thread.data.messages || []; + const lastMsg = [...messages].reverse()[0]; + if (lastMsg?.payload?.headers) { + const subj = lastMsg.payload.headers.find(h => h.name === 'Subject')?.value; + if (subj) subjectHeader = subj; + msgId = sanitizeHeaderValue(lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value); + } + } catch (_) { + /* use ticket.subject and no In-Reply-To if thread fetch fails */ + } + + const finalSubject = sanitizeHeaderValue(`${CONFIG.TICKET_CLOSE_SUBJECT_PREFIX} ${subjectHeader}`); + const utf8Subject = `=?utf-8?B?${Buffer.from( + finalSubject + ).toString('base64')}?=`; + + const serverDisplayName = escapeHtml(discordDisplayName || CONFIG.SUPPORT_NAME || 'Support'); + const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || ''); + const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '
'); + const safeCloseMessage = escapeHtml(CONFIG.TICKET_CLOSE_MESSAGE || '').replace(/\n/g, '
'); + const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || '').replace(/\n/g, '
'); + const htmlBody = ` +
+

From: ${serverDisplayName} on Discord

+

Message:

+

${safeCloseMessage}

+

${safeCloseSignature}

+
+ + + + + +
+ ${safeLogoUrl ? `` : ''} + +

${serverDisplayName}

+
${safeSignature}
+
+
`; + + const rawHeaders = [ + `From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`, + `To: ${recipientEmail}`, + `Subject: ${utf8Subject}`, + msgId ? `In-Reply-To: ${msgId}` : '', + msgId ? `References: ${msgId}` : '', + 'MIME-Version: 1.0', + 'Content-Type: text/html; charset="UTF-8"', + '', + htmlBody + ].filter(Boolean); + + const raw = Buffer.from(rawHeaders.join('\r\n')) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + await gmail.users.messages.send({ + userId: 'me', + requestBody: { raw, threadId: ticket.gmailThreadId } + }); + } catch (err) { + console.error('Ticket closed email error:', err); + } +} + +// StaffSignature model is registered in models.js; re-import here for use in this file +const { mongoose } = require('../db-connection'); +const StaffSignature = mongoose.model('StaffSignature'); + +/** + * Send a notification email in the ticket thread (e.g. escalation, high-priority). + * @param {Object} ticket - Ticket with gmailThreadId, senderEmail, subject + * @param {string} subjectLine - Subject line (e.g. "Ticket escalated" or "Priority updated") + * @param {string} messageBody - Plain or HTML message body + * @param {string} [fromLabel] - Label for "From" (e.g. "Support on Discord") + * @param {string} [userId] - Discord user ID for signature (optional) + */ +async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fromLabel, userId = null) { + try { + const gmail = getGmailClient(); + const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase(); + if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return; + if (!EMAIL_RE.test(recipientEmail)) { + logError('sendTicketNotificationEmail: invalid recipient', new Error(`Rejected: ${recipientEmail}`)).catch(() => {}); + return; + } + + let subjectHeader = ticket.subject || 'Support'; + let msgId = null; + try { + const thread = await gmail.users.threads.get({ + userId: 'me', + id: ticket.gmailThreadId + }); + const messages = thread.data.messages || []; + const lastMsg = [...messages].reverse()[0]; + if (lastMsg?.payload?.headers) { + const subj = lastMsg.payload.headers.find(h => h.name === 'Subject')?.value; + if (subj) subjectHeader = subj; + msgId = sanitizeHeaderValue(lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value); + } + } catch (_) {} + + const finalSubject = sanitizeHeaderValue(subjectLine || subjectHeader); + const utf8Subject = `=?utf-8?B?${Buffer.from(finalSubject).toString('base64')}?=`; + const label = escapeHtml(fromLabel || CONFIG.SUPPORT_NAME || 'Support'); + const safeBody = escapeHtml(messageBody || '').replace(/\n/g, '
'); + const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || ''); + + // Get staff signature if userId provided + let signatureBlocks = { text: '', html: '' }; + if (userId) { + signatureBlocks = await getStaffSignatureBlocks(userId); + } + + const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '
'); + const serverDisplayName = label; + const safeCloseMessage = safeBody; + const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || '').replace(/\n/g, '
'); + const htmlBody = ` +
+

From: ${serverDisplayName} on Discord

+

Message:

+

${safeCloseMessage}

+

${safeCloseSignature}

+
+ + + + + +
+ ${safeLogoUrl ? `` : ''} + +

${serverDisplayName}

+
${safeSignature}
+
+
`; + + const rawHeaders = [ + `From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`, + `To: ${recipientEmail}`, + `Subject: ${utf8Subject}`, + msgId ? `In-Reply-To: ${msgId}` : '', + msgId ? `References: ${msgId}` : '', + 'MIME-Version: 1.0', + 'Content-Type: text/html; charset="UTF-8"', + '', + htmlBody + ].filter(Boolean); + + const raw = Buffer.from(rawHeaders.join('\r\n')) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + await gmail.users.messages.send({ + userId: 'me', + requestBody: { raw, threadId: ticket.gmailThreadId } + }); + } catch (err) { + console.error('Ticket notification email error:', err); + } +} + +/** + * Send a Gmail reply to a ticket + * @param {string} threadId - Gmail thread ID + * @param {string} replyText - Reply text + * @param {string} recipientEmail - Recipient email + * @param {string} subject - Subject line + * @param {string} authorName - Replier's Discord server display name (caller resolves from member.displayName) + * @param {string} messageId - Message ID (optional) + * @param {string} userId - Discord user ID for optional personal valediction/tagline (optional) + */ +async function sendGmailReply( + threadId, + replyText, + recipientEmail, + subject, + authorName, + messageId, + userId = null +) { + const gmail = getGmailClient(); + + const safeRecipient = sanitizeHeaderValue(extractRawEmail(recipientEmail || '')).toLowerCase(); + if (!EMAIL_RE.test(safeRecipient)) { + logError('sendGmailReply: invalid recipient', new Error(`Rejected: ${safeRecipient}`)).catch(() => {}); + return null; + } + const safeMessageId = sanitizeHeaderValue(messageId); + const safeSubject = sanitizeHeaderValue(`Re: ${subject}`); + + const utf8Subject = `=?utf-8?B?${Buffer.from( + safeSubject + ).toString('base64')}?=`; + const safeAuthor = escapeHtml(authorName || ''); + + let signatureBlocks = { text: '', html: '' }; + if (userId) { + signatureBlocks = await getStaffSignatureBlocks(userId); + } + // signatureBlocks.html must arrive pre-escaped; do not inject raw HTML here. + const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '
') : ''; + const safeStaffSigText = signatureBlocks.text; + + const htmlBody = ` +
+

${escapeHtml(replyText).replace(/\n/g, '
')}

+ ${safeStaffSigHtml ? `

${safeStaffSigHtml}

` : ''} +
+ ${safeAuthor}
+ Indifferent Broccoli Support
+ https://indifferentbroccoli.com/
+ Join us on Discord
+
+ "We eat lag for breakfast. Whatever." +
+
`; + + const boundary = '000000000000' + Date.now().toString(16); + + const plainBody = []; + plainBody.push(replyText); + if (safeStaffSigText) { + plainBody.push(''); + plainBody.push(safeStaffSigText); + } + plainBody.push(''); + plainBody.push(authorName || ''); + plainBody.push('Indifferent Broccoli Support'); + plainBody.push('https://indifferentbroccoli.com/'); + plainBody.push('Join us on Discord: https://discord.gg/2vmfrrtvJY'); + plainBody.push(''); + plainBody.push('"We eat lag for breakfast. Whatever."'); + + const raw = Buffer.from([ + `From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`, + `To: ${safeRecipient}`, + `Subject: ${utf8Subject}`, + safeMessageId ? `In-Reply-To: ${safeMessageId}` : '', + safeMessageId ? `References: ${safeMessageId}` : '', + 'MIME-Version: 1.0', + 'Content-Type: multipart/alternative; boundary="' + boundary + '"', + '', + '--' + boundary, + 'Content-Type: text/plain; charset="UTF-8"', + '', + ...plainBody, + '', + '--' + boundary, + 'Content-Type: text/html; charset="UTF-8"', + '', + htmlBody, + '', + '--' + boundary + '--' + ].join('\r\n')) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + await gmail.users.messages.send({ + userId: 'me', + requestBody: { raw, threadId } + }); +} + +module.exports = { + getGmailClient, + reloadGmailClient, + sendGmailReply, + sendTicketClosedEmail, + sendTicketNotificationEmail +}; diff --git a/services/guildSettings.js b/services/guildSettings.js deleted file mode 100644 index 1a3cb4c..0000000 --- a/services/guildSettings.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Guild-specific settings (e.g. email ticket routing). - */ -const { mongoose } = require('../db-connection'); - -const GuildSettings = mongoose.model('GuildSettings'); - -/** - * Get email ticket routing for a guild. Returns 'thread' or '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 'category'; -} - -/** - * Set email ticket routing for a guild. - * @param {string} guildId - * @param {'thread'|'category'} value - */ -async function setEmailRouting(guildId, value) { - await GuildSettings.findOneAndUpdate( - { guildId }, - { $set: { emailRouting: value, updatedAt: new Date() } }, - { upsert: true, new: true } - ); -} - -module.exports = { getEmailRouting, setEmailRouting }; diff --git a/services/tickets.js b/services/tickets.js index 20ee603..6cf31e3 100644 --- a/services/tickets.js +++ b/services/tickets.js @@ -273,145 +273,49 @@ async function cleanupEmptyOverflowCategory(guild, categoryId, categoryName) { } async function createTicketChannel(guild, ticketNumber, userId, subject, creatorNickname) { - if (CONFIG.USE_THREADS && CONFIG.THREAD_PARENT_CHANNEL) { - const parentChannel = guild.channels.cache.get(CONFIG.THREAD_PARENT_CHANNEL); - if (!parentChannel) { - throw new Error('Thread parent channel not found'); - } + let parentId; + try { + parentId = await getOrCreateTicketCategory(guild, CONFIG.TICKET_CATEGORY_ID, CONFIG.TICKET_CATEGORY_NAME); + } catch (e) { + console.error('getOrCreateTicketCategory (createTicketChannel):', e); + throw new Error('Ticket category not found or could not be allocated'); + } - const thread = await parentChannel.threads.create({ - name: `πŸŽ«γƒ»ticket-${ticketNumber}`, - autoArchiveDuration: 1440, - type: ChannelType.PrivateThread, - invitable: false, - reason: `Ticket #${ticketNumber}` - }); - - await thread.members.add(userId); - // Add all members with the support role so they can see and reply in the thread - if (CONFIG.ROLE_ID_TO_PING) { - const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING); - if (role?.members?.size) { - for (const [memberId] of role.members) { - if (memberId === userId) continue; // already added - await thread.members.add(memberId).catch(() => {}); + let channel; + try { + channel = await guild.channels.create({ + name: creatorNickname ? toDiscordSafeName(`unclaimed-${creatorNickname}-${ticketNumber}`) : `ticket-${ticketNumber}`, + type: ChannelType.GuildText, + parent: parentId, + permissionOverwrites: [ + { + id: guild.id, + deny: [PermissionFlagsBits.ViewChannel] + }, + { + id: userId, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.ReadMessageHistory + ] + }, + { + id: CONFIG.ROLE_ID_TO_PING, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.ReadMessageHistory + ] } - } - } - return thread; - } else { - let parentId; - try { - parentId = await getOrCreateTicketCategory(guild, CONFIG.TICKET_CATEGORY_ID, CONFIG.TICKET_CATEGORY_NAME); - } catch (e) { - console.error('getOrCreateTicketCategory (createTicketChannel):', e); - throw new Error('Ticket category not found or could not be allocated'); - } - - let channel; - try { - channel = await guild.channels.create({ - name: creatorNickname ? toDiscordSafeName(`unclaimed-${creatorNickname}-${ticketNumber}`) : `ticket-${ticketNumber}`, - type: ChannelType.GuildText, - parent: parentId, - permissionOverwrites: [ - { - id: guild.id, - deny: [PermissionFlagsBits.ViewChannel] - }, - { - id: userId, - allow: [ - PermissionFlagsBits.ViewChannel, - PermissionFlagsBits.SendMessages, - PermissionFlagsBits.ReadMessageHistory - ] - }, - { - id: CONFIG.ROLE_ID_TO_PING, - allow: [ - PermissionFlagsBits.ViewChannel, - PermissionFlagsBits.SendMessages, - PermissionFlagsBits.ReadMessageHistory - ] - } - ] - }); - } catch (e) { - console.error('guild.channels.create (createTicketChannel):', e); - throw e; - } - - return channel; + ] + }); + } catch (e) { + console.error('guild.channels.create (createTicketChannel):', e); + throw e; } -} -/** - * Create a private Discord ticket thread under DISCORD_THREAD_CHANNEL_ID. - * Adds creator and all members with ROLE_ID_TO_PING. - * @param {import('discord.js').Guild} guild - * @param {number} ticketNumber - * @param {string} creatorUserId - * @returns {Promise} - */ -async function createDiscordTicketAsThread(guild, ticketNumber, creatorUserId) { - const parentId = CONFIG.DISCORD_THREAD_CHANNEL_ID; - if (!parentId) throw new Error('DISCORD_THREAD_CHANNEL_ID is not set'); - const parentChannel = guild.channels.cache.get(parentId); - if (!parentChannel) throw new Error('Discord thread parent channel not found'); - - const thread = await parentChannel.threads.create({ - name: `πŸŽ«γƒ»ticket-${ticketNumber}`, - autoArchiveDuration: 1440, - type: ChannelType.PrivateThread, - invitable: false, - reason: `Ticket #${ticketNumber}` - }); - - await thread.members.add(creatorUserId); - if (CONFIG.ROLE_ID_TO_PING) { - const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING); - if (role?.members?.size) { - for (const [memberId] of role.members) { - if (memberId === creatorUserId) continue; - await thread.members.add(memberId).catch(() => {}); - } - } - } - return thread; -} - -/** - * Create a private email ticket thread under EMAIL_THREAD_CHANNEL_ID. - * Adds all members with ROLE_ID_TO_PING (no creator; email tickets have no Discord user). - * @param {import('discord.js').Guild} guild - * @param {number} ticketNumber - * @param {string} chanName - * @returns {Promise} - */ -async function createEmailTicketAsThread(guild, ticketNumber, chanName) { - const parentId = CONFIG.EMAIL_THREAD_CHANNEL_ID; - if (!parentId) throw new Error('EMAIL_THREAD_CHANNEL_ID is not set'); - const parentChannel = guild.channels.cache.get(parentId); - if (!parentChannel) throw new Error('Email thread parent channel not found'); - - const thread = await parentChannel.threads.create({ - name: chanName || `πŸŽ«γƒ»ticket-${ticketNumber}`, - autoArchiveDuration: 1440, - type: ChannelType.PrivateThread, - invitable: false, - reason: `Ticket #${ticketNumber}` - }); - - if (CONFIG.ROLE_ID_TO_PING) { - const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING); - if (role?.members?.size) { - for (const [memberId] of role.members) { - await thread.members.add(memberId).catch(() => {}); - } - } - } - return thread; + return channel; } // --- LIMITS & PERMISSIONS --- @@ -649,8 +553,6 @@ module.exports = { getNextTicketNumber, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, - createDiscordTicketAsThread, - createEmailTicketAsThread, RENAME_WINDOW_MS, RENAME_LIMIT, getSenderLocal,