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

@@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(node -e ':*)",
"Bash(node --check services/tickets.js)",
"Bash(node --check config.js)",
"Bash(node --check handlers/commands.js)",
"Bash(node --check handlers/buttons.js)",
"Bash(node --check gmail-poll.js)"
]
}
}

View File

@@ -119,6 +119,9 @@ AUTO_UNCLAIM_AFTER_HOURS=24
ALLOW_CLAIM_OVERWRITE=false ALLOW_CLAIM_OVERWRITE=false
STAFF_EMOJIS=224692549225283584:🍅 # userId:emoji pairs, comma-separated STAFF_EMOJIS=224692549225283584:🍅 # userId:emoji pairs, comma-separated
CLAIMER_EMOJI_FALLBACK=🎫 # fallback if claimer has no entry in STAFF_EMOJIS CLAIMER_EMOJI_FALLBACK=🎫 # fallback if claimer has no entry in STAFF_EMOJIS
ADMIN_ID= # Discord user ID of the bot admin (for /staffnotification)
STAFF_NOTIFICATION_CATEGORY_ID= # Category for staff notification channels (created by /notification add)
UNCLAIMED_REMINDER_THRESHOLDS=1,2,4 # Comma-separated hour thresholds for unclaimed ticket alerts
# --- Thread-style tickets (legacy) --- # --- Thread-style tickets (legacy) ---
USE_THREADS=false USE_THREADS=false

View File

@@ -16,7 +16,8 @@ const { handleDiscordReply } = require('./handlers/messages');
// Services & jobs // Services & jobs
const { sendTicketClosedEmail } = require('./services/gmail'); const { sendTicketClosedEmail } = require('./services/gmail');
const { checkAutoClose, checkReminders, checkAutoUnclaim } = require('./services/tickets'); const { checkAutoClose, checkAutoUnclaim } = require('./services/tickets');
const { notifyAllStaffUnclaimed } = require('./services/staffNotifications');
const { registerCommands } = require('./commands/register'); const { registerCommands } = require('./commands/register');
const bosscordRoutes = require('./routes/bosscord'); const bosscordRoutes = require('./routes/bosscord');
const { setBot } = require('./api/bosscordClient'); const { setBot } = require('./api/bosscordClient');
@@ -152,11 +153,9 @@ client.once('ready', async () => {
console.log('✓ Auto-close enabled: checking every hour'); console.log('✓ Auto-close enabled: checking every hour');
} }
if (CONFIG.REMINDER_ENABLED) { setInterval(() => notifyAllStaffUnclaimed(client).catch(e => console.error('notifyAllStaffUnclaimed:', e)), 30 * 60 * 1000);
setInterval(() => checkReminders(client), 30 * 60 * 1000); notifyAllStaffUnclaimed(client).catch(e => console.error('notifyAllStaffUnclaimed:', e));
checkReminders(client); console.log('✓ Staff unclaimed reminders: checking every 30 minutes');
console.log('✓ Reminders enabled: checking every 30 minutes');
}
if (CONFIG.AUTO_UNCLAIM_ENABLED) { if (CONFIG.AUTO_UNCLAIM_ENABLED) {
setInterval(() => checkAutoUnclaim(client), 60 * 60 * 1000); setInterval(() => checkAutoUnclaim(client), 60 * 60 * 1000);

View File

@@ -384,6 +384,52 @@ async function registerCommands() {
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) .setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
new SlashCommandBuilder()
.setName('notification')
.setDescription('Manage your staff notification settings')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.addSubcommand(sub =>
sub
.setName('set')
.setDescription('Set your notification cooldown (hours between alerts per ticket)')
.addIntegerOption(opt =>
opt
.setName('hours')
.setDescription('Cooldown in hours (16)')
.setMinValue(1)
.setMaxValue(6)
.setRequired(true)
)
)
.addSubcommand(sub =>
sub
.setName('add')
.setDescription('Create a notification channel for a staff member')
.addUserOption(opt =>
opt.setName('member').setDescription('Staff member').setRequired(true)
)
),
new SlashCommandBuilder()
.setName('staffnotification')
.setDescription('Override notification cooldown for another staff member (admin only)')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.addUserOption(opt =>
opt.setName('member').setDescription('Staff member').setRequired(true)
)
.addIntegerOption(opt =>
opt
.setName('hours')
.setDescription('Cooldown in hours (16)')
.setMinValue(1)
.setMaxValue(6)
.setRequired(true)
),
new SlashCommandBuilder() new SlashCommandBuilder()
.setName('accountinfo') .setName('accountinfo')
.setDescription('Look up website account info by email or Discord user') .setDescription('Look up website account info by email or Discord user')

View File

@@ -119,21 +119,7 @@ const CONFIG = {
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: (() => { STAFF_CATEGORIES: new Map(), // deprecated kept for staffChannel.js compat
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_EMOJIS: (() => { STAFF_EMOJIS: (() => {
const raw = process.env.STAFF_EMOJIS; const raw = process.env.STAFF_EMOJIS;
const map = new Map(); const map = new Map();
@@ -150,10 +136,12 @@ const CONFIG = {
return map; return map;
})(), })(),
CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫', CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫',
STAFF_T1_CATEGORY: process.env.STAFF_T1_CATEGORY || null, ADMIN_ID: process.env.ADMIN_ID || null,
STAFF_T2_CATEGORY: process.env.STAFF_T2_CATEGORY || null, STAFF_NOTIFICATION_CATEGORY_ID: process.env.STAFF_NOTIFICATION_CATEGORY_ID || null,
STAFF_T3_CATEGORY: process.env.STAFF_T3_CATEGORY || null, UNCLAIMED_REMINDER_THRESHOLDS: (process.env.UNCLAIMED_REMINDER_THRESHOLDS || '1,2,4')
UNCLAIMED_CATEGORY_ID: process.env.UNCLAIMED_CATEGORY_ID || null .split(',')
.map(s => parseInt(s.trim(), 10))
.filter(n => !isNaN(n) && n > 0)
}; };
/** 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. */

View File

@@ -19,7 +19,7 @@ const {
getFormattedDate getFormattedDate
} = require('./utils'); } = require('./utils');
const { getGmailClient } = require('./services/gmail'); const { getGmailClient } = require('./services/gmail');
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, createEmailTicketAsThread } = require('./services/tickets'); const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, createEmailTicketAsThread, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
const { getEmailRouting } = require('./services/guildSettings'); const { getEmailRouting } = require('./services/guildSettings');
const { logError } = require('./services/debugLog'); const { logError } = require('./services/debugLog');
@@ -157,11 +157,9 @@ async function poll(client) {
continue; continue;
} }
const { local, number } = await getNextTicketNumber(sEmail); const { number } = await getNextTicketNumber(sEmail);
const safeLocal = local const creatorNickname = getSenderLocal(sEmail);
.replace(/[^a-z0-9-]/gi, '') const chanName = toDiscordSafeName(`unclaimed-${creatorNickname}-${number}`);
.substring(0, 50);
const chanName = `ticket-${safeLocal}-${number}`;
try { try {
const routing = await getEmailRouting(guild.id); const routing = await getEmailRouting(guild.id);

View File

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

View File

@@ -13,7 +13,7 @@ const {
const { mongoose } = require('../db-connection'); const { mongoose } = require('../db-connection');
const { CONFIG, TICKET_TAGS } = require('../config'); const { CONFIG, TICKET_TAGS } = require('../config');
const { getPriorityEmoji, getPriorityColor, replaceVariables, escapeRegex } = require('../utils'); 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 { sendTicketNotificationEmail } = require('../services/gmail');
const { getTicketActionRow } = require('../utils/ticketComponents'); const { getTicketActionRow } = require('../utils/ticketComponents');
const { getEmailRouting } = require('../services/guildSettings'); const { getEmailRouting } = require('../services/guildSettings');
@@ -26,6 +26,7 @@ const { handleSetupCommand } = require('./setup');
const Ticket = mongoose.model('Ticket'); const Ticket = mongoose.model('Ticket');
const Tag = mongoose.model('Tag'); const Tag = mongoose.model('Tag');
const User = mongoose.model('User'); 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. * 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_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); : (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
// Clear claim on escalation
await Ticket.updateOne( await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId }, { gmailThreadId: ticket.gmailThreadId },
{ $set: { escalated: true, escalationTier: nextTier } } { $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null } }
); );
ticket.escalated = true; ticket.escalated = true;
ticket.escalationTier = nextTier; ticket.escalationTier = nextTier;
ticket.claimedBy = null;
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const renameInfo = await canRename(ticket); const renameInfo = await canRename(ticket);
if (renameInfo.ok) { if (renameInfo.ok) {
const newName = makeTicketName( const newName = makeTicketName('escalated', ticket, creatorNickname);
{ escalated: true, claimed: false },
ticket,
interaction.guild
);
try { try {
await enqueueRename(interaction.channel, newName); await enqueueRename(interaction.channel, newName);
} catch (e) { } catch (e) {
@@ -97,19 +97,6 @@ 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);
} 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);
@@ -188,20 +175,18 @@ async function runDeescalation(interaction, ticket) {
await Ticket.updateOne( await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId }, { 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.escalated = newTier > 0;
ticket.escalationTier = newTier; 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); const renameInfo = await canRename(ticket);
if (renameInfo.ok) { if (renameInfo.ok) {
try { try {
const emoji = newTier === 0 ? CONFIG.PRIORITY_LOW_EMOJI : CONFIG.PRIORITY_MEDIUM_EMOJI; await enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname));
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);
} }
@@ -215,8 +200,15 @@ async function runDeescalation(interaction, ticket) {
if (!interaction.channel.isThread()) { if (!interaction.channel.isThread()) {
try { try {
if (newTier === 0 && CONFIG.STAFF_T1_CATEGORY) await enqueueMove(interaction.channel, CONFIG.STAFF_T1_CATEGORY); if (newTier === 0) {
if (newTier === 1 && CONFIG.STAFF_T2_CATEGORY) await enqueueMove(interaction.channel, CONFIG.STAFF_T2_CATEGORY); 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) { } catch (e) {
console.error('Move error (deescalate):', 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') { if (interaction.commandName === 'notifydm') {
try { try {
const setting = interaction.options.getString('setting') === 'on'; 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 { updateTicketActivity } = require('../services/tickets');
const { getNotifyDm } = require('../services/staffSettings'); const { getNotifyDm } = require('../services/staffSettings');
const { pingStaffChannel } = require('../services/staffChannel'); const { pingStaffChannel } = require('../services/staffChannel');
const { notifyStaffOfReply } = require('../services/staffNotifications');
const Ticket = mongoose.model('Ticket'); 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; const discordUser = m.member?.displayName || m.author.username;
if (ticket.gmailThreadId.startsWith('discord-')) { if (ticket.gmailThreadId.startsWith('discord-')) {

View File

@@ -815,7 +815,8 @@ mongoose.model('Ticket', new mongoose.Schema({
welcomeMessageId: String, welcomeMessageId: String,
claimerId: String, claimerId: String,
staffChannelId: String, staffChannelId: String,
parentCategoryId: String parentCategoryId: String,
unclaimedReminderssent: { type: [Number], default: [] }
})); }));
mongoose.model('TicketCounter', new mongoose.Schema({ mongoose.model('TicketCounter', new mongoose.Schema({
@@ -856,3 +857,11 @@ mongoose.model('StaffSettings', new mongoose.Schema({
notifyDm: { type: Boolean, default: false }, notifyDm: { type: Boolean, default: false },
updatedAt: { type: Date, default: Date.now } updatedAt: { type: Date, default: Date.now }
})); }));
mongoose.model('StaffNotification', new mongoose.Schema({
userId: { type: String, required: true, unique: true },
guildId: String,
channelId: String,
cooldownHours: { type: Number, default: 1 },
updatedAt: { type: Date, default: Date.now }
}));

View File

@@ -0,0 +1,109 @@
/**
* Staff notification service reply alerts and unclaimed ticket reminders.
*
* notifyStaffOfReply: posts in the claimer's notification channel when a
* non-staff user replies, respecting a per-staff cooldown.
*
* notifyAllStaffUnclaimed: background job that checks unclaimed tickets
* against configurable hour thresholds and posts one alert per threshold
* per ticket (highest newly-crossed threshold only).
*/
const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config');
const Ticket = mongoose.model('Ticket');
const StaffNotification = mongoose.model('StaffNotification');
// In-memory cooldown map: `${userId}:${ticketId}` -> last notified timestamp
const replyCooldowns = new Map();
/**
* Notify the claiming staff member when a non-staff user replies.
* Respects the staff member's cooldownHours setting (default 1h).
* Posts in their notification channel if one exists.
*/
async function notifyStaffOfReply(guild, ticket, message) {
if (!ticket.claimerId) return;
const staffRecord = await StaffNotification.findOne({ userId: ticket.claimerId }).lean();
if (!staffRecord?.channelId) return;
const cooldownMs = (staffRecord.cooldownHours || 1) * 60 * 60 * 1000;
const cooldownKey = `${ticket.claimerId}:${ticket.gmailThreadId}`;
const lastNotified = replyCooldowns.get(cooldownKey) || 0;
if (Date.now() - lastNotified < cooldownMs) return;
const notifChannel = await guild.channels.fetch(staffRecord.channelId).catch(() => null);
if (!notifChannel) return;
const jumpLink = `https://discord.com/channels/${guild.id}/${message.channel.id}/${message.id}`;
const snippet = message.content?.slice(0, 300) || '(no text)';
await notifChannel.send(
`New reply in **${message.channel.name}** from ${message.author.tag}:\n> ${snippet}\n[Jump to message](${jumpLink})`
);
replyCooldowns.set(cooldownKey, Date.now());
}
/**
* Background job: check all open unclaimed tickets against hour thresholds.
* For each ticket, find the highest threshold that has been crossed but not
* yet recorded. Post one notification per ticket per run (the highest new
* threshold) into every staff notification channel.
*/
async function notifyAllStaffUnclaimed(client) {
const thresholds = CONFIG.UNCLAIMED_REMINDER_THRESHOLDS;
if (!thresholds || thresholds.length === 0) return;
const sorted = [...thresholds].sort((a, b) => a - b);
const now = Date.now();
const unclaimedTickets = await Ticket.find({
status: 'open',
claimedBy: null,
createdAt: { $ne: null }
}).lean();
if (unclaimedTickets.length === 0) return;
const staffRecords = await StaffNotification.find({ channelId: { $ne: null } }).lean();
if (staffRecords.length === 0) return;
const guild = CONFIG.DISCORD_GUILD_ID
? client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID)
: client.guilds.cache.first();
if (!guild) return;
for (const ticket of unclaimedTickets) {
const ageMs = now - new Date(ticket.createdAt).getTime();
const ageHours = ageMs / (60 * 60 * 1000);
const alreadySent = ticket.unclaimedReminderssent || [];
// Find thresholds crossed but not yet sent
const crossedNew = sorted.filter(t => ageHours >= t && !alreadySent.includes(t));
if (crossedNew.length === 0) continue;
// Only send the highest newly-crossed threshold
const highest = crossedNew[crossedNew.length - 1];
const channelName = ticket.discordThreadId
? `<#${ticket.discordThreadId}>`
: `ticket #${ticket.ticketNumber}`;
const hoursAgo = Math.floor(ageHours);
const alertMsg = `Unclaimed ticket alert: ${channelName} has been unclaimed for ${hoursAgo}+ hour(s) (${highest}h threshold).`;
for (const rec of staffRecords) {
const chan = await guild.channels.fetch(rec.channelId).catch(() => null);
if (chan) {
await chan.send(alertMsg).catch(e => console.error('Unclaimed notify send:', e));
}
}
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $addToSet: { unclaimedReminderssent: highest } }
);
}
}
module.exports = { notifyStaffOfReply, notifyAllStaffUnclaimed };

View File

@@ -43,20 +43,47 @@ function toDiscordSafeName(str) {
.slice(0, 100); .slice(0, 100);
} }
// claimerEmoji and creatorNickname are only used in the claimed branch. /**
// Callers that do not pass them (e.g. escalation rename) get the unclaimed name as before. * Resolve a human-friendly creator nickname for channel naming.
function makeTicketName({ escalated, claimed }, ticket, guild, claimerEmoji, creatorNickname) { * Discord tickets: guild member displayName. Email tickets: senderLocal.
const senderLocal = getSenderLocal(ticket.senderEmail); * @param {import('discord.js').Guild} guild
* @param {object} ticket
* @returns {Promise<string>}
*/
async function resolveCreatorNickname(guild, ticket) {
if (ticket.gmailThreadId.startsWith('discord-')) {
const creatorUserId = ticket.gmailThreadId.split('-').pop();
try {
const member = await guild.members.fetch(creatorUserId);
return member.displayName;
} catch {
return getSenderLocal(ticket.senderEmail);
}
}
return getSenderLocal(ticket.senderEmail);
}
/**
* Build a channel name from ticket state.
* @param {'unclaimed'|'claimed'|'escalated'|'escalated-claimed'} state
* @param {object} ticket
* @param {string} creatorNickname - pre-resolved via resolveCreatorNickname
* @param {string} [claimerEmoji] - required for claimed / escalated-claimed
* @returns {string}
*/
function makeTicketName(state, ticket, creatorNickname, claimerEmoji) {
const num = ticket.ticketNumber || 1; const num = ticket.ticketNumber || 1;
if (escalated) { switch (state) {
return (claimed && claimerEmoji && creatorNickname) case 'claimed':
? toDiscordSafeName(`e-${claimerEmoji}-${creatorNickname}-${num}`) return toDiscordSafeName(`${claimerEmoji}-${creatorNickname}-${num}`);
: `escalated-ticket-${senderLocal}-${num}`; case 'escalated':
return toDiscordSafeName(`escalated-${creatorNickname}-${num}`);
case 'escalated-claimed':
return toDiscordSafeName(`e-${claimerEmoji}-${creatorNickname}-${num}`);
case 'unclaimed':
default:
return toDiscordSafeName(`unclaimed-${creatorNickname}-${num}`);
} }
if (claimed && claimerEmoji && creatorNickname) {
return toDiscordSafeName(`${claimerEmoji}-${creatorNickname}-${num}`);
}
return `ticket-${senderLocal}-${num}`;
} }
async function canRename(ticket) { async function canRename(ticket) {
@@ -261,7 +288,7 @@ async function cleanupEmptyOverflowCategory(guild, categoryId, categoryName) {
} }
} }
async function createTicketChannel(guild, ticketNumber, userId, subject) { async function createTicketChannel(guild, ticketNumber, userId, subject, creatorNickname) {
if (CONFIG.USE_THREADS && CONFIG.THREAD_PARENT_CHANNEL) { if (CONFIG.USE_THREADS && CONFIG.THREAD_PARENT_CHANNEL) {
const parentChannel = guild.channels.cache.get(CONFIG.THREAD_PARENT_CHANNEL); const parentChannel = guild.channels.cache.get(CONFIG.THREAD_PARENT_CHANNEL);
if (!parentChannel) { if (!parentChannel) {
@@ -300,7 +327,7 @@ async function createTicketChannel(guild, ticketNumber, userId, subject) {
let channel; let channel;
try { try {
channel = await guild.channels.create({ channel = await guild.channels.create({
name: `ticket-${ticketNumber}`, name: creatorNickname ? toDiscordSafeName(`unclaimed-${creatorNickname}-${ticketNumber}`) : `ticket-${ticketNumber}`,
type: ChannelType.GuildText, type: ChannelType.GuildText,
parent: parentId, parent: parentId,
permissionOverwrites: [ permissionOverwrites: [
@@ -554,6 +581,8 @@ module.exports = {
RENAME_WINDOW_MS, RENAME_WINDOW_MS,
RENAME_LIMIT, RENAME_LIMIT,
getSenderLocal, getSenderLocal,
toDiscordSafeName,
resolveCreatorNickname,
makeTicketName, makeTicketName,
canRename, canRename,
minutesFromMs, minutesFromMs,