This commit is contained in:
2026-04-20 18:05:36 +00:00
parent d73422555d
commit 33b1f276c6
26 changed files with 598 additions and 183 deletions

View File

@@ -7,6 +7,7 @@ const { CONFIG } = require('../config');
const { mongoose } = require('../db-connection');
const { logSecurity } = require('../services/debugLog');
const { enqueueSend } = require('../services/channelQueue');
const { isStaff } = require('../utils');
const User = mongoose.model('User');
@@ -134,6 +135,13 @@ async function handleAccountInfoCommand(interaction) {
async function handleSendAccountInfoToChannel(interaction) {
if (!interaction.isButton() || !interaction.customId.startsWith(BUTTON_PREFIX)) return false;
// Dispatched directly from interactionCreate — no upstream command-level staff gate here, so enforce it.
if (!isStaff(interaction.member)) {
logSecurity('Unauthorized account-info button', interaction.user, `non-staff pressed ${interaction.customId}`, null, 0xff0000).catch(() => {});
await interaction.reply({ content: 'You do not have permission to do that.', ephemeral: true }).catch(() => {});
return true;
}
const payload = interaction.customId.slice(BUTTON_PREFIX.length);
const [type, value] = payload.includes(':') ? payload.split(':') : [payload, ''];

View File

@@ -26,7 +26,7 @@ const { runEscalation, runDeescalation } = require('./commands');
const { trackInteraction, trackError } = require('./analytics');
const { pendingCloses } = require('./pendingCloses');
const { increment } = require('../services/patternStore');
const { logError } = require('../services/debugLog');
const { logError, logSystem } = require('../services/debugLog');
const Ticket = mongoose.model('Ticket');
const Transcript = mongoose.model('Transcript');
@@ -497,8 +497,10 @@ async function handleConfirmClose(interaction, ticket) {
});
}
// DM the transcript to the ticket creator (Discord-originated tickets)
if (ticket.gmailThreadId?.startsWith('discord-')) {
// DM the transcript to the ticket creator (Discord-originated tickets).
// Gated because many users have DMs from server members disabled — the send
// then 50007s and generates noise. Default off; enable via env when desired.
if (CONFIG.TRANSCRIPT_DM_TO_CREATOR && ticket.gmailThreadId?.startsWith('discord-')) {
const creatorId = ticket.gmailThreadId.split('-').pop();
try {
const creator = await interaction.client.users.fetch(creatorId);
@@ -515,7 +517,15 @@ async function handleConfirmClose(interaction, ticket) {
files: [dmFile]
});
} catch (dmErr) {
console.warn(`Could not DM transcript to user ${creatorId}:`, dmErr.message);
// 50007 = "Cannot send messages to this user" — user has DMs off. Expected class; debug-level only.
if (dmErr?.code === 50007) {
logSystem('Transcript DM skipped (recipient has DMs disabled)', [
{ name: 'User', value: creatorId },
{ name: 'Channel', value: channelName }
]).catch(() => {});
} else {
logError('transcript-dm', dmErr).catch(() => {});
}
}
}
@@ -643,6 +653,7 @@ async function handleTicketModal(interaction) {
}
parentCategoryIdForTicket = parentId;
try {
// TODO(queue-migrate): initial permissionOverwrites here are fine since the channel is just being created, but any later permissionOverwrites mutation on this channel should go through channelQueue.
channel = await guild.channels.create({
name: unclaimedName,
type: ChannelType.GuildText,

View File

@@ -440,6 +440,7 @@ async function handleCommand(interaction) {
}
try {
// TODO(queue-migrate): permissionOverwrites mutation bypasses channelQueue — could race a pending rename/send on the same channel.
await interaction.channel.permissionOverwrites.create(user.id, {
ViewChannel: true,
SendMessages: true,
@@ -462,6 +463,7 @@ async function handleCommand(interaction) {
}
try {
// TODO(queue-migrate): permissionOverwrites mutation bypasses channelQueue — could race a pending rename/send on the same channel.
await interaction.channel.permissionOverwrites.delete(user.id);
await interaction.reply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } });
} catch (err) {
@@ -524,6 +526,7 @@ async function handleCommand(interaction) {
}
try {
// TODO(queue-migrate): setParent bypasses channelQueue (enqueueMove) — use enqueueMove so moves serialize with pending renames/sends.
await interaction.channel.setParent(category.id, { lockPermissions: true });
await interaction.reply(`Moved ticket to **${category.name}**.`);
@@ -711,6 +714,7 @@ async function handleCommand(interaction) {
}
try {
// TODO(queue-migrate): setTopic bypasses channelQueue — could race a pending rename/send on the same channel.
await interaction.channel.setTopic(text);
await interaction.reply('Topic updated successfully.');
} catch (err) {
@@ -1085,19 +1089,46 @@ async function handleCommand(interaction) {
return interaction.editReply('BACKUP_EXPORT_CHANNEL_ID is not set in .env.');
}
try {
const tickets = await Ticket.find().sort({ ticketNumber: 1 }).lean();
const lines = ['# Ticket backup ' + new Date().toISOString(), 'ticketNumber\tstatus\tsenderEmail\tsubject\tcreatedAt\tclaimedBy\tpriority\tescalationTier'];
for (const t of tickets) {
// Stream every ticket through a Mongoose cursor to a tmp file so peak RSS
// stays bounded regardless of collection size; attach the file, then unlink.
const fs = require('fs');
const os = require('os');
const path = require('path');
const tmpName = `ticket-backup-${Date.now()}-${process.pid}.txt`;
const tmpPath = path.join(os.tmpdir(), tmpName);
const ws = fs.createWriteStream(tmpPath, { encoding: 'utf8' });
ws.write('# Ticket backup ' + new Date().toISOString() + '\n');
ws.write('ticketNumber\tstatus\tsenderEmail\tsubject\tcreatedAt\tclaimedBy\tpriority\tescalationTier\n');
let count = 0;
const cursor = Ticket.find().sort({ ticketNumber: 1 }).lean().cursor();
for await (const t of cursor) {
const created = t.createdAt ? new Date(t.createdAt).toISOString() : '';
lines.push([t.ticketNumber, t.status || '', (t.senderEmail || '').replace(/\t/g, ' '), (t.subject || '').replace(/\t/g, ' ').slice(0, 200), created, (t.claimedBy || '').replace(/\t/g, ' '), t.priority || '', t.escalationTier ?? ''].join('\t'));
ws.write([
t.ticketNumber,
t.status || '',
(t.senderEmail || '').replace(/\t/g, ' '),
(t.subject || '').replace(/\t/g, ' ').slice(0, 200),
created,
(t.claimedBy || '').replace(/\t/g, ' '),
t.priority || '',
t.escalationTier ?? ''
].join('\t') + '\n');
count++;
}
await new Promise((resolve, reject) => ws.end(err => err ? reject(err) : resolve()));
try {
const channel = await interaction.client.channels.fetch(CONFIG.BACKUP_EXPORT_CHANNEL_ID);
await enqueueSend(channel, {
content: `Ticket backup by ${interaction.user.tag} (${count} tickets)`,
files: [new AttachmentBuilder(tmpPath, { name: tmpName })]
});
await interaction.editReply(`Backup complete. ${count} tickets sent to the backup channel.`);
} finally {
fs.promises.unlink(tmpPath).catch(() => {});
}
const buf = Buffer.from(lines.join('\n'), 'utf8');
const channel = await interaction.client.channels.fetch(CONFIG.BACKUP_EXPORT_CHANNEL_ID);
await enqueueSend(channel, {
content: `Ticket backup by ${interaction.user.tag} (${tickets.length} tickets)`,
files: [new AttachmentBuilder(buf, { name: `ticket-backup-${Date.now()}.txt` })]
});
await interaction.editReply(`Backup complete. ${tickets.length} tickets sent to the backup channel.`);
} catch (err) {
trackError('backup-command', err, interaction);
await interaction.editReply('Failed to create backup: ' + (err.message || err));