Event-sourced tracking of staff/ticket lifecycle actions, plus a /stats command. Foundation for a future tickets-website analytics dashboard. Data: - StaffAction model (event log) + Ticket.game / Ticket.closedAt - STATS_ADMIN_IDS config (who may view others' stats) Recording (fire-and-forget, idempotent on real state transitions): - claim, response (channel reply + /response send), escalate, de-escalate, transfer, close (4 sites), reopen — each denormalizes ticketType, tier, priority, game, requester (senderEmail / creatorId), guildId - close events carry closerType / resolverId (claimer credit) / wasClaimed; transfer carries fromId / toId; reopen stamps resolverId - conditional close transition helper (atomic open->closed + closedAt) shared by all four close paths Query + command: - pure period parser (presets + free-text) and stats shaper (per-metric keys) - command-aware autocomplete dispatch - /stats: period (autocomplete) + member (admin-gated) + source (all/email/ discord), ManageMessages + staff-role gated, ephemeral, tier-labeled embed 288+ unit tests; timing/busiest-times data is collected but displayed later.
125 lines
4.4 KiB
JavaScript
125 lines
4.4 KiB
JavaScript
/**
|
||
* Discord messageCreate handler – forwards staff replies to Gmail.
|
||
*/
|
||
const { mongoose } = require('../db-connection');
|
||
const { CONFIG } = require('../config');
|
||
const { extractRawEmail, isStaff, getCleanBody } = require('../utils');
|
||
const { getGmailClient, sendGmailReply } = require('../services/gmail');
|
||
const { autoAdvanceFolder } = require('../services/gmailLabels');
|
||
const { getNotifyDm } = require('../services/staffSettings');
|
||
const { logError } = require('../services/debugLog');
|
||
const { recordAction } = require('../services/staffStats');
|
||
|
||
const Ticket = mongoose.model('Ticket');
|
||
|
||
/**
|
||
* Handle a Discord message in a ticket channel → relay to Gmail (email tickets only).
|
||
*/
|
||
async function handleDiscordReply(m, _TicketModel, _recordAction, _isStaff) {
|
||
const T = _TicketModel || Ticket;
|
||
const record = _recordAction || recordAction;
|
||
const checkIsStaff = _isStaff || isStaff;
|
||
|
||
if (m.author.bot || m.interaction) return;
|
||
|
||
const ticket = await T.findOne({ discordThreadId: m.channel.id }).lean();
|
||
if (!ticket) return;
|
||
|
||
const memberForCheck = await m.guild.members.fetch(m.author.id).catch(() => null);
|
||
const isStaffMember = checkIsStaff(memberForCheck);
|
||
T.updateOne(
|
||
{ discordThreadId: m.channel.id },
|
||
{ $set: { lastActivity: new Date() } }
|
||
).catch(err => logError('updateActivity', err).catch(() => {}));
|
||
|
||
// DM the claimer if they have notifydm on and a non-staff user replied.
|
||
if (ticket.claimerId && !isStaffMember && m.author.id !== ticket.claimerId) {
|
||
const dmEnabled = await getNotifyDm(ticket.claimerId);
|
||
if (dmEnabled) {
|
||
// Cache-first: GuildMembers intent keeps the cache populated; only fetch
|
||
// on miss (e.g. cold cache after restart). Avoids a REST round-trip on
|
||
// every customer reply in a busy ticket.
|
||
const staffMember = m.guild.members.cache.get(ticket.claimerId)
|
||
|| await m.guild.members.fetch(ticket.claimerId).catch(() => null);
|
||
if (staffMember) {
|
||
const jumpLink = `https://discord.com/channels/${m.guild.id}/${m.channel.id}/${m.id}`;
|
||
await staffMember
|
||
.send(
|
||
`New customer reply in **${m.channel.name}**:\n> ${m.content.slice(0, 300)}\n[Jump to message](${jumpLink})`
|
||
)
|
||
.catch(() => {});
|
||
}
|
||
}
|
||
}
|
||
|
||
if (isStaffMember) {
|
||
record(m.author.id, 'response', { ticket, guildId: m.guild?.id });
|
||
}
|
||
|
||
if (ticket.gmailThreadId.startsWith('discord-')) {
|
||
return;
|
||
}
|
||
|
||
// Email tickets: send reply via Gmail.
|
||
try {
|
||
const gmail = getGmailClient();
|
||
const thread = await gmail.users.threads.get({
|
||
userId: 'me',
|
||
id: ticket.gmailThreadId
|
||
});
|
||
|
||
const last = [...thread.data.messages].reverse().find(msg => {
|
||
const from =
|
||
msg.payload.headers.find(h => h.name === 'From')?.value || '';
|
||
return !from.toLowerCase().includes(CONFIG.MY_EMAIL);
|
||
});
|
||
|
||
if (!last) return;
|
||
|
||
let recipient =
|
||
last.payload.headers.find(h => h.name === 'From')?.value || '';
|
||
const replyTo =
|
||
last.payload.headers.find(h => h.name === 'Reply-To')?.value;
|
||
if (replyTo) recipient = replyTo;
|
||
|
||
const subject =
|
||
last.payload.headers.find(h => h.name === 'Subject')?.value ||
|
||
'Support';
|
||
const msgId =
|
||
last.payload.headers.find(h => h.name === 'Message-ID')?.value;
|
||
const origDate =
|
||
last.payload.headers.find(h => h.name === 'Date')?.value || '';
|
||
const origFrom =
|
||
last.payload.headers.find(h => h.name === 'From')?.value || recipient;
|
||
|
||
const recipientEmail = extractRawEmail(recipient).toLowerCase();
|
||
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) {
|
||
console.warn('Bad recipient for reply:', recipientEmail);
|
||
return;
|
||
}
|
||
|
||
// Quote the customer's latest inbound message beneath the staff reply.
|
||
const quote = { from: origFrom, date: origDate, body: getCleanBody(last.payload) };
|
||
|
||
await sendGmailReply(
|
||
ticket.gmailThreadId,
|
||
m.content,
|
||
recipientEmail,
|
||
subject,
|
||
msgId,
|
||
m.author.id,
|
||
quote
|
||
);
|
||
|
||
// Staff just replied to the customer → advance to Awaiting Reply (unless the
|
||
// thread is manually filed). Fire-and-forget: a label failure must not break
|
||
// the reply that already went out.
|
||
autoAdvanceFolder(ticket.gmailThreadId, 'AWAITING_REPLY')
|
||
.catch(err => logError('autoAdvanceFolder(AWAITING_REPLY)', err).catch(() => {}));
|
||
} catch (e) {
|
||
console.error('REPLY ERROR:', e);
|
||
}
|
||
}
|
||
|
||
module.exports = { handleDiscordReply };
|