Files
broccolini-bot/handlers/commands/contextMenu.js
indifferentketchup 2fab3b97bf Remove dead/stale code, dedup close+escalation paths
Dead/stale removals (grep-confirmed no consumers):
- config: drop 9 unread CONFIG keys (ROLE_TO_PING_ID, SIGNATURE,
  REMINDER_*, RENAME_LOG_CHANNEL_ID, SETTINGS_*); remove their
  ALLOWED_CONFIG_KEYS entries and the orphaned settings-site UI fields
- configSchema: delete unreachable json/string_or_json validators
- models: drop unused ticketTag field
- gmail-poll: remove unused isPollSuspended export
- utils: remove dead htmlToTextWithBlocks/decodeHtmlEntities/BLOCK_TAG_REGEX
- internalApi: remove router._allowedKeys (test it served is gone)
- discord client: drop unused GuildPresences privileged intent
- broccolini-discord: remove dormant /api 503 gate (no /api routes)

Fixes:
- context-menu ticket create now uses makeTicketName('unclaimed', ...)
  instead of the contract-violating ticket-<n> name
- drop write-only pending.userId from both close paths

Dedup / simplify:
- new services/transcript.js shares the transcript text/date/header
  builders between the button and force-close paths (had drifted)
- resolveEscalationCategoryId() replaces 3 copies of the category logic
- ticketChannelOverwrites() shares the create-permission array between
  the two interactive ticket-create paths
- finalizeBody() shares the email-cleanup tail in parseGmailMessage
- getTicketActionRow drops its never-passed options arg;
  sendTicketNotificationEmail drops its always-null subjectLine arg
- hoist invariant guild lookup out of the auto-close/unclaim loops
- drop redundant lastActivity write (and now-dead updateTicketActivity)
- /help lists all current commands and the right-click apps
2026-06-02 19:59:14 +00:00

160 lines
5.9 KiB
JavaScript

/**
* Right-click "Apps" menu commands:
* - "Create Ticket From Message" — turn a Discord message into a ticket.
* - "View User Tickets" — show last 10 tickets for the targeted user.
*/
const {
ChannelType,
EmbedBuilder,
MessageFlags
} = require('discord.js');
const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config');
const { getPriorityEmoji } = require('../../utils');
const { checkTicketCreationRateLimit, getOrCreateTicketCategory, makeTicketName } = require('../../services/tickets');
const { getTicketActionRow, ticketChannelOverwrites } = require('../../utils/ticketComponents');
const { enqueueSend } = require('../../services/channelQueue');
const { logError } = require('../../services/debugLog');
const Ticket = mongoose.model('Ticket');
async function handleCreateTicketFromMessage(interaction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const rateLimit = checkTicketCreationRateLimit(interaction.user.id);
if (!rateLimit.allowed) {
const mins = Math.ceil((rateLimit.retryAfterMs || 0) / 60000);
return interaction.editReply(`You can only create ${CONFIG.RATE_LIMIT_TICKETS_PER_USER} ticket(s) per ${CONFIG.RATE_LIMIT_WINDOW_MINUTES} minutes. Try again in ${mins} minute(s).`);
}
try {
const message = interaction.targetMessage;
const subject = `Message from ${message.author.tag}`;
const description = message.content || 'No content';
const guild = interaction.guild;
const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean();
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
const creatorNickname = message.member?.displayName || message.author.username;
const unclaimedName = makeTicketName('unclaimed', { ticketNumber }, creatorNickname);
let parentCategoryIdForTicket;
try {
parentCategoryIdForTicket = await getOrCreateTicketCategory(
guild,
CONFIG.DISCORD_TICKET_CATEGORY_ID,
CONFIG.TICKET_CATEGORY_NAME
);
} catch (err) {
console.error('getOrCreateTicketCategory (context menu ticket):', err);
return interaction.editReply('❌ Discord ticket category could not be resolved. Contact an administrator.');
}
let channel;
try {
channel = await guild.channels.create({
name: unclaimedName,
type: ChannelType.GuildText,
parent: parentCategoryIdForTicket,
permissionOverwrites: ticketChannelOverwrites(guild, message.author.id)
});
} catch (err) {
console.error('guild.channels.create (context menu ticket):', err);
return interaction.editReply('❌ Failed to create ticket channel. Contact an administrator.');
}
const gmailThreadId = `discord-msg-${Date.now()}-${message.id}`;
const now = new Date();
await Ticket.create({
gmailThreadId,
discordThreadId: channel.id,
senderEmail: message.author.tag,
subject,
createdAt: now,
status: 'open',
ticketNumber,
priority: 'normal',
lastActivity: now,
creatorId: message.author.id,
parentCategoryId: parentCategoryIdForTicket
});
const welcomeEmbed = new EmbedBuilder()
.setDescription(CONFIG.TICKET_WELCOME_MESSAGE)
.setColor(CONFIG.EMBED_COLOR_INFO);
const infoEmbed = new EmbedBuilder()
.setColor(CONFIG.EMBED_COLOR_INFO)
.addFields(
{ name: 'From message', value: `[Jump to message](${message.url})` },
{ name: 'Creator', value: message.author.toString(), inline: true },
{ name: 'Created by Staff', value: interaction.user.toString(), inline: true },
{ name: 'Content', value: description.slice(0, 1000) || 'No content', inline: false }
);
const row = getTicketActionRow({ escalationTier: 0 });
try {
const welcomeMsg = await enqueueSend(channel, {
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\nHey There ${message.author} 🥦`,
embeds: [welcomeEmbed, infoEmbed],
components: [row]
});
await Ticket.updateOne(
{ discordThreadId: channel.id },
{ $set: { welcomeMessageId: welcomeMsg.id } }
);
} catch (err) {
console.error('welcomeMessageId-save', err);
}
await interaction.editReply(`✅ Ticket created: ${channel}`);
} catch (err) {
logError('create-ticket-from-message', err, interaction).catch(() => {});
await interaction.editReply('❌ Failed to create ticket from message.');
}
}
async function handleViewUserTickets(interaction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
try {
const targetUser = interaction.targetUser;
const tickets = await Ticket.find({ senderEmail: targetUser.tag })
.sort({ createdAt: -1 })
.limit(10)
.lean();
if (!tickets || tickets.length === 0) {
return interaction.editReply(`📋 No tickets found for ${targetUser.tag}`);
}
const embed = new EmbedBuilder()
.setTitle(`📋 Tickets for ${targetUser.tag}`)
.setDescription(`Found ${tickets.length} ticket(s)`)
.setColor(CONFIG.EMBED_COLOR_INFO);
for (const ticket of tickets.slice(0, 5)) {
const priorityEmoji = getPriorityEmoji(ticket.priority || 'normal');
const statusEmoji = ticket.status === 'open' ? '🟢' : '🔴';
embed.addFields({
name: `${priorityEmoji} Ticket #${ticket.ticketNumber} ${statusEmoji}`,
value: `**Subject:** ${ticket.subject || 'No subject'}\n**Status:** ${ticket.status}\n**Claimed:** ${ticket.claimedBy || 'Unclaimed'}`,
inline: false
});
}
if (tickets.length > 5) {
embed.setFooter({ text: `Showing 5 of ${tickets.length} tickets` });
}
await interaction.editReply({ embeds: [embed] });
} catch (err) {
logError('view-user-tickets', err, interaction).catch(() => {});
await interaction.editReply('❌ Failed to fetch user tickets.');
}
}
module.exports = { handleCreateTicketFromMessage, handleViewUserTickets };