personal queue
This commit is contained in:
@@ -22,26 +22,28 @@ async function registerCommands() {
|
|||||||
const commands = [
|
const commands = [
|
||||||
new SlashCommandBuilder()
|
new SlashCommandBuilder()
|
||||||
.setName('escalate')
|
.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])
|
.setContexts([InteractionContextType.Guild])
|
||||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
|
||||||
.addStringOption(opt =>
|
.addStringOption(opt =>
|
||||||
opt
|
opt
|
||||||
.setName('reason')
|
.setName('level')
|
||||||
.setDescription('Reason for escalating')
|
.setDescription('Target escalation level')
|
||||||
.setMinLength(10)
|
.setRequired(true)
|
||||||
.setMaxLength(500)
|
|
||||||
.setRequired(false)
|
|
||||||
)
|
|
||||||
.addIntegerOption(opt =>
|
|
||||||
opt
|
|
||||||
.setName('tier')
|
|
||||||
.setDescription('Target tier (2 or 3); omit to escalate one step')
|
|
||||||
.setRequired(false)
|
|
||||||
.addChoices(
|
.addChoices(
|
||||||
{ name: 'Tier 2', value: 2 },
|
{ name: 'Tier 2', value: '2' },
|
||||||
{ name: 'Tier 3', value: 3 }
|
{ 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])
|
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild),
|
.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()
|
new SlashCommandBuilder()
|
||||||
.setName('backup')
|
.setName('backup')
|
||||||
.setDescription('Export full ticket list to a .txt file in the backup/export channel')
|
.setDescription('Export full ticket list to a .txt file in the backup/export channel')
|
||||||
|
|||||||
21
config.js
21
config.js
@@ -115,7 +115,26 @@ const CONFIG = {
|
|||||||
EMBED_COLOR_CLOSED: parseInt(process.env.EMBED_COLOR_CLOSED) || 0xFF0000,
|
EMBED_COLOR_CLOSED: parseInt(process.env.EMBED_COLOR_CLOSED) || 0xFF0000,
|
||||||
EMBED_COLOR_CLAIMED: parseInt(process.env.EMBED_COLOR_CLAIMED) || 0xFFFF00,
|
EMBED_COLOR_CLAIMED: parseInt(process.env.EMBED_COLOR_CLAIMED) || 0xFFFF00,
|
||||||
EMBED_COLOR_ESCALATED: parseInt(process.env.EMBED_COLOR_ESCALATED) || 0xFF6600,
|
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. */
|
/** Ticket category tags for /tag set – [emoji] [label] in dropdown; priority emoji always first in channel name, then tag emoji. */
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ const { canRename, makeTicketName, minutesFromMs, pickTicketCategoryId, createDi
|
|||||||
const { sendTicketClosedEmail } = require('../services/gmail');
|
const { sendTicketClosedEmail } = require('../services/gmail');
|
||||||
const { getTicketActionRow } = require('../utils/ticketComponents');
|
const { getTicketActionRow } = require('../utils/ticketComponents');
|
||||||
const { setEmailRouting } = require('../services/guildSettings');
|
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 { runEscalation, runDeescalation } = require('./commands');
|
||||||
const { trackInteraction, trackError } = require('./analytics');
|
const { trackInteraction, trackError } = require('./analytics');
|
||||||
|
|
||||||
@@ -286,9 +287,10 @@ async function handleClaim(interaction, ticket) {
|
|||||||
if (!isClaimed || (isClaimed && !isClaimedByMe && CONFIG.ALLOW_CLAIM_OVERWRITE)) {
|
if (!isClaimed || (isClaimed && !isClaimedByMe && CONFIG.ALLOW_CLAIM_OVERWRITE)) {
|
||||||
await Ticket.updateOne(
|
await Ticket.updateOne(
|
||||||
{ gmailThreadId: freshTicket.gmailThreadId },
|
{ gmailThreadId: freshTicket.gmailThreadId },
|
||||||
{ $set: { claimedBy: claimerLabel } }
|
{ $set: { claimedBy: claimerLabel, claimerId: interaction.user.id } }
|
||||||
);
|
);
|
||||||
freshTicket.claimedBy = claimerLabel;
|
freshTicket.claimedBy = claimerLabel;
|
||||||
|
freshTicket.claimerId = interaction.user.id;
|
||||||
|
|
||||||
const renameInfo = await canRename(freshTicket);
|
const renameInfo = await canRename(freshTicket);
|
||||||
if (renameInfo.ok) {
|
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 baseLabel = `Unclaim (${claimerLabel})`;
|
||||||
const label = renameInfo.ok
|
const label = renameInfo.ok
|
||||||
? baseLabel
|
? baseLabel
|
||||||
@@ -341,24 +362,36 @@ async function handleClaim(interaction, ticket) {
|
|||||||
await interaction.followUp({ embeds: [claimEmbed] });
|
await interaction.followUp({ embeds: [claimEmbed] });
|
||||||
} else {
|
} else {
|
||||||
// Unclaim
|
// 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(
|
await Ticket.updateOne(
|
||||||
{ gmailThreadId: freshTicket.gmailThreadId },
|
{ gmailThreadId: freshTicket.gmailThreadId },
|
||||||
{ $set: { claimedBy: null } }
|
{ $set: { claimedBy: null, claimerId: null, staffChannelId: null } }
|
||||||
);
|
);
|
||||||
freshTicket.claimedBy = null;
|
freshTicket.claimedBy = null;
|
||||||
|
freshTicket.claimerId = null;
|
||||||
|
freshTicket.staffChannelId = null;
|
||||||
|
|
||||||
const renameInfo = await canRename(freshTicket);
|
const renameInfo = await canRename(freshTicket);
|
||||||
if (renameInfo.ok) {
|
if (renameInfo.ok) {
|
||||||
const newName = makeTicketName(
|
const currentName = interaction.channel.name.replace(/^[🟢🟡🔴]/, '');
|
||||||
{ escalated: !!freshTicket.escalated, claimed: false },
|
|
||||||
freshTicket,
|
|
||||||
guild
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
await enqueueRename(interaction.channel, newName);
|
await enqueueRename(interaction.channel, `🟢${currentName}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Rename error (unclaim):', 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 {
|
} else {
|
||||||
const unlockAtMs = Date.now() + renameInfo.waitMs;
|
const unlockAtMs = Date.now() + renameInfo.waitMs;
|
||||||
const unlockAtUnix = Math.floor(unlockAtMs / 1000);
|
const unlockAtUnix = Math.floor(unlockAtMs / 1000);
|
||||||
@@ -521,6 +554,13 @@ async function handleConfirmClose(interaction, ticket) {
|
|||||||
{ $set: { discordThreadId: null, status: 'closed' } }
|
{ $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) {
|
if (transcriptMsg?.id) {
|
||||||
await Transcript.create({
|
await Transcript.create({
|
||||||
gmailThreadId: ticket.gmailThreadId,
|
gmailThreadId: ticket.gmailThreadId,
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const { sendTicketNotificationEmail } = require('../services/gmail');
|
|||||||
const { getTicketActionRow } = require('../utils/ticketComponents');
|
const { getTicketActionRow } = require('../utils/ticketComponents');
|
||||||
const { getEmailRouting } = require('../services/guildSettings');
|
const { getEmailRouting } = require('../services/guildSettings');
|
||||||
const { enqueueRename, enqueueMove } = require('../services/channelQueue');
|
const { enqueueRename, enqueueMove } = require('../services/channelQueue');
|
||||||
|
const { moveStaffChannel } = require('../services/staffChannel');
|
||||||
|
const { setNotifyDm } = require('../services/staffSettings');
|
||||||
const { trackInteraction, trackError, getAnalyticsSummary } = require('./analytics');
|
const { trackInteraction, trackError, getAnalyticsSummary } = require('./analytics');
|
||||||
const { handleAccountInfoCommand } = require('./accountinfo');
|
const { handleAccountInfoCommand } = require('./accountinfo');
|
||||||
const { handleSetupCommand } = require('./setup');
|
const { handleSetupCommand } = require('./setup');
|
||||||
@@ -67,11 +69,10 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
|||||||
|
|
||||||
await Ticket.updateOne(
|
await Ticket.updateOne(
|
||||||
{ gmailThreadId: ticket.gmailThreadId },
|
{ gmailThreadId: ticket.gmailThreadId },
|
||||||
{ $set: { escalated: true, escalationTier: nextTier, claimedBy: null } }
|
{ $set: { escalated: true, escalationTier: nextTier } }
|
||||||
);
|
);
|
||||||
ticket.escalated = true;
|
ticket.escalated = true;
|
||||||
ticket.escalationTier = nextTier;
|
ticket.escalationTier = nextTier;
|
||||||
ticket.claimedBy = null;
|
|
||||||
|
|
||||||
const renameInfo = await canRename(ticket);
|
const renameInfo = await canRename(ticket);
|
||||||
if (renameInfo.ok) {
|
if (renameInfo.ok) {
|
||||||
@@ -97,6 +98,23 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
|||||||
await enqueueMove(interaction.channel, categoryId);
|
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()
|
const pendingEmbed = new EmbedBuilder()
|
||||||
.setDescription('Ticket will be escalated in a few seconds.')
|
.setDescription('Ticket will be escalated in a few seconds.')
|
||||||
.setColor(CONFIG.EMBED_COLOR_INFO);
|
.setColor(CONFIG.EMBED_COLOR_INFO);
|
||||||
@@ -168,18 +186,6 @@ async function runDeescalation(interaction, ticket) {
|
|||||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||||||
const newTier = currentTier - 1;
|
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(
|
await Ticket.updateOne(
|
||||||
{ gmailThreadId: ticket.gmailThreadId },
|
{ gmailThreadId: ticket.gmailThreadId },
|
||||||
@@ -188,15 +194,15 @@ async function runDeescalation(interaction, ticket) {
|
|||||||
ticket.escalated = newTier > 0;
|
ticket.escalated = newTier > 0;
|
||||||
ticket.escalationTier = newTier;
|
ticket.escalationTier = newTier;
|
||||||
|
|
||||||
|
const baseName = interaction.channel.name.replace(/^[🟢🟡🔴]/, '');
|
||||||
const renameInfo = await canRename(ticket);
|
const renameInfo = await canRename(ticket);
|
||||||
if (renameInfo.ok) {
|
if (renameInfo.ok) {
|
||||||
const newName = makeTicketName(
|
|
||||||
{ escalated: newTier > 0, claimed: false },
|
|
||||||
ticket,
|
|
||||||
interaction.guild
|
|
||||||
);
|
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
console.error('Rename error (deescalate):', e);
|
console.error('Rename error (deescalate):', e);
|
||||||
}
|
}
|
||||||
@@ -208,8 +214,13 @@ async function runDeescalation(interaction, ticket) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!interaction.channel.isThread() && categoryId) {
|
if (!interaction.channel.isThread()) {
|
||||||
await enqueueMove(interaction.channel, categoryId);
|
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';
|
const tierLabel = newTier === 0 ? 'normal' : newTier === 1 ? 'tier 2' : 'tier 3';
|
||||||
@@ -271,10 +282,12 @@ async function handleCommand(interaction) {
|
|||||||
return;
|
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') {
|
if (interaction.commandName === 'escalate') {
|
||||||
const reason = interaction.options.getString('reason') || 'No reason provided.';
|
const reason = null;
|
||||||
const tierOption = interaction.options.getInteger('tier'); // 2 or 3 from choice, or 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();
|
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
@@ -286,9 +299,6 @@ async function handleCommand(interaction) {
|
|||||||
return interaction.reply({ content: 'This ticket is already at tier 3 support.', ephemeral: true });
|
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) {
|
if (nextTier <= currentTier) {
|
||||||
return interaction.reply({ content: 'Ticket is already at or past that tier.', ephemeral: true });
|
return interaction.reply({ content: 'Ticket is already at or past that tier.', ephemeral: true });
|
||||||
}
|
}
|
||||||
@@ -307,12 +317,33 @@ async function handleCommand(interaction) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await runEscalation(interaction, ticket, nextTier, reason);
|
await runEscalation(interaction, ticket, nextTier, reason);
|
||||||
|
if (action === 'unclaim') {
|
||||||
|
await Ticket.updateOne(
|
||||||
|
{ gmailThreadId: ticket.gmailThreadId },
|
||||||
|
{ $set: { claimedBy: null, claimerId: null } }
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Escalate error:', err);
|
console.error('Escalate error:', err);
|
||||||
await interaction.reply({ content: 'Failed to escalate this ticket.', ephemeral: true });
|
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)
|
// /deescalate (tier 3 → tier 2, tier 2 → normal)
|
||||||
if (interaction.commandName === 'deescalate') {
|
if (interaction.commandName === 'deescalate') {
|
||||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const { CONFIG } = require('../config');
|
|||||||
const { extractRawEmail } = require('../utils');
|
const { extractRawEmail } = require('../utils');
|
||||||
const { getGmailClient, sendGmailReply } = require('../services/gmail');
|
const { getGmailClient, sendGmailReply } = require('../services/gmail');
|
||||||
const { updateTicketActivity } = require('../services/tickets');
|
const { updateTicketActivity } = require('../services/tickets');
|
||||||
|
const { getNotifyDm } = require('../services/staffSettings');
|
||||||
|
const { pingStaffChannel } = require('../services/staffChannel');
|
||||||
|
|
||||||
const Ticket = mongoose.model('Ticket');
|
const Ticket = mongoose.model('Ticket');
|
||||||
|
|
||||||
@@ -18,6 +20,29 @@ async function handleDiscordReply(m) {
|
|||||||
const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean();
|
const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean();
|
||||||
if (!ticket) return;
|
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;
|
const discordUser = m.member?.displayName || m.author.username;
|
||||||
|
|
||||||
if (ticket.gmailThreadId.startsWith('discord-')) {
|
if (ticket.gmailThreadId.startsWith('discord-')) {
|
||||||
|
|||||||
11
models.js
11
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)
|
ticketTag: String, // e.g. server-down, billing – used for channel name prefix (after priority emoji)
|
||||||
lastActivity: Date,
|
lastActivity: Date,
|
||||||
reminderSent: { type: Boolean, default: false },
|
reminderSent: { type: Boolean, default: false },
|
||||||
welcomeMessageId: String
|
welcomeMessageId: String,
|
||||||
|
claimerId: String,
|
||||||
|
staffChannelId: String
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mongoose.model('TicketCounter', new mongoose.Schema({
|
mongoose.model('TicketCounter', new mongoose.Schema({
|
||||||
@@ -846,3 +848,10 @@ mongoose.model('GuildSettings', new mongoose.Schema({
|
|||||||
emailRouting: { type: String, enum: ['thread', 'category'], default: 'category' },
|
emailRouting: { type: String, enum: ['thread', 'category'], default: 'category' },
|
||||||
updatedAt: { type: Date, default: Date.now }
|
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 }
|
||||||
|
}));
|
||||||
|
|||||||
87
services/staffChannel.js
Normal file
87
services/staffChannel.js
Normal file
@@ -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 };
|
||||||
17
services/staffSettings.js
Normal file
17
services/staffSettings.js
Normal file
@@ -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 };
|
||||||
Reference in New Issue
Block a user