personal queue

This commit is contained in:
indifferentketchup
2026-03-28 20:07:17 -05:00
parent 8a4e306f28
commit 6b4fd65d4b
8 changed files with 300 additions and 53 deletions

View File

@@ -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,

View File

@@ -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();

View File

@@ -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-')) {