Files
broccolini-bot/services/staffNotifications.js

126 lines
4.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, parseThresholdString } = require('../config');
const { increment } = require('./patternStore');
const { enqueueSend } = require('./channelQueue');
const { assertKeysRegistered } = require('./notificationRegistry');
const { isEnabled } = require('./notificationEnabled');
// Alert key this module drives. Registered to fail fast on drift.
const UNCLAIMED_ALERT_KEYS = ['unclaimed_reminder'];
assertKeysRegistered('staffNotifications', UNCLAIMED_ALERT_KEYS);
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 enqueueSend(
notifChannel,
`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) {
if (!isEnabled('unclaimed_reminder')) return;
const rawThresholds = (CONFIG.NOTIFICATION_THRESHOLDS && CONFIG.NOTIFICATION_THRESHOLDS.unclaimed_reminder) || [];
const thresholds = rawThresholds
.map(parseThresholdString)
.filter(n => Number.isFinite(n) && n >= 0)
.map(ms => ms / (60 * 60 * 1000));
if (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 enqueueSend(chan, alertMsg).catch(e => console.error('Unclaimed notify send:', e));
increment('staff_stale_pings', rec.userId, 'today');
increment('staff_stale_pings', rec.userId, 'week');
}
}
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $addToSet: { unclaimedRemindersSent: highest } }
);
}
}
module.exports = { notifyStaffOfReply, notifyAllStaffUnclaimed };