diff --git a/commands/register.js b/commands/register.js index da4ec13..8f9891d 100644 --- a/commands/register.js +++ b/commands/register.js @@ -22,26 +22,28 @@ async function registerCommands() { const commands = [ new SlashCommandBuilder() .setName('escalate') - .setDescription('Escalate this ticket to tier 2 or 3 (or one step if no tier chosen)') + .setDescription('Escalate this ticket to tier 2 or tier 3') .setContexts([InteractionContextType.Guild]) .setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) .addStringOption(opt => opt - .setName('reason') - .setDescription('Reason for escalating') - .setMinLength(10) - .setMaxLength(500) - .setRequired(false) - ) - .addIntegerOption(opt => - opt - .setName('tier') - .setDescription('Target tier (2 or 3); omit to escalate one step') - .setRequired(false) + .setName('level') + .setDescription('Target escalation level') + .setRequired(true) .addChoices( - { name: 'Tier 2', value: 2 }, - { name: 'Tier 3', value: 3 } + { name: 'Tier 2', value: '2' }, + { name: 'Tier 3', value: '3' } + ) + ) + .addStringOption(opt => + opt + .setName('action') + .setDescription('Unclaim ticket or keep current claimer') + .setRequired(true) + .addChoices( + { name: 'Unclaim', value: 'unclaim' }, + { name: 'Keep', value: 'keep' } ) ), @@ -281,6 +283,23 @@ async function registerCommands() { .setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), + new SlashCommandBuilder() + .setName('notifydm') + .setDescription('Toggle DM notifications when your ticket receives a customer reply.') + .setContexts([InteractionContextType.Guild]) + .setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) + .addStringOption(opt => + opt + .setName('setting') + .setDescription('on or off') + .setRequired(true) + .addChoices( + { name: 'on', value: 'on' }, + { name: 'off', value: 'off' } + ) + ), + new SlashCommandBuilder() .setName('backup') .setDescription('Export full ticket list to a .txt file in the backup/export channel') diff --git a/config.js b/config.js index 00f0fb2..f9a8495 100644 --- a/config.js +++ b/config.js @@ -115,7 +115,26 @@ const CONFIG = { EMBED_COLOR_CLOSED: parseInt(process.env.EMBED_COLOR_CLOSED) || 0xFF0000, EMBED_COLOR_CLAIMED: parseInt(process.env.EMBED_COLOR_CLAIMED) || 0xFFFF00, EMBED_COLOR_ESCALATED: parseInt(process.env.EMBED_COLOR_ESCALATED) || 0xFF6600, - EMBED_COLOR_INFO: parseInt(process.env.EMBED_COLOR_INFO) || 0x1e2124 + EMBED_COLOR_INFO: parseInt(process.env.EMBED_COLOR_INFO) || 0x1e2124, + STAFF_CATEGORIES: (() => { + const raw = process.env.STAFF_CATEGORIES; + const map = new Map(); + if (!raw || !String(raw).trim()) return map; + for (const part of String(raw).split(',')) { + const seg = part.trim(); + if (!seg) continue; + const idx = seg.indexOf(':'); + if (idx === -1) continue; + const userId = seg.slice(0, idx).trim(); + const categoryId = seg.slice(idx + 1).trim(); + if (userId && categoryId) map.set(userId, categoryId); + } + return map; + })(), + STAFF_T1_CATEGORY: process.env.STAFF_T1_CATEGORY || null, + STAFF_T2_CATEGORY: process.env.STAFF_T2_CATEGORY || null, + STAFF_T3_CATEGORY: process.env.STAFF_T3_CATEGORY || null, + UNCLAIMED_CATEGORY_ID: process.env.UNCLAIMED_CATEGORY_ID || null }; /** Ticket category tags for /tag set – [emoji] [label] in dropdown; priority emoji always first in channel name, then tag emoji. */ diff --git a/handlers/buttons.js b/handlers/buttons.js index 5192011..2ada5f8 100644 --- a/handlers/buttons.js +++ b/handlers/buttons.js @@ -20,7 +20,8 @@ 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 { enqueueRename, enqueueMove } = require('../services/channelQueue'); +const { createStaffChannel } = require('../services/staffChannel'); const { runEscalation, runDeescalation } = require('./commands'); const { trackInteraction, trackError } = require('./analytics'); @@ -286,9 +287,10 @@ async function handleClaim(interaction, ticket) { if (!isClaimed || (isClaimed && !isClaimedByMe && CONFIG.ALLOW_CLAIM_OVERWRITE)) { await Ticket.updateOne( { gmailThreadId: freshTicket.gmailThreadId }, - { $set: { claimedBy: claimerLabel } } + { $set: { claimedBy: claimerLabel, claimerId: interaction.user.id } } ); freshTicket.claimedBy = claimerLabel; + freshTicket.claimerId = interaction.user.id; const renameInfo = await canRename(freshTicket); if (renameInfo.ok) { @@ -310,6 +312,25 @@ async function handleClaim(interaction, ticket) { ); } + try { + const staffCategoryId = CONFIG.STAFF_CATEGORIES.get(interaction.user.id); + if (staffCategoryId) await enqueueMove(interaction.channel, staffCategoryId); + const staffChan = await createStaffChannel( + interaction.guild, + freshTicket, + interaction.user.id, + interaction.channel.name + ); + if (staffChan) { + await Ticket.updateOne( + { gmailThreadId: freshTicket.gmailThreadId }, + { $set: { staffChannelId: staffChan.id } } + ); + } + } catch (e) { + console.error('Staff channel / category (claim):', e); + } + const baseLabel = `Unclaim (${claimerLabel})`; const label = renameInfo.ok ? baseLabel @@ -341,24 +362,36 @@ async function handleClaim(interaction, ticket) { await interaction.followUp({ embeds: [claimEmbed] }); } else { // Unclaim + try { + if (freshTicket.staffChannelId) { + const { deleteStaffChannel } = require('../services/staffChannel'); + await deleteStaffChannel(interaction.guild, freshTicket.staffChannelId); + } + } catch (e) { + console.error('Delete staff channel (unclaim):', e); + } + await Ticket.updateOne( { gmailThreadId: freshTicket.gmailThreadId }, - { $set: { claimedBy: null } } + { $set: { claimedBy: null, claimerId: null, staffChannelId: null } } ); freshTicket.claimedBy = null; + freshTicket.claimerId = null; + freshTicket.staffChannelId = null; const renameInfo = await canRename(freshTicket); if (renameInfo.ok) { - const newName = makeTicketName( - { escalated: !!freshTicket.escalated, claimed: false }, - freshTicket, - guild - ); + const currentName = interaction.channel.name.replace(/^[🟢🟡🔴]/, ''); try { - await enqueueRename(interaction.channel, newName); + await enqueueRename(interaction.channel, `🟢${currentName}`); } catch (e) { console.error('Rename error (unclaim):', e); } + try { + if (CONFIG.STAFF_T1_CATEGORY) await enqueueMove(interaction.channel, CONFIG.STAFF_T1_CATEGORY); + } catch (e) { + console.error('Move error (unclaim):', e); + } } else { const unlockAtMs = Date.now() + renameInfo.waitMs; const unlockAtUnix = Math.floor(unlockAtMs / 1000); @@ -521,6 +554,13 @@ async function handleConfirmClose(interaction, ticket) { { $set: { discordThreadId: null, status: 'closed' } } ); + try { + const { deleteStaffChannel } = require('../services/staffChannel'); + await deleteStaffChannel(interaction.guild, ticket.staffChannelId); + } catch (e) { + console.error('Delete staff channel (close):', e); + } + if (transcriptMsg?.id) { await Transcript.create({ gmailThreadId: ticket.gmailThreadId, diff --git a/handlers/commands.js b/handlers/commands.js index 5d58ecc..768fa2e 100644 --- a/handlers/commands.js +++ b/handlers/commands.js @@ -18,6 +18,8 @@ const { sendTicketNotificationEmail } = require('../services/gmail'); const { getTicketActionRow } = require('../utils/ticketComponents'); const { getEmailRouting } = require('../services/guildSettings'); const { enqueueRename, enqueueMove } = require('../services/channelQueue'); +const { moveStaffChannel } = require('../services/staffChannel'); +const { setNotifyDm } = require('../services/staffSettings'); const { trackInteraction, trackError, getAnalyticsSummary } = require('./analytics'); const { handleAccountInfoCommand } = require('./accountinfo'); const { handleSetupCommand } = require('./setup'); @@ -67,11 +69,10 @@ async function runEscalation(interaction, ticket, nextTier, reason) { await Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, - { $set: { escalated: true, escalationTier: nextTier, claimedBy: null } } + { $set: { escalated: true, escalationTier: nextTier } } ); ticket.escalated = true; ticket.escalationTier = nextTier; - ticket.claimedBy = null; const renameInfo = await canRename(ticket); if (renameInfo.ok) { @@ -97,6 +98,23 @@ async function runEscalation(interaction, ticket, nextTier, reason) { await enqueueMove(interaction.channel, categoryId); } + if (!interaction.channel.isThread()) { + try { + const emoji = nextTier === 1 ? CONFIG.PRIORITY_MEDIUM_EMOJI : CONFIG.PRIORITY_HIGH_EMOJI; + const baseName = interaction.channel.name.replace(/^[🟢🟡🔴]/, ''); + const renameInfoEsc = await canRename(ticket); + if (renameInfoEsc.ok) await enqueueRename(interaction.channel, `${emoji}${baseName}`); + const tierCategory = nextTier === 1 ? CONFIG.STAFF_T2_CATEGORY : CONFIG.STAFF_T3_CATEGORY; + if (tierCategory) await enqueueMove(interaction.channel, tierCategory); + if (ticket.staffChannelId) { + const staffChan = await interaction.guild.channels.fetch(ticket.staffChannelId).catch(() => null); + if (staffChan) await moveStaffChannel(staffChan, tierCategory); + } + } catch (e) { + console.error('Staff tier category (escalate):', e); + } + } + const pendingEmbed = new EmbedBuilder() .setDescription('Ticket will be escalated in a few seconds.') .setColor(CONFIG.EMBED_COLOR_INFO); @@ -168,18 +186,6 @@ async function runDeescalation(interaction, ticket) { const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); const newTier = currentTier - 1; - let categoryId = null; - if (!interaction.channel.isThread()) { - if (newTier === 0) { - const categoryIds = isDiscordTicket - ? [CONFIG.DISCORD_TICKET_CATEGORY_ID, ...(CONFIG.DISCORD_TICKET_OVERFLOW_CATEGORY_IDS || [])] - : [CONFIG.TICKET_CATEGORY_ID, ...(CONFIG.EMAIL_TICKET_OVERFLOW_CATEGORY_IDS || [])]; - categoryId = pickTicketCategoryId(interaction.guild, categoryIds); - } else { - categoryId = isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID; - if (!categoryId) categoryId = isDiscordTicket ? CONFIG.DISCORD_ESCALATED_CATEGORY_ID : CONFIG.EMAIL_ESCALATED_CATEGORY_ID; - } - } await Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, @@ -188,15 +194,15 @@ async function runDeescalation(interaction, ticket) { ticket.escalated = newTier > 0; ticket.escalationTier = newTier; + const baseName = interaction.channel.name.replace(/^[🟢🟡🔴]/, ''); const renameInfo = await canRename(ticket); if (renameInfo.ok) { - const newName = makeTicketName( - { escalated: newTier > 0, claimed: false }, - ticket, - interaction.guild - ); try { - await enqueueRename(interaction.channel, newName); + const emoji = newTier === 0 ? CONFIG.PRIORITY_LOW_EMOJI : CONFIG.PRIORITY_MEDIUM_EMOJI; + await enqueueRename( + interaction.channel, + newTier === 0 ? baseName : `${emoji}${baseName}` + ); } catch (e) { console.error('Rename error (deescalate):', e); } @@ -208,8 +214,13 @@ async function runDeescalation(interaction, ticket) { ); } - if (!interaction.channel.isThread() && categoryId) { - await enqueueMove(interaction.channel, categoryId); + if (!interaction.channel.isThread()) { + try { + if (newTier === 0 && CONFIG.STAFF_T1_CATEGORY) await enqueueMove(interaction.channel, CONFIG.STAFF_T1_CATEGORY); + if (newTier === 1 && CONFIG.STAFF_T2_CATEGORY) await enqueueMove(interaction.channel, CONFIG.STAFF_T2_CATEGORY); + } catch (e) { + console.error('Move error (deescalate):', e); + } } const tierLabel = newTier === 0 ? 'normal' : newTier === 1 ? 'tier 2' : 'tier 3'; @@ -271,10 +282,12 @@ async function handleCommand(interaction) { return; } - // /escalate (optionally to a target tier; works for both email and Discord) + // /escalate (tier 2 or 3 via level; works for both email and Discord) if (interaction.commandName === 'escalate') { - const reason = interaction.options.getString('reason') || 'No reason provided.'; - const tierOption = interaction.options.getInteger('tier'); // 2 or 3 from choice, or null + const reason = null; + const level = interaction.options.getString('level'); + const nextTier = level === '3' ? 2 : 1; + const action = interaction.options.getString('action'); const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); if (!ticket) { @@ -286,9 +299,6 @@ async function handleCommand(interaction) { return interaction.reply({ content: 'This ticket is already at tier 3 support.', ephemeral: true }); } - const nextTier = tierOption != null - ? (tierOption === 3 ? 2 : 1) // 3 → DB tier 2, 2 → DB tier 1 - : currentTier + 1; if (nextTier <= currentTier) { return interaction.reply({ content: 'Ticket is already at or past that tier.', ephemeral: true }); } @@ -307,12 +317,33 @@ async function handleCommand(interaction) { try { await runEscalation(interaction, ticket, nextTier, reason); + if (action === 'unclaim') { + await Ticket.updateOne( + { gmailThreadId: ticket.gmailThreadId }, + { $set: { claimedBy: null, claimerId: null } } + ); + } } catch (err) { console.error('Escalate error:', err); await interaction.reply({ content: 'Failed to escalate this ticket.', ephemeral: true }); } } + if (interaction.commandName === 'notifydm') { + try { + const setting = interaction.options.getString('setting') === 'on'; + await setNotifyDm(interaction.user.id, interaction.guildId, setting); + await interaction.reply({ + content: `DM notifications ${setting ? 'enabled ✅' : 'disabled 🔕'}.`, + ephemeral: true + }); + } catch (err) { + console.error('notifydm error:', err); + await interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {}); + } + return; + } + // /deescalate (tier 3 → tier 2, tier 2 → normal) if (interaction.commandName === 'deescalate') { const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); diff --git a/handlers/messages.js b/handlers/messages.js index 95af410..b966d7b 100644 --- a/handlers/messages.js +++ b/handlers/messages.js @@ -6,6 +6,8 @@ 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 { pingStaffChannel } = require('../services/staffChannel'); const Ticket = mongoose.model('Ticket'); @@ -18,6 +20,29 @@ async function handleDiscordReply(m) { const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean(); if (!ticket) return; + if (ticket.claimerId && m.author.id !== ticket.claimerId && ticket.staffChannelId) { + try { + const staffChan = await m.guild.channels.fetch(ticket.staffChannelId).catch(() => null); + if (staffChan) { + await pingStaffChannel(staffChan, ticket.claimerId, m); + } + 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(() => {}); + } + } + } catch (e) { + console.error('Staff ping error:', e); + } + } + const discordUser = m.member?.displayName || m.author.username; if (ticket.gmailThreadId.startsWith('discord-')) { diff --git a/models.js b/models.js index 9b504c4..b5092a8 100644 --- a/models.js +++ b/models.js @@ -812,7 +812,9 @@ mongoose.model('Ticket', new mongoose.Schema({ ticketTag: String, // e.g. server-down, billing – used for channel name prefix (after priority emoji) lastActivity: Date, reminderSent: { type: Boolean, default: false }, - welcomeMessageId: String + welcomeMessageId: String, + claimerId: String, + staffChannelId: String })); mongoose.model('TicketCounter', new mongoose.Schema({ @@ -846,3 +848,10 @@ mongoose.model('GuildSettings', new mongoose.Schema({ 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 }, + notifyDm: { type: Boolean, default: false }, + updatedAt: { type: Date, default: Date.now } +})); diff --git a/services/staffChannel.js b/services/staffChannel.js new file mode 100644 index 0000000..e316f37 --- /dev/null +++ b/services/staffChannel.js @@ -0,0 +1,87 @@ +const { CONFIG } = require('../config'); + +/** + * Create a staff tracking channel for a ticket. + * Returns the created channel or null if no staff category configured. + */ +async function createStaffChannel(guild, ticket, claimerId, channelName) { + const categoryId = CONFIG.STAFF_CATEGORIES.get(claimerId); + if (!categoryId) return null; + + try { + const { ChannelType } = require('discord.js'); + const staffChan = await guild.channels.create({ + name: channelName, + type: ChannelType.GuildText, + parent: categoryId + }); + + // Build pinned embed with ticket info + jump link to original ticket channel + const { EmbedBuilder } = require('discord.js'); + const originalChannel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null); + const jumpLink = originalChannel ? `https://discord.com/channels/${guild.id}/${ticket.discordThreadId}` : null; + + const embed = new EmbedBuilder() + .setTitle(`🎫 Ticket #${ticket.ticketNumber}`) + .setColor(0x5865f2) + .addFields( + { name: 'Customer', value: ticket.senderEmail || 'Unknown', inline: true }, + { name: 'Game', value: ticket.game || 'Not detected', inline: true }, + { name: 'Subject', value: ticket.subject || 'No subject', inline: false }, + { name: 'Original Ticket', value: jumpLink ? `[Jump to ticket](${jumpLink})` : 'Unknown', inline: false } + ) + .setFooter({ text: `Claimed by ${ticket.claimedBy || 'Unknown'}` }) + .setTimestamp(); + + const pinMsg = await staffChan.send({ embeds: [embed] }); + await pinMsg.pin().catch(() => {}); + + return staffChan; + } catch (e) { + console.error('Failed to create staff channel:', e); + return null; + } +} + +/** + * Ping the staff channel with a customer reply, including jump link and message copy. + */ +async function pingStaffChannel(staffChannel, claimerId, originalMessage) { + if (!staffChannel) return; + try { + const jumpLink = `https://discord.com/channels/${originalMessage.guild.id}/${originalMessage.channel.id}/${originalMessage.id}`; + await staffChannel.send( + `<@${claimerId}> Customer replied in ticket:\n> ${originalMessage.content.slice(0, 500)}\n[Jump to message](${jumpLink})` + ); + } catch (e) { + console.error('Failed to ping staff channel:', e); + } +} + +/** + * Move staff channel to a different category. + */ +async function moveStaffChannel(staffChannel, categoryId) { + if (!staffChannel || !categoryId) return; + try { + const { enqueueMove } = require('./channelQueue'); + await enqueueMove(staffChannel, categoryId); + } catch (e) { + console.error('Failed to move staff channel:', e); + } +} + +/** + * Delete the staff tracking channel. + */ +async function deleteStaffChannel(guild, staffChannelId) { + if (!staffChannelId) return; + try { + const chan = await guild.channels.fetch(staffChannelId).catch(() => null); + if (chan) await chan.delete(); + } catch (e) { + console.error('Failed to delete staff channel:', e); + } +} + +module.exports = { createStaffChannel, pingStaffChannel, moveStaffChannel, deleteStaffChannel }; diff --git a/services/staffSettings.js b/services/staffSettings.js new file mode 100644 index 0000000..2c14202 --- /dev/null +++ b/services/staffSettings.js @@ -0,0 +1,17 @@ +const { mongoose } = require('../db-connection'); +const StaffSettings = mongoose.model('StaffSettings'); + +async function getNotifyDm(userId) { + const doc = await StaffSettings.findOne({ userId }).lean(); + return doc ? doc.notifyDm : false; +} + +async function setNotifyDm(userId, guildId, value) { + await StaffSettings.findOneAndUpdate( + { userId }, + { $set: { notifyDm: value, guildId, updatedAt: new Date() } }, + { upsert: true, new: true } + ); +} + +module.exports = { getNotifyDm, setNotifyDm };