staff notifications

This commit is contained in:
indifferentketchup
2026-04-06 23:53:32 -05:00
parent 8c95b5eb8d
commit c5d7539677
12 changed files with 379 additions and 108 deletions

View File

@@ -16,7 +16,7 @@ const {
} = require('discord.js');
const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config');
const { canRename, makeTicketName, minutesFromMs, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit, getSenderLocal } = require('../services/tickets');
const { canRename, makeTicketName, resolveCreatorNickname, minutesFromMs, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit, getSenderLocal, toDiscordSafeName } = require('../services/tickets');
const { sendTicketClosedEmail } = require('../services/gmail');
const { getTicketActionRow } = require('../utils/ticketComponents');
const { setEmailRouting } = require('../services/guildSettings');
@@ -144,16 +144,24 @@ async function handleButton(interaction) {
if (currentTier >= 2) {
return interaction.reply({ content: 'This ticket is already at tier 3 support.', ephemeral: true });
}
const choiceRow = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('escalate_to_tier2')
.setLabel('To Tier 2')
.setStyle(ButtonStyle.Secondary),
new ButtonBuilder()
.setCustomId('escalate_to_tier3')
.setLabel('To Tier 3')
.setStyle(ButtonStyle.Secondary)
);
const escalateButtons = [];
if (currentTier < 1) {
escalateButtons.push(
new ButtonBuilder()
.setCustomId('escalate_to_tier2')
.setLabel('To Tier 2')
.setStyle(ButtonStyle.Secondary)
);
}
if (currentTier < 2) {
escalateButtons.push(
new ButtonBuilder()
.setCustomId('escalate_to_tier3')
.setLabel('To Tier 3')
.setStyle(ButtonStyle.Secondary)
);
}
const choiceRow = new ActionRowBuilder().addComponents(escalateButtons);
return interaction.reply({
content: 'Escalate to which tier?',
components: [choiceRow],
@@ -302,29 +310,12 @@ async function handleClaim(interaction, ticket) {
// Resolve claimerEmoji from STAFF_EMOJIS map (fallback to CLAIMER_EMOJI_FALLBACK)
const claimerEmoji = CONFIG.STAFF_EMOJIS.get(interaction.user.id) || CONFIG.CLAIMER_EMOJI_FALLBACK;
// Resolve creatorNickname: displayName for Discord tickets, senderLocal for email tickets
let creatorNickname;
if (freshTicket.gmailThreadId.startsWith('discord-')) {
const creatorUserId = freshTicket.gmailThreadId.split('-').pop();
try {
const creatorMember = await guild.members.fetch(creatorUserId);
creatorNickname = creatorMember.displayName;
} catch {
creatorNickname = freshTicket.senderEmail;
}
} else {
creatorNickname = getSenderLocal(freshTicket.senderEmail);
}
const creatorNickname = await resolveCreatorNickname(guild, freshTicket);
const renameInfo = await canRename(freshTicket);
if (renameInfo.ok) {
const newName = makeTicketName(
{ escalated: !!freshTicket.escalated, claimed: true },
freshTicket,
guild,
claimerEmoji,
creatorNickname
);
const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed';
const newName = makeTicketName(state, freshTicket, creatorNickname, claimerEmoji);
try {
await enqueueRename(interaction.channel, newName);
} catch (e) {
@@ -377,10 +368,12 @@ async function handleClaim(interaction, ticket) {
freshTicket.claimerId = null;
freshTicket.staffChannelId = null;
const creatorNicknameUnclaim = await resolveCreatorNickname(guild, freshTicket);
const unclaimState = (freshTicket.escalationTier ?? 0) >= 1 ? 'escalated' : 'unclaimed';
const renameInfo = await canRename(freshTicket);
if (renameInfo.ok) {
try {
await enqueueRename(interaction.channel, `ticket-${freshTicket.ticketNumber}`);
await enqueueRename(interaction.channel, makeTicketName(unclaimState, freshTicket, creatorNicknameUnclaim));
} catch (e) {
console.error('Rename error (unclaim):', e);
}
@@ -607,6 +600,9 @@ async function handleTicketModal(interaction) {
const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean();
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
const creatorNicknameModal = interaction.member?.displayName || interaction.user.username;
const unclaimedName = toDiscordSafeName(`unclaimed-${creatorNicknameModal}-${ticketNumber}`);
let channel;
let parentCategoryIdForTicket = null;
if (useThread && CONFIG.DISCORD_THREAD_CHANNEL_ID) {
@@ -634,7 +630,7 @@ async function handleTicketModal(interaction) {
parentCategoryIdForTicket = parentId;
try {
channel = await guild.channels.create({
name: `ticket-${ticketNumber}`,
name: unclaimedName,
type: ChannelType.GuildText,
parent: parentId,
permissionOverwrites: [

View File

@@ -13,7 +13,7 @@ const {
const { mongoose } = require('../db-connection');
const { CONFIG, TICKET_TAGS } = require('../config');
const { getPriorityEmoji, getPriorityColor, replaceVariables, escapeRegex } = require('../utils');
const { canRename, makeTicketName, getOrCreateTicketCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
const { canRename, makeTicketName, resolveCreatorNickname, getSenderLocal, toDiscordSafeName, getOrCreateTicketCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
const { sendTicketNotificationEmail } = require('../services/gmail');
const { getTicketActionRow } = require('../utils/ticketComponents');
const { getEmailRouting } = require('../services/guildSettings');
@@ -26,6 +26,7 @@ const { handleSetupCommand } = require('./setup');
const Ticket = mongoose.model('Ticket');
const Tag = mongoose.model('Tag');
const User = mongoose.model('User');
const StaffNotification = mongoose.model('StaffNotification');
/**
* True if member has the support role (ROLE_ID_TO_PING) or any ADDITIONAL_STAFF_ROLES.
@@ -66,20 +67,19 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
? (isDiscordTicket ? (CONFIG.DISCORD_ESCALATED2_CHANNEL_ID || CONFIG.DISCORD_ESCALATED_CATEGORY_ID) : (CONFIG.EMAIL_ESCALATED2_CHANNEL_ID || CONFIG.EMAIL_ESCALATED_CATEGORY_ID))
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
// Clear claim on escalation
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { escalated: true, escalationTier: nextTier } }
{ $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null } }
);
ticket.escalated = true;
ticket.escalationTier = nextTier;
ticket.claimedBy = null;
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const renameInfo = await canRename(ticket);
if (renameInfo.ok) {
const newName = makeTicketName(
{ escalated: true, claimed: false },
ticket,
interaction.guild
);
const newName = makeTicketName('escalated', ticket, creatorNickname);
try {
await enqueueRename(interaction.channel, newName);
} catch (e) {
@@ -97,19 +97,6 @@ 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);
} 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);
@@ -188,20 +175,18 @@ async function runDeescalation(interaction, ticket) {
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { escalated: newTier > 0, escalationTier: newTier, claimedBy: null } }
{ $set: { escalated: newTier > 0, escalationTier: newTier, claimedBy: null, claimerId: null } }
);
ticket.escalated = newTier > 0;
ticket.escalationTier = newTier;
ticket.claimedBy = null;
const baseName = interaction.channel.name.replace(/^[🟢🟡🔴]/, '');
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const state = newTier === 0 ? 'unclaimed' : 'escalated';
const renameInfo = await canRename(ticket);
if (renameInfo.ok) {
try {
const emoji = newTier === 0 ? CONFIG.PRIORITY_LOW_EMOJI : CONFIG.PRIORITY_MEDIUM_EMOJI;
await enqueueRename(
interaction.channel,
newTier === 0 ? baseName : `${emoji}${baseName}`
);
await enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname));
} catch (e) {
console.error('Rename error (deescalate):', e);
}
@@ -215,8 +200,15 @@ async function runDeescalation(interaction, ticket) {
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);
if (newTier === 0) {
const homeCategory = isDiscordTicket ? CONFIG.DISCORD_TICKET_CATEGORY_ID : CONFIG.TICKET_CATEGORY_ID;
if (homeCategory) await enqueueMove(interaction.channel, homeCategory);
} else if (newTier === 1) {
const t2Category = isDiscordTicket
? (CONFIG.DISCORD_ESCALATED2_CHANNEL_ID || CONFIG.DISCORD_ESCALATED_CATEGORY_ID)
: (CONFIG.EMAIL_ESCALATED2_CHANNEL_ID || CONFIG.EMAIL_ESCALATED_CATEGORY_ID);
if (t2Category) await enqueueMove(interaction.channel, t2Category);
}
} catch (e) {
console.error('Move error (deescalate):', e);
}
@@ -328,6 +320,82 @@ async function handleCommand(interaction) {
}
}
// /notification set | /notification add
if (interaction.commandName === 'notification') {
const sub = interaction.options.getSubcommand();
if (sub === 'set') {
const hours = interaction.options.getInteger('hours');
try {
await StaffNotification.findOneAndUpdate(
{ userId: interaction.user.id },
{ $set: { cooldownHours: hours, updatedAt: new Date() } },
{ upsert: true }
);
return interaction.reply({ content: `Notification cooldown set to ${hours} hour(s).`, ephemeral: true });
} catch (err) {
console.error('notification set error:', err);
return interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {});
}
}
if (sub === 'add') {
if (!CONFIG.STAFF_NOTIFICATION_CATEGORY_ID) {
return interaction.reply({ content: 'STAFF_NOTIFICATION_CATEGORY_ID is not configured.', ephemeral: true });
}
const member = interaction.options.getMember('member');
if (!member) {
return interaction.reply({ content: 'Could not resolve that member.', ephemeral: true });
}
const displayName = member.displayName;
const emoji = CONFIG.STAFF_EMOJIS.get(member.id) || '';
const chanName = toDiscordSafeName(`${displayName}${emoji}`);
try {
const newChannel = await interaction.guild.channels.create({
name: chanName,
type: ChannelType.GuildText,
parent: CONFIG.STAFF_NOTIFICATION_CATEGORY_ID,
permissionOverwrites: [
{ id: interaction.guild.id, deny: [PermissionFlagsBits.ViewChannel] },
{ id: member.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] },
...(CONFIG.ROLE_ID_TO_PING ? [{ id: CONFIG.ROLE_ID_TO_PING, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] }] : [])
]
});
await StaffNotification.findOneAndUpdate(
{ userId: member.id },
{ $set: { channelId: newChannel.id, guildId: interaction.guild.id, updatedAt: new Date() } },
{ upsert: true }
);
return interaction.reply({ content: `Notification channel created: ${newChannel}`, ephemeral: true });
} catch (err) {
console.error('notification add error:', err);
return interaction.reply({ content: 'Failed to create notification channel.', ephemeral: true }).catch(() => {});
}
}
return;
}
// /staffnotification (admin only)
if (interaction.commandName === 'staffnotification') {
if (interaction.user.id !== CONFIG.ADMIN_ID) {
return interaction.reply({ content: 'This command is restricted to the bot admin.', ephemeral: true });
}
const member = interaction.options.getMember('member');
const hours = interaction.options.getInteger('hours');
if (!member) {
return interaction.reply({ content: 'Could not resolve that member.', ephemeral: true });
}
try {
await StaffNotification.findOneAndUpdate(
{ userId: member.id },
{ $set: { cooldownHours: hours, updatedAt: new Date() } },
{ upsert: true }
);
return interaction.reply({ content: `Notification cooldown for ${member.displayName} set to ${hours} hour(s).`, ephemeral: true });
} catch (err) {
console.error('staffnotification error:', err);
return interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {});
}
}
if (interaction.commandName === 'notifydm') {
try {
const setting = interaction.options.getString('setting') === 'on';

View File

@@ -8,6 +8,7 @@ const { getGmailClient, sendGmailReply } = require('../services/gmail');
const { updateTicketActivity } = require('../services/tickets');
const { getNotifyDm } = require('../services/staffSettings');
const { pingStaffChannel } = require('../services/staffChannel');
const { notifyStaffOfReply } = require('../services/staffNotifications');
const Ticket = mongoose.model('Ticket');
@@ -43,6 +44,19 @@ async function handleDiscordReply(m) {
}
}
// Notify claiming staff if a non-staff user replied (works for both Discord and email tickets)
if (ticket.claimerId) {
const guild = m.guild;
const member = await guild.members.fetch(m.author.id).catch(() => null);
const isStaff = member && CONFIG.ROLE_ID_TO_PING && member.roles.cache.has(CONFIG.ROLE_ID_TO_PING);
if (!isStaff) {
const freshTicket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean();
if (freshTicket) {
await notifyStaffOfReply(guild, freshTicket, m).catch(e => console.error('notifyStaffOfReply:', e));
}
}
}
const discordUser = m.member?.displayName || m.author.username;
if (ticket.gmailThreadId.startsWith('discord-')) {