From c5d75396779c545fc0eb0ca72b705db8005aeb0c Mon Sep 17 00:00:00 2001 From: indifferentketchup <159190319+indifferentketchup@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:53:32 -0500 Subject: [PATCH] staff notifications --- .claude/settings.local.json | 12 ++++ .env.example | 3 + broccolini-discord.js | 11 ++- commands/register.js | 46 ++++++++++++ config.js | 26 ++----- gmail-poll.js | 10 ++- handlers/buttons.js | 62 ++++++++-------- handlers/commands.js | 126 +++++++++++++++++++++++++-------- handlers/messages.js | 14 ++++ models.js | 11 ++- services/staffNotifications.js | 109 ++++++++++++++++++++++++++++ services/tickets.js | 57 +++++++++++---- 12 files changed, 379 insertions(+), 108 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 services/staffNotifications.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..5946c2a --- /dev/null +++ b/.claude/settings.local.json @@ -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)" + ] + } +} diff --git a/.env.example b/.env.example index 2f5035a..819c41f 100644 --- a/.env.example +++ b/.env.example @@ -119,6 +119,9 @@ AUTO_UNCLAIM_AFTER_HOURS=24 ALLOW_CLAIM_OVERWRITE=false STAFF_EMOJIS=224692549225283584:🍅 # userId:emoji pairs, comma-separated 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) --- USE_THREADS=false diff --git a/broccolini-discord.js b/broccolini-discord.js index 50c358d..f28c19a 100644 --- a/broccolini-discord.js +++ b/broccolini-discord.js @@ -16,7 +16,8 @@ const { handleDiscordReply } = require('./handlers/messages'); // Services & jobs 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 bosscordRoutes = require('./routes/bosscord'); const { setBot } = require('./api/bosscordClient'); @@ -152,11 +153,9 @@ client.once('ready', async () => { console.log('✓ Auto-close enabled: checking every hour'); } - if (CONFIG.REMINDER_ENABLED) { - setInterval(() => checkReminders(client), 30 * 60 * 1000); - checkReminders(client); - console.log('✓ Reminders enabled: checking every 30 minutes'); - } + setInterval(() => notifyAllStaffUnclaimed(client).catch(e => console.error('notifyAllStaffUnclaimed:', e)), 30 * 60 * 1000); + notifyAllStaffUnclaimed(client).catch(e => console.error('notifyAllStaffUnclaimed:', e)); + console.log('✓ Staff unclaimed reminders: checking every 30 minutes'); if (CONFIG.AUTO_UNCLAIM_ENABLED) { setInterval(() => checkAutoUnclaim(client), 60 * 60 * 1000); diff --git a/commands/register.js b/commands/register.js index 8f9891d..d2a0ffa 100644 --- a/commands/register.js +++ b/commands/register.js @@ -384,6 +384,52 @@ async function registerCommands() { .setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) .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 (1–6)') + .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 (1–6)') + .setMinValue(1) + .setMaxValue(6) + .setRequired(true) + ), + new SlashCommandBuilder() .setName('accountinfo') .setDescription('Look up website account info by email or Discord user') diff --git a/config.js b/config.js index f4822f2..642d752 100644 --- a/config.js +++ b/config.js @@ -119,21 +119,7 @@ const CONFIG = { 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, - 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_CATEGORIES: new Map(), // deprecated – kept for staffChannel.js compat STAFF_EMOJIS: (() => { const raw = process.env.STAFF_EMOJIS; const map = new Map(); @@ -150,10 +136,12 @@ const CONFIG = { return map; })(), CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫', - 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 + ADMIN_ID: process.env.ADMIN_ID || null, + STAFF_NOTIFICATION_CATEGORY_ID: process.env.STAFF_NOTIFICATION_CATEGORY_ID || null, + UNCLAIMED_REMINDER_THRESHOLDS: (process.env.UNCLAIMED_REMINDER_THRESHOLDS || '1,2,4') + .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. */ diff --git a/gmail-poll.js b/gmail-poll.js index 5c9eb61..17e9222 100644 --- a/gmail-poll.js +++ b/gmail-poll.js @@ -19,7 +19,7 @@ const { getFormattedDate } = require('./utils'); 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 { logError } = require('./services/debugLog'); @@ -157,11 +157,9 @@ async function poll(client) { continue; } - const { local, number } = await getNextTicketNumber(sEmail); - const safeLocal = local - .replace(/[^a-z0-9-]/gi, '') - .substring(0, 50); - const chanName = `ticket-${safeLocal}-${number}`; + const { number } = await getNextTicketNumber(sEmail); + const creatorNickname = getSenderLocal(sEmail); + const chanName = toDiscordSafeName(`unclaimed-${creatorNickname}-${number}`); try { const routing = await getEmailRouting(guild.id); diff --git a/handlers/buttons.js b/handlers/buttons.js index 54d88c1..e2002ab 100644 --- a/handlers/buttons.js +++ b/handlers/buttons.js @@ -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: [ diff --git a/handlers/commands.js b/handlers/commands.js index e78727b..68a4774 100644 --- a/handlers/commands.js +++ b/handlers/commands.js @@ -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'; diff --git a/handlers/messages.js b/handlers/messages.js index b966d7b..6e91eee 100644 --- a/handlers/messages.js +++ b/handlers/messages.js @@ -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-')) { diff --git a/models.js b/models.js index f140407..6826ef1 100644 --- a/models.js +++ b/models.js @@ -815,7 +815,8 @@ mongoose.model('Ticket', new mongoose.Schema({ welcomeMessageId: String, claimerId: String, staffChannelId: String, - parentCategoryId: String + parentCategoryId: String, + unclaimedReminderssent: { type: [Number], default: [] } })); mongoose.model('TicketCounter', new mongoose.Schema({ @@ -856,3 +857,11 @@ mongoose.model('StaffSettings', new mongoose.Schema({ notifyDm: { type: Boolean, default: false }, 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 } +})); diff --git a/services/staffNotifications.js b/services/staffNotifications.js new file mode 100644 index 0000000..63532da --- /dev/null +++ b/services/staffNotifications.js @@ -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 }; diff --git a/services/tickets.js b/services/tickets.js index 313c0cd..c377e0a 100644 --- a/services/tickets.js +++ b/services/tickets.js @@ -43,20 +43,47 @@ function toDiscordSafeName(str) { .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. -function makeTicketName({ escalated, claimed }, ticket, guild, claimerEmoji, creatorNickname) { - const senderLocal = getSenderLocal(ticket.senderEmail); +/** + * Resolve a human-friendly creator nickname for channel naming. + * Discord tickets: guild member displayName. Email tickets: senderLocal. + * @param {import('discord.js').Guild} guild + * @param {object} ticket + * @returns {Promise} + */ +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; - if (escalated) { - return (claimed && claimerEmoji && creatorNickname) - ? toDiscordSafeName(`e-${claimerEmoji}-${creatorNickname}-${num}`) - : `escalated-ticket-${senderLocal}-${num}`; + switch (state) { + case 'claimed': + return toDiscordSafeName(`${claimerEmoji}-${creatorNickname}-${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) { @@ -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) { const parentChannel = guild.channels.cache.get(CONFIG.THREAD_PARENT_CHANNEL); if (!parentChannel) { @@ -300,7 +327,7 @@ async function createTicketChannel(guild, ticketNumber, userId, subject) { let channel; try { channel = await guild.channels.create({ - name: `ticket-${ticketNumber}`, + name: creatorNickname ? toDiscordSafeName(`unclaimed-${creatorNickname}-${ticketNumber}`) : `ticket-${ticketNumber}`, type: ChannelType.GuildText, parent: parentId, permissionOverwrites: [ @@ -554,6 +581,8 @@ module.exports = { RENAME_WINDOW_MS, RENAME_LIMIT, getSenderLocal, + toDiscordSafeName, + resolveCreatorNickname, makeTicketName, canRename, minutesFromMs,