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,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);
}
// 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<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;
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,