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
160 lines
5.9 KiB
JavaScript
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 };
|